Skip to content

test(pairing): pin what pairing does to the joining device's identity - #489

Merged
th3-br41n merged 1 commit into
mainfrom
fix/f492-inbox-resubscribe
Aug 3, 2026
Merged

test(pairing): pin what pairing does to the joining device's identity#489
th3-br41n merged 1 commit into
mainfrom
fix/f492-inbox-resubscribe

Conversation

@th3-br41n

Copy link
Copy Markdown
Contributor

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.identity is readonly, set once in the constructor. scanPayload calls saveIdentitySecret, which only writes the source's secret to the keystore for "the next VaultSession.open" (pairing-handlers.ts:153), and confirmSas signs its roster records with session.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.json records identityPublicKey once, at creation (vault.ts:172); every later writer only preserves it. openVault passes it as expectedPublicKeyBase64 (vault.ts:246-248), and the keystore now holds the source's secret, so VaultSession.open throws:

Vault <id>: identity public key in keystore does not match vault.json (possible tampering or wrong keystore).

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-493 is a design decision with three positions that each land the device on a different identity:

  • (a) rewrite the target's vault.json identity + 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 them
  • (b) refuse to join when the target already holds a non-empty vault
  • (c) real multi-identity support — out of v1 scope

10.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:packages clean; both cases green.

🤖 Generated with Claude Code

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
th3-br41n merged commit 6969153 into main Aug 3, 2026
2 of 3 checks passed
@th3-br41n
th3-br41n deleted the fix/f492-inbox-resubscribe branch August 3, 2026 22:37
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>
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