docs: record that Python's high-risk unlock writes the partition key to disk

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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
This commit is contained in:
thePR0M3TH3ANandClaude Opus 5 committed 2026-08-19 10:20:42 -04:00
1 parent 0c48c1f9dc
commit 727ddeff62
2 files changed
+69

No files matched your search

+39
View File
@@ -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
@@ -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),