From 727ddeff62774838629a3668addb623bab6e41b6 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN Date: Wed, 19 Aug 2026 10:20:42 -0400 Subject: [PATCH] docs: record that Python's high-risk unlock writes the partition key to disk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agent_high_risk_unlock.json stores the session's partition_key_tag, and the partition file's encryption key is derived from exactly that value. While a session is live the partition therefore decrypts from disk with no factor, for anything that can read APP_DIR — and the session file sits beside the key envelope, so reading one implies reading the other. That is the guarantee the partition exists to provide. Documented at grant_high_risk_unlock, where it happens, rather than only in a tracking file. The exposure is inherent to on-disk session state in a CLI with no resident process, and encrypting the session under the master password or seed does not fix it — that would make the password alone sufficient, which is what the second factor is meant to prevent. The two real fixes are product decisions (drop the session model, or give Python a resident agent), so they are recorded rather than chosen. Interim guidance: short TTLs, high-risk-lock when finished, treat read access to APP_DIR as equivalent to holding the factor. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN --- TODO.md | 39 +++++++++++++++++++++ src/seedpass/core/agent_secret_isolation.py | 30 ++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/TODO.md b/TODO.md index 75d5fc70..c48b4e3f 100644 --- a/TODO.md +++ b/TODO.md @@ -269,6 +269,11 @@ the review independent in the sense that matters. TUI, and a new `util check-derivation`. The lead test in each derives the keys and compares bytes, so the claim is verified rather than asserted. **The derivation change is deliberately deferred**, see the v2 design below. +- [x] **(ported 2026-08-19) High-risk partitions and approval gates.** Both + file formats byte-compatible with Python and verified in both + directions. The unlock session diverges deliberately — see the security + finding above. `agent high-risk factor-set/status/unlock/lock/migrate`, + `agent approval issue/list/revoke`, and the /api/v1/high-risk routes. - [x] **(surfaced 2026-08-19) L-2 / L-3 — merge-level loss that is silent rather than wrong.** Both implementations gained an optional `MergeReport`: same-id conflicts (naming the kept and discarded entries) and a count of tombstones evicted at the @@ -430,6 +435,40 @@ status` reporting v1 means "rebuild this, it holds secrets". "cannot tell" must not mean "delete it", or a corrupt-manifest read would throw away a good index. +### Security finding: Python's high-risk unlock puts the partition key on disk + +Found 2026-08-19 while porting the feature, and verified empirically. + +`agent_high_risk_unlock.json` records the live session's `partition_key_tag`, +and `high_risk_partition_store._fernet_for_tag` derives the partition file's +encryption key from exactly that value. So while a session is live, the +high-risk partition decrypts from disk alone with **no factor**, for anything +that can read `APP_DIR` — and the session file lives in the same directory as +the key envelope, so being able to read one implies being able to read the +other. + +That is the guarantee the partition exists to provide: ssh/pgp/seed/nostr +entries are the kinds judged to need a SECOND factor beyond the master +password, and during an unlock they effectively need none. + +It is inherent to on-disk session state in a CLI with no resident process: +"unlocked for a TTL without re-supplying the factor" means the key must be +recoverable by the next process, and here that is the filesystem. Encrypting +the session under the master password or the seed does not help — then the +password alone reaches the partition, which is the thing the second factor is +supposed to prevent. + +`grant_high_risk_unlock` now documents this at the point it happens. The +TypeScript port keeps the tag in the session agent's memory instead and never +writes it (asserted by a test that scans every file in the app directory). + +- [ ] **Decide the Python fix.** Two real options, both product decisions: + (a) drop the session model and require the factor per operation, or + (b) give Python a resident agent for this (the TS agent already is one). + Interim guidance is in the docstring: short TTLs, `high-risk-lock` when + finished, treat read access to APP_DIR as equivalent to holding the + factor. + ### Unbuilt milestones - [ ] **Milestone 6 (static/PWA web app)** and **Milestone 7 (browser diff --git a/src/seedpass/core/agent_secret_isolation.py b/src/seedpass/core/agent_secret_isolation.py index cec4a163..bef41bbf 100644 --- a/src/seedpass/core/agent_secret_isolation.py +++ b/src/seedpass/core/agent_secret_isolation.py @@ -183,6 +183,36 @@ def unwrap_high_risk_partition_key(factor: str) -> str: def grant_high_risk_unlock( *, fingerprint: str, ttl_seconds: int, partition_key_tag: str | None = None ) -> dict[str, Any]: + """Record a high-risk unlock session. + + .. warning:: + + The session record contains ``partition_key_tag``, and the partition + file's encryption key is derived from exactly that value + (``high_risk_partition_store._fernet_for_tag``). So while a session is + live, the high-risk partition can be decrypted from disk alone, with no + factor, by anything able to read ``APP_DIR`` -- and the session file + sits in the same directory as the partition's key envelope, so being + able to read one implies being able to read the other. + + That weakens the guarantee the partition exists to provide: these are + the entry kinds judged to need a SECOND factor beyond the master + password, and for the duration of an unlock they effectively need no + factor at all. + + This is inherent to keeping session state on disk in a CLI with no + resident process: "unlocked for a TTL without re-supplying the factor" + means the key must be recoverable from somewhere the next process can + reach, and here that somewhere is the filesystem. The TypeScript port + keeps the tag in its session agent's memory instead (see + js/packages/cli/src/highRisk.ts). Fixing it here needs either a + resident agent or dropping the session model in favour of supplying the + factor per operation -- a product decision, recorded in TODO.md rather + than made silently. + + Until then: keep TTLs short, run ``high-risk-lock`` when finished, and + treat read access to APP_DIR as equivalent to holding the factor. + """ now = _utcnow() rec = { "fingerprint": str(fingerprint),