Skip to content

fix(pairing): adopt the joined identity, and refuse to re-point a used vault (F-492/F-493) - #509

Merged
th3-br41n merged 2 commits into
mainfrom
fix/f492-f493-pairing-identity
Aug 4, 2026
Merged

fix(pairing): adopt the joined identity, and refuse to re-point a used vault (F-492/F-493)#509
th3-br41n merged 2 commits into
mainfrom
fix/f492-f493-pairing-identity

Conversation

@th3-br41n

Copy link
Copy Markdown
Contributor

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:

  • 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.
  • 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

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_TYPES says 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.json alongside the keystore

The 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

main has been failing biome check since #489, which ran typecheck and tests but not lint. One formatting commit, included here because it blocks CI on everything else.

Not closed

revokedAt is 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:packages clean · lint clean (all 8 ratchets) · 1105 tests green across pairing/, 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

th3-br41n and others added 2 commits August 4, 2026 02:07
…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>
@th3-br41n
th3-br41n merged commit e168b48 into main Aug 4, 2026
2 of 3 checks passed
@th3-br41n
th3-br41n deleted the fix/f492-f493-pairing-identity branch August 4, 2026 00:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant