Envelope-encrypt the vault: one DEK, per-principal wrapped keys #146
No reviewers
Labels
No labels
bug
duplicate
enhancement
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lz/agent-nexus!146
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/mcp"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes the blocker on #140.
What changes
Before, the AES key sealing
connections.encrypted_tokenandrepo_env_vars.encrypted_valuewas the Argon2id output of the master passphrase. Key and credential were 1:1, which made rotation O(rows) and a second credential impossible.Now one random 32-byte DEK seals all data, and each principal holds a copy of that DEK wrapped under a KEK derived from its own credential:
seal/openandderiveKeyare untouched — only what key is handed to them changes. New tablevault_keys(migration 0020) holds one row per principal; exactly one exists today,('master','master'). Multi-user is now a row insert, which is the entire point.Consequences:
wrapped_dek. A second thing that can disagree with the first is gone.locals.masterKeyis now the DEK, and every one of its ~40 consumers is opaque to the change — they only ever hand aBuffertoseal/open.Design:
docs/superpowers/specs/2026-09-11-vault-envelope-encryption-design.md. Mechanics recorded as AGENTS.md fact #27.The legacy upgrade
It cannot be a migration — migrations run at boot, and the key only exists once the operator types the passphrase. So it runs lazily on the first successful unlock, re-sealing both columns KEK→DEK, inserting the principal with the existing salt/params, and deleting
meta.verifier, all in one transaction. Measured: unwrap that transaction and a throw partway through leaves rows sealed under a DEK that was never persisted, while the surviving verifier still reports the install as legacy — neither key opens them.⚠️ The upgrade is one-way. Once it has run, reverting to the pre-envelope image leaves an install no passphrase opens through the UI. Back up
nexus-databefore deploying.Review
SDD per-task reviews, a whole-feature review, and a final
/pr-reviewpass (six agents). The last pass found three guards that could not fail, each fixed and each proven to fail before the fix:Buffer.from()copy had no test. Removing it left all 69 tests green while seating the operator's new session on 32 zero bytes —destroyAll()zeroizes session keys andSessionStore.createstores by reference. Every secret would fail to open immediately after a rotation, silently.unlockMasterPassphrase's fall-through re-read into its catch left all 70 tests green while reporting a correct passphrase as wrong for the race shape that returns null instead of throwing.sealed-columns.test.tsis satisfied by a bareselect()naming the column — now stated honestly in the test rather than overclaimed.It also fixed two real defects:
setupMasterPassphrasewas check-then-upsert on an unauthenticated route, so a concurrent setup could silently replace the winner's wrapper (writePrincipalis now split intoinsertPrincipal/upsertPrincipal, making the rule structural and deleting the ten-line comment that used to defend it); and the race catch calledreadPrincipal, whoseJSON.parsecould throw aSyntaxErrorthat replaced the damage error being judged.Six false comments were corrected — three written on this branch and falsified by a later commit on the same branch, which is the class per-task review structurally cannot see. Including a wrong security claim (a shared salt does not make one principal's KEK derivable from another's parameters; it amortises one Argon2id precomputation), also corrected in the design doc.
Comment density:
vault-envelope.ts29%→22%, migration 31%→18%,sealed-columns.test.ts43%→34%,vault-store.ts38%→30%. Most of what went was a third copy of arguments already in the design doc and fact #27.Safety check on the live instance
There was a real window where rotation re-sealed
connectionsbut notrepo_env_vars:c8aa70b(2026-07-16 19:37Z) →c472266(2026-07-17 10:13Z), both onmain. A row left dead by that window would, after this branch, make every unlock throw an uncaught 500 — permanent lockout, recoverable only by hand-editing SQLite.Checked before opening this PR: the instance is clear. One
repo_env_varsrow, created 2026-07-17T17:24Z — 7.2 hours after the fix, outside the window.Follow-ups filed, not fixed here
#142 (one unopenable row bricks unlock — the throw is right, the uncaught 500 is not), #143 (a lost
vault_keysrow routes to/setup, concealing the loss), #144 (rotation reports failure for a rotation that succeeded), #145 (deadctEqual,PrincipalKindunion).Gates
1532 tests passing (146 files), typecheck 0 errors across 4944 files, lint clean.
Rotation's unlock moves inside the try, so a corrupt row on a legacy install returns {ok:false, reason:'error'} with the real message instead of rejecting into SvelteKit's raw Internal Error, which _handleRotate does not catch. RotateResult.newKey becomes .key: it is the same DEK the caller already held, and the old name invited a caller to assume stale sessions were already dead by virtue of the key changing. store.destroyAll() carries that alone. The rotate docstring regains the residual-risk disclosure the plan mandated — a DEK exfiltrated from memory survives a rotation. The race-swallow branch gains a logger.warn, the only evidence it can leave; no test reaches it. Two comments stated things that were not true of the code beneath them, and the legacy on-disk shape is now declared once in vault-envelope.ts and read by both hand-built fixtures.