Skip to content

fix(pairing): roster verification, identity adoption, and a refusal that protects existing vaults - #508

Merged
th3-br41n merged 1 commit into
mainfrom
feat/lan-2b-roster-verify
Aug 3, 2026
Merged

fix(pairing): roster verification, identity adoption, and a refusal that protects existing vaults#508
th3-br41n merged 1 commit into
mainfrom
feat/lan-2b-roster-verify

Conversation

@th3-br41n

@th3-br41n th3-br41n commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

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-492 and F-493.

1. Roster signatures are verified on the read path (LAN-2b)

verifyAddDeviceRecord existed from the start but only vault-validate called 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.

listActive verifies under this vault's identity key and drops what fails. The key is a required parameter, not an option — which immediately paid for itself: tsc surfaced a third consumer grep had missed, the LAN admission roster at index.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:

if (keyBytesEqual(input.senderKey, input.selfPub)) return true;

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_TYPES states 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.json is 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

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, and is recorded rather than bolted on.

Verification

typecheck:packages clean · lint clean (all 8 ratchets) · 1103 tests green across pairing/, 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.ts planted sig: "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

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>
@th3-br41n
th3-br41n merged commit 33b501d into main Aug 3, 2026
2 of 3 checks passed
@th3-br41n
th3-br41n deleted the feat/lan-2b-roster-verify branch August 3, 2026 23:47
@th3-br41n th3-br41n changed the title fix(pairing): verify roster signatures on the read path (LAN-2b) fix(pairing): roster verification, identity adoption, and a refusal that protects existing vaults Aug 4, 2026
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>
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