fix(pairing): roster verification, identity adoption, and a refusal that protects existing vaults - #508
Merged
Merged
Conversation
Closes the LOW the 10.3c key-distribution gate raised. `verifyAddDeviceRecord` has existed from the start, but only `vault-validate` ever called it — so the two consumers that decide real access trusted whatever the properties doc happened to contain. 10.3c is what makes that matter. Before it, a forged roster row bought LAN admission; now it buys every entity DEK in the vault, sealed to a key of the attacker's choosing. `listActive` now verifies each row under this vault's identity key and drops what fails. The key is a REQUIRED parameter rather than a constructor option so a new call site cannot silently opt out — which immediately paid for itself: `tsc` surfaced a third consumer grep had missed, the LAN admission roster in `index.ts`, which was handing the raw store to the handshake. That is the most security-relevant caller of the three. `list()` stays unverified on purpose: Settings must still be able to SHOW a bad row so the user can remove it. Verification belongs on the paths that grant access, not the path that renders. The fan-out fixture needed a real signature — it planted `sig: "test-signature"`, which is precisely the forged-row shape this rejects, so that test had been asserting fan-out against a roster the owner never vouched for. Not closed here: `revokedAt` is outside the signed payload, so revocation state stays forgeable by anyone who can already write the vault (local compromise). It belongs with LAN-2b(d) rotate-on-revoke, which is an owner decision, and is recorded rather than bolted on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ra3orblade
pushed a commit
that referenced
this pull request
Aug 25, 2026
Closes the LOW the 10.3c key-distribution gate raised. `verifyAddDeviceRecord` has existed from the start, but only `vault-validate` ever called it — so the two consumers that decide real access trusted whatever the properties doc happened to contain. 10.3c is what makes that matter. Before it, a forged roster row bought LAN admission; now it buys every entity DEK in the vault, sealed to a key of the attacker's choosing. `listActive` now verifies each row under this vault's identity key and drops what fails. The key is a REQUIRED parameter rather than a constructor option so a new call site cannot silently opt out — which immediately paid for itself: `tsc` surfaced a third consumer grep had missed, the LAN admission roster in `index.ts`, which was handing the raw store to the handshake. That is the most security-relevant caller of the three. `list()` stays unverified on purpose: Settings must still be able to SHOW a bad row so the user can remove it. Verification belongs on the paths that grant access, not the path that renders. The fan-out fixture needed a real signature — it planted `sig: "test-signature"`, which is precisely the forged-row shape this rejects, so that test had been asserting fan-out against a roster the owner never vouched for. Not closed here: `revokedAt` is outside the signed payload, so revocation state stays forgeable by anyone who can already write the vault (local compromise). It belongs with LAN-2b(d) rotate-on-revoke, which is an owner decision, and is recorded rather than bolted on. 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.
Three security fixes in the pairing/roster path, landed together because they are one thread: what a roster row is worth, and what adopting an identity actually transfers. Closes the 10.3c gate's LOW plus
F-492andF-493.1. Roster signatures are verified on the read path (LAN-2b)
verifyAddDeviceRecordexisted from the start but onlyvault-validatecalled it, so the consumers that decide access trusted whatever the properties doc held. 10.3c changed what that is worth: a forged row used to buy LAN admission; now it buys every entity DEK in the vault.listActiveverifies under this vault's identity key and drops what fails. The key is a required parameter, not an option — which immediately paid for itself:tscsurfaced a third consumer grep had missed, the LAN admission roster atindex.ts, handing the raw store to the handshake.list()stays unverified so Settings can still show a bad row to remove it.2. Joining a vault that already holds your work is refused (F-493)
Adopting an identity is an authority transfer.
authorizesWrapInstall's first rule:Any frame whose sender equals this vault's own sovereign key may install or rotate a DEK on any entity, bypassing the Owner check. So re-pointing a populated vault would hand the other device unconditional 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. The realistic path there is a user talked into scanning a hostile pairing code.
Classification is by provenance, not type:
SYSTEM_ENTITY_TYPESstates 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.3. The identity is actually adopted (F-492)
vault.jsonis updated alongside the keystore — they must name the same identity or the next open throws, which is the bug that left a joined device unable to open its vault at all. Safe only because (2) 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.Then the vault re-opens so the session is rebuilt around the adopted identity atomically. 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.
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, and is recorded rather than bolted on.Verification
typecheck:packagesclean ·lintclean (all 8 ratchets) · 1103 tests green acrosspairing/,collab/,sync/,storage/.New 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.
One fixture had to change —
sibling-wrap-fanout.test.tsplantedsig: "test-signature", precisely the forged-row shape now rejected, so it had been asserting fan-out against a roster the owner never vouched for.🤖 Generated with Claude Code