fix(pairing): adopt the joined identity, and refuse to re-point a used vault (F-492/F-493) - #509
Merged
Merged
Conversation
…d vault F-492 and F-493 are one defect with two faces: pairing installs the source's sovereign identity into the target's keystore and nothing else follows. The running session keeps its pre-pairing identity (`VaultSession.identity` is readonly, set once), so the device subscribes the wrong `inbox:`, sends under the wrong `sender`, and fails the self-identity branch in `authorizesWrapInstall` — it pairs, reports success, and syncs nothing. Then `vault.json` still names the old identity, so the NEXT open throws on the mismatch guard and the vault will not open at all. Three changes, in the order the security argument requires. 1. REFUSE when the vault is not pristine (`pairing/vault-pristine.ts`). Adopting an identity is an authority transfer: `authorizesWrapInstall` admits any frame whose sender equals this vault's own sovereign key, bypassing the Owner check, so re-pointing a vault that already holds the user's work would hand the other device unconditional DEK-ROTATION authority over content it was never a member of — and rotation is worse than read, because the victim then emits under a key the attacker holds. Classification is by provenance, not type: `SYSTEM_ENTITY_TYPES` says of itself that it is presentation-only and must never change semantics, so it is the wrong input. Bootstrap principals are a closed set; an unrecognised one counts as user content, so a future seeder that forgets to register makes pairing refuse rather than silently permit. 2. ADOPT in `vault.json` alongside the keystore write. The two must name the same identity or the re-open throws. Safe only because (1) ran first: the identity check is the vault's tamper-evidence for key substitution, and it is now rewritten only when the check has proven there is nothing behind it to protect. 3. RE-OPEN after the pair completes, so the session is rebuilt around the adopted identity in one atomic step. A hot swap would leave the old key in some components and the new one in others inside an authorization path. Deliberately after `paired()`: a re-open failure costs a restart, never an un-paired device holding a half-adopted key. Plus the consent surface: the confirm step now names the identity being adopted by fingerprint. The SAS proves the channel is not relayed; it says nothing about WHOSE identity is on the far end, which is the thing actually being consented to. Not closed: `revokedAt` is still outside the signed payload (LAN-2b(d)). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merged unformatted in #489 — that PR ran typecheck and tests but not lint, so `main` has been failing `biome check` since. Formatting only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Re-opened work that #508's squash merge missed. #508 merged while I was still pushing to its branch, so it captured only the roster-verification commit — the entire F-492/F-493 fix was left behind. Cherry-picked onto current
main, re-verified from scratch.F-492 and F-493 are one defect with two faces. Pairing installs the source's sovereign identity into the target's keystore and nothing else follows:
VaultSession.identityisreadonly, set once), so the device subscribes the wronginbox:, sends under the wrongsender, and fails the self-identity branch inauthorizesWrapInstall. It pairs, reports success, and syncs nothing.vault.jsonstill names the old identity, so the next open throws on the mismatch guard and the vault will not open at all.Three changes, in the order the security argument requires.
1. Refuse when the vault is not pristine
Adopting an identity is an authority transfer.
authorizesWrapInstall's first rule admits any frame whose sender equals this vault's own sovereign key, bypassing the Owner check — so re-pointing a populated vault hands the other device unconditional DEK-rotation authority over content it was never a member of. Rotation is worse than read: the victim then emits under a key the attacker holds. The realistic path is a user talked into scanning a hostile pairing code.Classification is by provenance, not type.
SYSTEM_ENTITY_TYPESsays of itself that it is presentation-only and must "never change query or filtering semantics", so it is the wrong input for a refusal. Bootstrap principals are a closed set; an unrecognised one counts as user content, so a future seeder that forgets to register makes pairing refuse rather than silently permit.2. Adopt in
vault.jsonalongside the keystoreThe two must name the same identity or the re-open throws. Safe only because (1) runs first: that field is the vault's tamper-evidence for key substitution, and it is now rewritten only once the pristine check has proven there is nothing behind it to protect.
3. Re-open after the pair completes
The session is rebuilt around the adopted identity in one atomic step. A hot swap would leave the old key in some components and the new one in others, inside an authorization path. Deliberately after
paired(): a re-open failure costs a restart, never an un-paired device holding a half-adopted key.Consent surface
The confirm step now names the identity being adopted, by fingerprint. The SAS proves the channel is not relayed; it says nothing about whose identity is on the far end — which is the thing actually being consented to.
Also: fixes main's red lint
mainhas been failingbiome checksince #489, which ran typecheck and tests but not lint. One formatting commit, included here because it blocks CI on everything else.Not closed
revokedAtis outside the signed payload, so revocation state stays forgeable by anyone who can already write the vault. That belongs with LAN-2b(d) rotate-on-revoke, which is an owner decision.Verification
Re-run against current
main, not inherited:typecheck:packagesclean ·lintclean (all 8 ratchets) · 1105 tests green acrosspairing/,collab/,sync/,storage/.Tests pin: a fresh install is pristine; one user note is not; an unknown principal fails closed; the refusal happens before the identity secret is written; the re-open fires on confirm and not on scan; a re-open failure still leaves the device paired.
🤖 Generated with Claude Code