test(pairing): pin what pairing does to the joining device's identity - #489
Merged
Conversation
Two cases, both against real `VaultSession`s, written to settle F-492's root cause before writing a fix — and they disprove the fix I had filed. 1. `saveIdentitySecret` (what `scanPayload` calls) does NOT change the live session. `VaultSession.identity` is readonly, set once in the constructor, so the joining device keeps its pre-pairing identity for the whole session — which is why it stays subscribed to `inbox:<old key>` while the source fans 10.3c wraps out to the shared identity's inbox. Re-deriving the inbox at pairing time would recompute the same stale channel. 2. The next `VaultSession.open` on that device then REJECTS. `vault.json` records `identityPublicKey` once, at vault creation, and every later writer only preserves it; `openVault` passes it as `expectedPublicKeyBase64`, and the keystore now holds the source's secret. So joining a device that has its own vault leaves that vault unopenable at next launch (filed F-493). No production change here on purpose. F-493 is a design decision with three positions that each land the device on a different identity, and 10.3c's own bullet requires a key-distribution mechanism to be designed before it is written. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
th3-br41n
added a commit
that referenced
this pull request
Aug 4, 2026
…d vault (F-492/F-493) (#509) * fix(pairing): adopt the joined identity, and refuse to re-point a used 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> * style: format the F-492 adoption test 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> --------- 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.
Written to settle
F-492's root cause before writing a fix — and it disproves the fix I had filed. No production change here on purpose.What the two cases pin
1. The live session never adopts the shared identity.
VaultSession.identityisreadonly, set once in the constructor.scanPayloadcallssaveIdentitySecret, which only writes the source's secret to the keystore for "the nextVaultSession.open" (pairing-handlers.ts:153), andconfirmSassigns its roster records withsession.getUserIdentity()— the target's own key. Nothing reopens the vault; the join UI goes straight to "Joined".So the joining device keeps its pre-pairing identity for the whole session, which is exactly why it stays subscribed to
inbox:<old key>while the source fans 10.3c wraps to the shared identity's inbox. Re-deriving the inbox at pairing time — the fix F-492 proposed — would have recomputed the same stale channel.2. The next vault open then REJECTS.
vault.jsonrecordsidentityPublicKeyonce, at creation (vault.ts:172); every later writer only preserves it.openVaultpasses it asexpectedPublicKeyBase64(vault.ts:246-248), and the keystore now holds the source's secret, soVaultSession.openthrows:Joining a device that has its own vault leaves that vault unopenable at next launch. Filed as
F-493— loss of access to an existing vault, not a sync gap.Why no fix in this PR
F-493is a design decision with three positions that each land the device on a different identity:vault.jsonidentity + reopen — adopts the shared identity, but silently repoints a vault whose entities were all created under the OLD identity (createdBy, access grants, signatures) and orphans them10.3c's own bullet says a key-distribution mechanism is designed before it is written. This wants an owner position and an OQ first.The v1 premise is stated in the code itself (
pairing-handlers.ts:153): "the target's vault.json is the same logical vault as the source's, just opened on a different device after the user copies it across". That holds only if the user manually copied the vault folder first — nothing says so, nothing enforces it, and Settings → Devices → Join is reachable from any device with its own vault.typecheck:packagesclean; both cases green.🤖 Generated with Claude Code