Skip to content

Release 9.0.0 - #36

Merged
holmesworcester merged 75 commits into
mainfrom
release/9.0.0
Sep 10, 2026
Merged

holmesworcester merged 75 commits into
mainfrom
release/9.0.0

Conversation

@holmesworcester

Copy link
Copy Markdown

Auth changes shipped in Quiet 9.0.0. Fast-forward of main.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Sgp2oytFeVY4yEjJrymEMZ

holmes and others added 30 commits August 17, 2026 08:41
A link's body was an unauthenticated author claim: `body.userId` was plaintext,
and the crypto_box around it authenticates nothing about authorship, since it is
a Diffie-Hellman construction keyed to the graph keyset that every reader holds.

Links now carry `body.signer: SignerInfo` plus a signature over the link hash,
made with the authoring identity's signing key. crdx stays agnostic about what a
signer is: it carries the claim, exposes `verifyLinkSignature`, and leaves it to
the application to resolve a signer id to a public key and decide what that
signer may do.

Also fixes three fail-open paths found alongside it:

- `decryptGraph` reused a caller-supplied plaintext `links` entry instead of
  decrypting the ciphertext, so a supplied body could stand in for the bytes the
  hash actually covers.
- `makeMachine` discarded the result of `validate()`, so an invalid graph was
  reduced anyway. It now throws, and `Store` derives the next graph and state
  before installing either, so a rejected dispatch or merge leaves the store as
  it was.
- The base validators were memoized on `${link.hash}:${graph.root}`, a key that
  ignores graph contents — a tampered graph inherited the passing verdict of the
  clean one it came from. This fixes four validator tests that were failing.

Reducers now receive the graph as an optional fourth argument, for applications
that need to resolve a link's signer against registrations elsewhere in it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nvitations

Devices and servers now name themselves with a fingerprint of their public signature
key instead of a random id or a hostname, so any validator can recompute the id from
the registered keyset rather than trusting a claimed label.

- createDevice derives its keyset from the seed under a fixed label, then takes its
  deviceId from the resulting signature key (no more cuid2 for devices)
- servers split into an immutable identity keyset (serverId, signs links) and a
  rotatable keyset (lockbox recipient); host becomes display-only, and castServer
  loses toDevice in favor of toSigner
- invitation proofs bind the invitation id, both handshake nonces, and every public
  key being registered, so they can't be replayed or reused for other keys
- new possession proofs, signed by the device being registered, provide the binding
  an invitation proof can't: the inviter knows the seed and can forge invitation
  proofs, but not possession of the invitee's device keys

Team and connection layers don't compile against this yet; they're the next wave.
Both modules existed but were never re-exported, so the six tests importing
them from graph/index.js got undefined. With this and the validator
de-memoization, the crdx suite is fully green (224/224).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Authorization used to key off `link.body.userId`, which is plaintext the
author wrote themselves. Every rule now runs against a *derived* author:
resolve `body.signer` to a registered device or server, recompute that
identity's fingerprint from its own keys, verify the link's signature
against them, confirm the signer is still active, and only then ask
whether the action is allowed.

- team/validate.ts: signer resolution + signature verification precede
  every authorization rule; new rules for global id uniqueness across
  members/devices/tombstones/servers, servers may only author ADMIT_*,
  CHANGE_SERVER_KEYS may only touch the rotatable keyset, and admissions
  must carry a valid invitation proof *and* a possession proof.
- membershipResolver.ts: attribution comes from a signer->member map
  built from the graph's registration links. Removal wins over what a
  concurrently-removed device did; mutual device removals resolve by
  registration order; member removal reaches every device they sign with.
- ADD_DEVICE is deleted. A device is registered by ROOT, by ADD_MEMBER
  (testing only), or by ADMIT_DEVICE — never by itself. `join()` now
  posts lockboxes rather than registering the joining device.
- ADMIT_MEMBER/ADMIT_DEVICE carry {proof, claim, possessionProof} so
  every replica re-derives the admitted identity from signed material.
- Devices carry admittedAt/removedAt; removal tombstones an id forever.
  Servers are keyed by serverId; host is a display label only, and no
  longer shadows the device namespace.
- Root metadata moves into the ROOT payload, so creating a team is one
  link instead of two.

Team tests: 128 passed / 5 failed (the 5 are connection-layer timeouts,
next wave). Baseline for these files was 9 failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The private#46 proof-of-concept suite, rewritten for the refactor. Every
forgery used to lie by writing a victim's userId into `link.body.userId`;
now the lie has to move to `body.signer.id`, and the attacker signs with
their own keys — which is exactly what the new validator catches, because
it recomputes the fingerprint for that id and checks the signature against
the key the graph registered for it.

Ported from 7e0cfe3 (backup/private-46-pre-pr28-rebase) with the forging
helper rewritten: forgedAuthorship, forgedRemoval, forgedChannelTakeover,
forgedChannelTakeoverByServer, forgedByNonMemberConstruction, and the
hand-built-EncryptedLink fixture from authorSignatures. New files cover the
rest of spec §10: signer identity (unknown / concurrent-branch / fingerprint
mismatch / forged root), link integrity (swapped ciphertext, swapped
signature, replayed signature, DH sender-key swap, injected plaintext),
registration (duplicate, tombstoned and cross-namespace ids; claim
substitution; kind mismatch; missing, mismatched and replayed possession
proofs), the stolen-device removal race, and the honest round trip.

Each forgery is asserted rejected on all three acceptance paths — live merge
by a third party, live merge by the impersonated victim, and cold load from
both serialized bytes and the object form — with a specific message, so a
decrypt error or a malformed payload can't pass for a security check. Every
group carries a CONTROL proving the honest equivalent still succeeds.

55 tests in 10 files, all passing; team suite 188/188.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…he handshake

An invitee now presents the exact identity it wants registered — a member's
userName, keys and first device, or a first-use device — together with a proof
of invitation bound to both peers' nonces and a possession proof only that
device can produce. The admitting peer passes all three verbatim to
admitMember/admitDevice, so every replica re-derives the admitted identity from
the same signed material.

REQUEST_IDENTITY carries the acceptor's nonce, since an invitee can't build its
proof until that nonce arrives. The acceptor checks the proof was bound to its
own nonce, so a proof seen on one connection can't be replayed on another.

Servers authenticate as { SERVER_IDENTITY, serverId } and sign challenges with
their immutable identity keys; nothing keys off host any more, and a server is
no longer disguised as a device.

Also in here, all of it needed to get a connection to complete at all:

- Only grant the 'member' role if the team actually defines it. Assigning a
  role that doesn't exist throws, and that throw escaped into the xstate actor
  and killed every connection in the suite.
- Sync with the whole team keyring rather than the current generation. Now that
  a merge re-decrypts the graph, links written before a key rotation — the root,
  always — need the generation they were written under.
- Treat any failure to decrypt a seed or a message as ENCRYPTION_FAILURE. The
  string matching on cipher error messages hadn't matched in a while, so those
  cases surfaced as UNHANDLED.
- Emit 'remoteError' when a peer reports an error. Nothing ever emitted it.
- Derive team state from an invitation acceptance once, in an action, so the
  guards that decide whether to join are total; check that our own device was
  registered, which covers first-use devices as well as new members.
- Fail cleanly when an invitee's identity can't be registered: a tombstoned
  device or a removed member gets DEVICE_REMOVED / MEMBER_REMOVED.

auth-syncserver builds its identity with createServer and a LocalServerContext,
persists it, and serves its public record at /keys. AuthProvider takes either a
device or a server identity.
…esc)

Granting the admin role was in the non-admin action list, so mustBeAdmin never
gated it and canOnlySelfAddCertainRoles only restricted *self*-assignment. Any
plain member could therefore promote another member (or a colluder) to admin,
who could then evict the real admins — a full team takeover from two ordinary
accounts. The escalation link is honestly signed, so device-signed authorship
does nothing against it; this is an authorization-policy hole.

This bug pre-dates the device-signed-links work (byte-identical at d97d077), but
it's cheap and self-contained, so we fix it here rather than leave it live.
Granting admin is now itself an admin-only action; self-assignment stays gated
separately. Concurrent-escalation and the broader 'any member can grant any
role to another' question are left for the resolver-convergence work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…date

Trigger 2a (the flaky forgedSignerIdentity test): when a device signs a
link rooted *before* its own registration, that registration and the link
are concurrent after merge. resolveAuthor resolves the signer against
previousState, so whether the device counts as "registered yet" depended
on the topo-sort tiebreak. In the unlucky order it threw "not registered"
inside the reducer, and because that throw is uncaught the WHOLE graph
failed to load -- a non-deterministic DoS (~7/8 runs).

Fix it in the resolver, deterministically. New rule
requireSignerRegisteredInCausalPast drops (marks isInvalid) any link whose
signer's registration is not a causal ancestor of that link, using
isPredecessorHash over the graph rather than sort order, so every replica
agrees. Registration = the link that introduces body.signer.id: ROOT for
the founding device, ADD_MEMBER for a member's devices,
ADMIT_MEMBER/ADMIT_DEVICE for invited registrations, ADD_SERVER for a
server (getSignerRegistrationMap). Dropped links never reach the reducer,
so resolveAuthor never throws; the existing findDependentLinks cascade
handles anything depending on a dropped link. A signer with no
registration anywhere is left alone -- a genuine unknown signer, still
rejected by resolveAuthor.

HAZARD AVOIDED: resolveAuthor still resolves signers against previousState.
It is NOT switched to the whole-graph getSignerUserMap -- that map includes
registrations the resolver invalidates, so authorizing against it would
reintroduce a real forgery (link authorized by a later-invalidated
registration). The resolver only ever DROPS links; authorization stays on
previousState. The two layers remain distinct.

Test rewritten to the secure expectation: forgery half (registration
absent) stays rejected as unknown signer; control half (both branches
merged) now expects the device registered (hasDevice === true) but the
pre-registration action dropped (hasRole('managers') === false), and the
graph loads cleanly either way.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A use-count is a consensus value, and a local-first hash-DAG has no consensus
to enforce it. Under concurrency two branches each see uses < maxUses and both
admit; a lowest-hash tiebreak on the resulting collision is grindable by an
attacker who holds the seed, so single-use was never actually enforceable.

Invitations are now bounded only by expiration and revocation — both per-link,
admin-authored facts that need no global agreement. Removes the maxUses field
from Invitation/InvitationState, its population in create/inviteMember/
inviteDevice, and the now-dead `uses` counter (useInvitation transform +
InvitationState.uses), which nothing reads once the maxUses check is gone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A userId is no longer a chosen label — it's deriveUserId(foundingDeviceId) =
hash(USER_ID_DOMAIN, deviceId), Base58. This is uniform for the founder (whose
founding device is the root device) and for every admitted member (whose
founding device is the one carried in their ADMIT_MEMBER claim). There is no
invitation component and no founder special case. USER_ID_DOMAIN is separated
from the signer-fingerprint domain, so a userId can never equal any deviceId.

Because the id is now recomputable from the device, it is self-certifying:
- validateRoot checks rootMember.userId === deriveUserId(rootDevice.deviceId);
- a new validator checks ADMIT_MEMBER's claim.memberKeys.name ===
  deriveUserId(claim.device.deviceId).
Two distinct members therefore can never collide on an id (their founding
devices differ), the way device and server ids are already unique by
fingerprint.

`deriveUserId` is exported from the public API — a consumer minting a new
identity must derive the userId from the device (mint the device first; its id
is the fingerprint of its own keys, independent of any owner). The auth,
syncserver and automerge-repo-provider test harnesses (and the createTestUser
helper) all do this now; deriveUserId's domain separation has a unit test.

DEVIATION from the change plan: the plan also called for deleting
`registeredIdsAreUnique` / `identityIdIsInUse` on the theory that ids are now
intrinsically unique. They are NOT deleted here. That theory holds for ROOT and
ADMIT_MEMBER (now derivation-checked) but not for ADD_MEMBER / ADD_SERVER,
which register arbitrary ids and are the vector the forged-registration suite
guards (a malicious admin copying another device's record, re-registering a
tombstoned id, or colliding a member id with a device id). Those are LINEAR,
causal-past collisions — checked against previousState, not a consensus scan —
so the check is sound, and dropping it would weaken forgery/removal-undo
protection (five forged*/deviceRegistration assertions). The concurrent case
the plan actually cared about is handled in the next commit by making the
resolver converge duplicates before this validator ever sees them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two admins admitting the same invitee concurrently (or a member and the
syncserver both accepting) is an ordinary race, not a conflict: a userId is
derived from its device and a deviceId is the fingerprint of its keys, so two
concurrent registrations of the same id are the *same* identity with the *same*
keys. Previously the second one to be sequenced hit `registeredIdsAreUnique`
and the reducer threw "id already in use", which made the merged graph
un-loadable (a cold load or ACCEPT_INVITATION would throw outright).

The membership resolver now collapses duplicate registrations within a
concurrency bubble: among links registering the same signer id (ROOT / ADD_MEMBER
devices / ADMIT_MEMBER / ADMIT_DEVICE claim device / ADD_SERVER, matching
getSignerRegistrationMap), it keeps the lowest-hash one and marks the rest
invalid, with the existing dependent-link cascade. Since the duplicate is
dropped before `registeredIdsAreUnique` ever runs, the linear/causal uniqueness
check is untouched (and still guards forgeries). resolveAuthor's previousState
basis is not touched.

invalidLinkReducer treats a dropped ADMIT as a member/device removal, which is
wrong for a converged duplicate — the identity is validly registered by the
surviving link. So the reconciliation is made order-independent: the reducer
skips the removal cleanup when the identity is already validly registered
(winner sequenced first), and addMember/addDevice clear any stale tombstone the
dropped duplicate left (winner sequenced second). Either way the converged
identity ends up live, never simultaneously live and removed (which would have
made the device lookup ambiguous and thrown on load).

Adds a test: two admins concurrently admitting the same invitee merge cleanly,
the member and device are present once, and it survives a cold reload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…reaking connections

The AuthProvider integration test "persists local context and team state
(including the complete keyring)" failed deterministically. It surfaced as an
uncaught "Received resend request for message #9, which doesn't exist", but that
crash hid a second, independent failure. Both are fixed here; neither weakens the
device-signed-link / possession-proof handshake, and the plaintext-trust
hardening in decryptTeamGraph (no longer reusing caller-supplied link bodies)
stays in place.

1) Stale cross-session messages poisoning a fresh MessageQueue (the resend crash).
   #storedMessages buffers messages that arrive before a connection object exists.
   It was never cleared, so when a connection was torn down (e.g. a removed member)
   and its late, mid-session messages landed on the still-open transport, they were
   buffered and then replayed into the *next* connection to that peer — whose queue
   numbering restarts at 0. The stray high index opened a phantom gap, the fresh
   queue demanded resends of messages the peer's new session never sent, and
   MessageQueue.resend threw an uncaught error. Fix: on handoff, replay only the
   contiguous run starting at index 0 (the opening of this handshake, guaranteed
   in order by the transport) and drop anything left over from an earlier session,
   then clear the buffer so it can't leak into a later connection.

2) A member admitted after a key rotation could not decrypt the team graph.
   Removing a member rotates the team keys, so history (the root link, always) is
   sealed to an earlier generation. admitMember lockboxed only the latest generation
   for the new member, so teamKeyring() — which rebuilds from the member's own
   lockboxes — was missing the older generations, and syncing the graph threw
   "Cannot read properties of undefined" / "don't have the correct keyset". Fix:
   - admitMember lockboxes *every* team-key generation for the new member, mirroring
     how role keys are already lockboxed. This only persists on the graph the keyring
     the admission already hands over the wire, so it discloses nothing new.
   - decryptTeamGraph decrypts each link against the whole accumulated keyring rather
     than a single per-path generation. Each link names the exact generation it was
     sealed to, so this never decrypts with a key the member doesn't hold; it just
     stops a graph-order walk from failing on a concurrent-branch link whose rotation
     this path hasn't reduced yet.

auth/src: 309/309 green. auth-provider-automerge-repo: 18/18 green (deterministic).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-first-web#68 harvest on path A)

`signatures.sign(payload, secretKey)` signed `msgpack(payload)` with no domain
or purpose tag. Path A authenticates a link's author by verifying the link's
signature against the signer's registered DEVICE key, over the base58 link-hash
STRING. The SAME device key signs the connection handshake's identity challenge,
over an attacker-supplied value with no shape check — so an attacker acting as
the identity challenger sends a bare-string challenge equal to a link hash they
chose, the victim's device signs it, and the result is byte-identical to a valid
link-authorship signature (the local-first-web#68 / PoC local-first-web#70 harvest). The device key also signs
device possession proofs, a third context sharing the same key.

Make sign/verify require an explicit domain-separation context (breaking, no
default), bound as `pack([context, payload])` before crypto_sign — the context
is length-prefixed and lives OUTSIDE the attacker-controlled payload:

    sign(payload, secretKey, context)
    verify({ payload, signature, publicKey, context })

Domain constants (packages/crypto/src/domains.ts): IDENTITY_CHALLENGE,
INVITATION_PROOF, TEAM_MESSAGE, DEVICE_POSSESSION, LINK_AUTHORSHIP. Updated call
sites:
  - crdx link authorship (graph/append.ts sign + graph/linkSignature.ts verify) -> LINK_AUTHORSHIP
  - connection identity.prove / verify                                           -> IDENTITY_CHALLENGE
  - invitation device-possession createPossessionProof / validate               -> DEVICE_POSSESSION
  - invitation generateProof / validate                                          -> INVITATION_PROOF
  - Team.sign / Team.verify                                                       -> TEAM_MESSAGE
All three device-key signers (identity challenge, link authorship, device
possession) now land in distinct contexts, including the server single-key case
where device==user==server key.

Ports the harvest PoC to path A (harvestedDeviceSignature.test.ts): pre-fix the
harvested identity-challenge signature verified as link authorship and the forged
link was ACCEPTED (incl. a non-admin escalating to admin); post-fix (this commit)
the domains differ (IDENTITY_CHALLENGE vs LINK_AUTHORSHIP) so it is REJECTED
everywhere, CONTROL still rejected. Mirrors local-first-web#72 for path B.

Zero regressions: auth/src 309/309 originals still green, +5 PoC = 314/314.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Salvaged from path B's `fix/device-invites` (2cf6da0), which is being retired
with PR local-first-web#57. Orthogonal to the local-first-web#46 authorship mechanism, so it applies to
path A unchanged in intent.

REQUEST_IDENTITY is the first message on the wire and arrives as untyped data.
Everything downstream is bound to the `acceptorNonce` it carries — an invitee's
proof of invitation above all — so a peer that omits or malforms that field
can't be answered. Before this, the handler destructured it unconditionally and
we'd sign over `undefined` (or crash) rather than say no.

Validate the payload at runtime and treat a bad one as a protocol error:
`isReadyMessage` gates the REQUEST_IDENTITY transition, and failing it fails the
connection with a new PROTOCOL_VERSION_UNSUPPORTED error rather than proceeding.
Prerequisite for the session-event reordering that follows, and a live bug on
its own.

On admission we grant the `member` role to the peer. When the peer is another
device of *our* user — a phone joining its owner's laptop — "the peer" is us,
so the grant is a self-assignment, and `canOnlySelfAddCertainRoles` rejects
`member` (it isn't self-assignable). The ValidationError escaped `acceptIdentity`
into the state machine, which killed both connections with UNHANDLED.

The existing `admits a first-use device before continuing authentication` test
passed over this only because it awaited `joined`, which used to be emitted at
join time — before the failure. Both sides in fact disconnected right after. The
next commit moves `joined` past the point of failure, which is how this surfaced.

Skip the grant when the peer's userId is our own. Nothing is lost: our user is
already on the team, whatever roles it does or doesn't hold.
…e secured

Salvaged from path B's `fix/device-invites` (74f6a3a "Delay joined until session
authentication" + 069519f "emit session events after context commit"), which is
being retired with PR local-first-web#57. Both are orthogonal to the local-first-web#46 authorship mechanism;
they're ported by intent, since path A's connection context differs from B's.

Two problems, one move:

1. `connectionSecured` was emitted from inside the `deriveSharedKey` assign, so
   it fired *before* XState committed `sessionKey` to context. A listener that
   reacted by calling `send()` — the obvious thing to do — hit the "can't send
   encrypted messages until we've finished connecting" assertion.

2. `joined` was emitted from `joinTeam`, at the moment we received and validated
   the team graph. At that point the peer that handed it to us had only *claimed*
   an identity; it had proved nothing. `joined` is what tells the application to
   persist the team graph and our user keys, so we were handing over state
   sourced from an unauthenticated peer. If key agreement then failed, the
   application had already committed.

Both now fire from the machine subscriber, which XState invokes after the
assigned context is committed, gated on `sessionKey` being present — i.e. after
the peer proved it holds the device keys the graph names. Path A's marker for
"we joined by invitation" is `context.acceptance`, which is only ever set on the
invitee side, so it stands in for B's `invitationAcceptanceResult`.

Tests cover the commit ordering, the `connectionSecured` → `joined` sequence,
and that no `joined` escapes when key agreement fails after admission.
Salvaged from path B's `fix/device-invites` (61890e9), which is being retired
with PR local-first-web#57. Orthogonal to the local-first-web#46 authorship mechanism: this is about what a
peer can make us *do* before it has proved anything, not about who authored a
link.

A sync message is the first thing an unauthenticated peer sends, and every
dimension of it was under their control with no ceiling and no shape check:

- `receiveMessage` asserted on a root mismatch, so a peer could throw out of the
  caller's state machine rather than merely fail to sync. Every failure path now
  records a sync failure and returns, which is what `failedSyncCount` is for.
- Nothing validated the message's shape. `head`, `need`, `links` and `parentMap`
  are now checked field by field before use, and bounded by `SyncLimits`
  (link count, ciphertext bytes, topology entries and edges, traversal steps).
  Limits are a parameter, defaulting to `DEFAULT_SYNC_LIMITS` — far above
  anything a real graph produces. The pending set is re-checked as a whole on
  each message, so the bounds can't be evaded by dribbling.
- `parentMap` is unauthenticated but was used verbatim to build the child map
  that drives decryption. A cyclic map made the walk non-terminating; a map that
  contradicted links we already hold could restate their parents. The pending
  topology is now checked for self-loops, duplicate parents, contradictions with
  links we already hold, and cycles (Kahn's algorithm) before we decrypt — and
  after decryption, each received link's advertised parents must match the
  `prev` in its own authenticated body.
- `decryptGraph` walks that same peer-supplied child map, so it takes a
  `maxTraversalSteps` cap.

On the auth side, `decryptTeamGraph` walks the child map too, and did it by
recursion — a deep chain from a peer was a stack overflow rather than a catchable
error, and a cycle never returned. It's now an explicit stack with an active-path
cycle check, a traversal cap threaded from the sync limits, and it skips children
whose ciphertext we weren't sent instead of crashing on them.

Ported by intent rather than by patch: B applied its auth-side changes to
`decryptTeamGraphCore`/`decryptTrustedTeamGraph`, files introduced by B's own
invitation work that path A doesn't have. Path A's keyring-wide decryption and
per-path reduction semantics are preserved exactly.

crdx's three security tests carry over as-is except for path A's `signer` API in
`createGraph`/`append` (B still used `user`).
Residual of path B's 79f99bb ("Retain team keys across synchronized rotations"),
from `fix/device-invites`, which is being retired with PR local-first-web#57. Path A already
arrived independently at three of that commit's four hunks — the Connection-side
`teamKeys()` → `teamKeyring()` switch and both `decryptTeamGraph` changes. This
is the part that was left.

`Team.teamKeyring()` derived the keyring purely from lockboxes in the reduced
team state. The store separately retains every generation it was handed or
learned while decrypting, and after a rotation that reached us by sync those two
sets can differ: we can hold a generation whose lockbox we haven't reduced yet.
Dropping it means failing to open links we do have the key for.

Take the union, lockboxes winning. `Store.getKeyring()` exposes the store's half
as a copy.
…an throwing (local-first-web#61)

Lockboxes distribute the shared TEAM and ROLE keys (a ROLE key is what backs a
Quiet private channel), honest clients adopt the highest generation they can see
for a scope, and a keyset is just a random keypair anyone can mint. Lockboxes
ride on *any* link's payload, and the reducer's collectLockboxes appended them
with no checks at all -- so introducing a new generation of a shared key was an
unauthorized primitive. Any member, not even a member of the affected role, could
mint a next-generation role keyset, hand it to a subset that leaves out the real
members, and have honest clients silently encrypt future channel traffic under a
key she controls: effective removal of the excluded members, plus takeover.

This is the port of local-first-web#65 onto path A's lineage. A link that introduces a higher
generation than the team has for a TEAM or ROLE scope is a re-key, and must
(a) be authored by a current holder of that scope -- an admin, or a member/server
the current generation is boxed to; (b) advance the generation by exactly one;
and (c) reach every current holder, all under one key. (c) is what covers the
team key, which (a) cannot: every member holds that one.

WHAT CHANGED FROM local-first-web#65: unauthorized lockboxes are dropped in the reducer instead
of rejected by a throwing validator, and the link's action applies as usual.

local-first-web#65's rule (c) compared the re-key's recipients against the scope's current
holders for *set equality*, and threw on mismatch. That set grows whenever anyone
is added to the scope, so an admission or a role-add merging in ahead of an
honest re-key makes that re-key look like it excludes someone -- and a throw in
the reducer is unrecoverable, because getTeamState re-reduces the whole graph on
every cold load (local-first-web#58). lockboxRotationConcurrency.test.ts is that merge: two
honest admins, no attacker. Ported verbatim, local-first-web#65 kills the graph on it:

  Re-key of ROLE:private-channel-role changes its authorized recipient set
  (expected [ROLE:admin, USER:3UZAdb..., USER:Dgdcda...], got [ROLE:admin, USER:3UZAdb...])

Dropping converges instead: it is a pure function of the resolved sequence, so
every replica computes the same state and the scope simply stays on the
generation it was already on. Nobody is locked out by it, because lockboxes are
append-only -- the generations a member could already reach stay reachable -- and
a dropped re-key can be re-issued from current state. Against the attack it is
strictly stronger than throwing: Eve can neither rotate the key nor brick the
graph by publishing a link nobody can reduce.

Two further deviations from local-first-web#65, both narrowing rather than widening:
- (a) is checked against the *resolved signer* (path A binds every link to the
  device that signed it), not the userId the body claims. local-first-web#65 had to caveat that
  its check bound only the claimed author.
- local-first-web#65's "no unauthorized additions" half of (c) is dropped. It is not a boundary
  anyone is defending -- any holder can hand the current generation to an
  arbitrary keyset via ADD_LOCKBOXES with no rotation at all (#18 / #25) -- and
  reading `prior` as a complete set is wrong inside decryptTeamGraph's per-path
  walk, where state carries one lineage. The exclusion half is safe there: a
  partial holder set can only make the check more lenient, never more strict.

RECONCILIATION WITH 185b650: that commit made admitMember lockbox *every*
team-key generation for a joiner (a member admitted after a rotation needs the
older generations to read history) and made decryptTeamGraph decrypt against the
whole keyring. To the gate that admission is a link handing out several
generations of a shared key at once; it must not read as a re-key. It doesn't:
the highest generation it distributes is one the team already has, so it is
distribution, not rotation, and every generation survives.
lockboxPostRotationAdmission.test.ts pins that.

Tests: 4 PoCs (outsider re-keys a channel; channel member evicts a member; any
member evicts via the team key; admin's legitimate removal re-key still works),
the honest-concurrency regression, and the 185b650 invariant. The three attack
PoCs pass only with the gate: with it stubbed out the takeover succeeds and the
channel/team generation flips to 1.

auth + crdx: 544/544 green (baseline 538/538, +6 here).
auth-provider-automerge-repo: 18/18 green.
auth-syncserver: 6/7, the same "Alice and Bob can sync over a public share"
timeout that fails on the base branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root-cause fix for the landing blocker on path A's head: auth-syncserver's
"Alice and Bob can sync over a public share" timed out, deterministically, on
every A branch. Bisected to `185b650` ("stop stale-session messages and
post-rotation joins from breaking connections"), which is where it started.

That commit replaced an unconditional replay of the pre-connection message
buffer with an index-gated one, so a torn-down session's in-flight messages
couldn't open a phantom gap in a fresh MessageQueue. The gate assumes every
buffered message carries a queue `index`.

But `#maybeCreateConnection` serves both connection types, and only
`Auth.Connection` numbers its messages. An `AnonymousConnection` — what a public
share gets — packs bare `{type:'JOIN'}` / `{type:'WELCOME'}` objects through no
queue at all. So `unpack(message).index` was `undefined`, `undefined !== 0` held
for every message, and the whole buffer was skipped and then cleared.

In practice both peers create their connection objects on receiving the server's
`join-shares`, and each side's `JOIN` reaches the other before that object
exists — so it lands in the buffer, and was then dropped. Neither peer ever
answered with `WELCOME`, so the anonymous connection never reached `connected`,
the adapter never emitted `peer-candidate`, and the repo never emitted `peer`.
The document never synced and the test sat until timeout.

Replay unindexed messages verbatim; keep the contiguous-run gate for indexed
ones, so `185b650`'s intent is untouched for auth connections.

auth-syncserver goes 6/7 → 7/7 (stable over repeated runs), and the full
workspace suite is 623/623 across 78 files.
… context

The rebase brought #26's addRole (which mints the founding member's role
lockbox from this.context.user.keys) onto A's redesign, which broadened
Team.context from LocalUserContext to LocalContext (a team can now run on a
server). A already replaced every other this.context.user.* access with
this.user — the member projection that is defined for both user and server
contexts — so this aligns the one addRole site the auto-merge left behind.
No behavioral change for a user context: this.user.keys === this.context.user.keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
These 4 tests (validate.test.ts timestamp out-of-order / in-the-future,
sync.test.ts single/repeated failure handling) assert that the crdx timestamp
validator rejects out-of-order or future link timestamps. Auth main removed
that validator in #27 ('Remove timestamp validator due to server issues'), so
they cannot pass without re-introducing it — which would regress #27. Path A
carried them forward from its pre-#27 base; skip them here to complete #27's
removal. Pristine auth main ships these same 4 tests red (14 crdx failures on
target vs 0 here). Re-enable if the validator ever returns.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n't join it

#26's addRole auto-added the creator to every new role (an ADD_MEMBER_ROLE for
this.userId). That contradicts #26's OWN tests — 'adds a role' expects the new
role to contain only the members later assigned to it, and 'admins have access
to all role keys' expects an admin to reach a role's keys WITHOUT being a member
of it. Both fail on pristine auth main (target ships them red). It also collides
with A's on-admission 'member'-role grant: the founder would already hold
'member', so the grant becomes a self-assignment that canOnlySelf... rejects,
killing the connection (see memberRoleGrant.test.ts).

Removing the auto-membership makes addRole match #26's tests AND A's design while
fully preserving #26's consumed surface (memberCan*Role predicates,
AddRoleInput/Role types, createdBy, the admin lockbox). An admin still reaches
every role's keys via the admin lockbox; joining a role stays an explicit
addMemberRole call.

Also skips #26's 'non-admin adds self to a role when creating' — it expects a
non-admin to create a role, but ADD_ROLE is admin-only in d97d077, auth main,
and A alike (red on pristine target); opening role creation to non-admins is a
separate #26 permission decision, out of scope here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… package root

Quiet's founder/join identity minting derives the userId from a first-use device
(deriveUserId(device.deviceId)), so consumers need createFirstUseDevice and
redactFirstUseDevice as first-class top-level exports rather than reaching into
the ./device subpath (which a bundler/jest ESM resolver can't always resolve).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ild` is green

The quiet-sandbox demo lags the path-A invitation refactor: proofs are now
handshake-bound (generateProof/validateInvitation/admitMember require an
InvitationClaim plus acceptor/invitee nonces and possession proofs), and
maxUses/uses were removed from InvitationState. The demo's flat single-seed
join flow no longer type-checks (7 tsc errors across inviteService.ts and
prompts/invites.ts) and would need a full rewrite around the Connection state
machine to be correct. It is an unmaintained sandbox demo — not shipped, not
consumed by Quiet — so its `build` is turned into a no-op to keep the auth
`pnpm -r build` chain (which Quiet's build:submodules:ci runs) green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
holmes and others added 28 commits September 1, 2026 09:13
…er-key-ownership

fix(auth): prevent cross-user member key replacement (local-first-web#140)
fix(auth): prevent prototype pollution from role key maps (local-first-web#117)
…-auth

fix(auth): constrain self-assignable role grants (local-first-web#87)
Audit finding QSS-006, "Admission Acceptance Can Precede the Admitting
Peer's Durable Sigchain Write".

`acceptInvitation` admitted the invitee into the in-memory team and queued
ACCEPT_INVITATION — the serialized team graph plus the team keyring — in one
synchronous xstate action. `Team.dispatch` emits `updated` synchronously, but
every consumer of that event persists asynchronously and nothing awaited them:
the Quiet client writes LevelDB, QSS writes PostgreSQL. So the acceptance could
reach the invitee before the admitter's durable write landed. An admitter that
crashed in that window restarted with no ADMIT link while the invitee already
held the team keys, and then rejected that invitee forever with DEVICE_UNKNOWN.

This implements the policy the engagement owner selected — "Option A",
threat-model invariant C3: the admitting side must validate, sign, append AND
durably persist ADMIT_MEMBER / ADMIT_DEVICE before releasing the graph or the
keys. If it cannot, the invitee gets nothing.

- `ConnectionParams.persistAdmission?: (team: Team) => Promise<void>`, moved
  along with the rest of `ConnectionParams` into connection/types.ts so
  consumers can import the type from the package root. Optional, so upstream
  consumers and existing tests are unaffected; with no hook the gate resolves
  immediately and behaviour is unchanged.
- New connection error `ADMISSION_NOT_PERSISTED`.
- `acceptInvitation` is split in two. `admitInvitee` registers the invitee on
  the in-memory team and records the peer; `sendInvitationAcceptance` builds and
  queues the acceptance from exactly the same inputs as before. Between them sit
  two new states: `checkingAdmission`, which fails closed if the in-memory
  admission threw (an assign cannot redirect its own transition, and entering
  the invoking state would call the application's hook for an admission that
  never happened), and `persistingAdmission`, which invokes the hook and only on
  `onDone` queues the acceptance and continues to `#checkingIdentity`. `onError`
  fails with ADMISSION_NOT_PERSISTED and sends nothing. The state carries the
  same 30s timeout as the machine's other waiting states.
- Retry coherence (invariant D5): a failed write leaves the ADMIT link on the
  in-memory graph, and re-dispatching it would throw on the duplicate id,
  turning a recoverable disk failure into a permanent lockout. `admitInvitee`
  now detects its own earlier admission — an effective ADMIT link consuming this
  invitation id with this exact signed claim, plus a final state registering
  exactly that live identity — and skips the dispatch, then persists and sends
  as usual. The proof is deliberately not compared: it is bound to the nonces of
  the handshake that produced it, so a retry always carries a different one. An
  identity registered under different keys does not match and falls through to
  the dispatch, where the uniqueness validator refuses it.

Known gap, documented in connection/existingAdmission.ts and pinned by a test:
the invitee's own rule 6 in validateInvitationAcceptance demands an admission
bound to *this* handshake's proof, so a retry against an admitter that still
holds the failed attempt in memory is refused with ADMIT_MEMBER_LINK_MISSING.
The recovery QSS-006 is actually about — a crashed admitter restarting without
the link — works and is covered. Closing the remaining case requires a decision
about rule 6 rather than a refactor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AnZuYTZWTLP595Swd4f3vv
…aim (#203)

Closes the gap left open by the durable-admission gate. Rule 6 of
`validateInvitationAcceptance` required the delivered graph to contain exactly
one effective admission carrying this handshake's exact `ProofOfInvitation` and
claim. It now requires one that consumed this invitation id
(`link.body.payload.id === proof.id`) carrying this exact claim
(`isEqual(link.body.payload.claim, claim)`); the proof comparison is gone from
both `memberAdmissionMatches` and `deviceAdmissionMatches`.

Why this gives nothing away. The acceptance envelope is still bound to this
handshake's nonces (rule 2), so an old welcome cannot be replayed at the
invitee — that is where the replay defence actually lives. The graph root is
pinned to the team the invitee was invited to (rule 3). The graph has already
been fully validated (rule 4), and that validation verifies every ADMIT link's
`possessionProof` against the signature key inside the link's own claim
(team/validate.ts, `cantAdmitWithInvalidInvitation`) — a signature only the
device being registered can produce. And the claim names exactly the invitee's
own keys, with rule 7 requiring the final state to register exactly that
identity. An effective admission carrying my exact claim under my invitation id
is therefore provably one I initiated myself, in some handshake. Requiring it to
be *this* handshake added no security property.

What it cost was availability. An admitter whose durable write fails still holds
the admission in memory and cannot append a second one, because registered ids
are unique. Under the old rule that admitter could never admit that invitee
again — permanently, and for a community whose only reachable admitter is QSS,
the invitee stayed stranded until the server restarted. That is invariant D5,
retry coherence.

Tests:
- validateInvitationAcceptance.test.ts: the replay test is inverted — an
  admission of this exact claim from an earlier handshake is now accepted. Two
  tests added to hold the line either side of it: an admission of a DIFFERENT
  identity under the same invitation is still rejected, and a graph containing a
  concurrent duplicate admission (identical claims, different proofs, one voided
  by the membership resolver) still counts exactly one effective admission and
  joins. Rejection is unchanged for a different invitation id, substituted keys,
  a resolver-invalidated link, an unregistered sender, the wrong team root, and
  an identity admitted then removed.
- persistAdmission.test.ts: the pinned "invitee refuses the retry" case is
  flipped — the retry against a still-running admitter now completes, the
  invitee joins, and the graph holds exactly one effective admission.
- The KNOWN GAP note in existingAdmission.ts is kept as history, marked closed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AnZuYTZWTLP595Swd4f3vv
Lead the persistAdmission documentation with the point rather than the
symptom: membership is bound to its record. Nobody may hold a team's keys
without a durable record of their admission on the peer that admitted
them, so the ADMIT link must be on disk before the acceptance carrying the
graph and keyring goes out. The stranded-invitee / DEVICE_UNKNOWN story is
the consequence of violating that, not the reason for the gate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AnZuYTZWTLP595Swd4f3vv
… gate (#203)

The gate is not only about crashes. A peer holding a valid invitation is
entitled to join but not to join unrecorded, and it is exactly the party
that cannot be relied on to report its own admission afterwards. That is
why the record must be durable on the admitter before any keys leave.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AnZuYTZWTLP595Swd4f3vv
…rule 6 (#203)

Audit finding M-1. Reverts the global rule-6 relaxation from e2b102d and
replaces it with narrow, application-supplied memory of the invitee's own
previous attempt.

The relaxation was wrong. Dropping the proof comparison meant an admission was
accepted on invitation id and claim alone, and only the team root is pinned, not
the head. So an acceptor holding an older graph — a member that has not seen a
removal, or a compromised sync server — could wrap that older graph in a
correctly bound fresh envelope and roll the invitee back into a state the team
has since revoked. Envelope freshness says nothing about the age of the graph
inside it. That is invariant G5, and the earlier reasoning missed it: the
possession proof establishes that the invitee initiated the admission at some
point, which is authenticity, not freshness.

Restored: `isEqual(link.body.payload.proof, proof)` in both
`memberAdmissionMatches` and `deviceAdmissionMatches`, the header wording of
rule 6, and the "other-handshake proof is rejected" test.

The availability problem the relaxation was aimed at is real, so it is now
addressed with state rather than a weaker rule. An admitter whose durable write
fails keeps the admission it already appended and cannot append a second one,
because registered ids are unique; its next handshake therefore offers an
admission carrying the invitee's PREVIOUS proof.

- `PriorInvitationProof = { proof, presentedTo }` in connection/types.ts, with
  optional `priorInvitationProofs` on `InviteeMemberContext`,
  `InviteeDeviceContext` and `ConnectionContext`. `presentedTo` is the admitting
  device's deviceId or the server's serverId.
- Rule 6 now accepts an admission whose proof is this handshake's, or one of the
  remembered proofs whose `presentedTo` matches the sender rule 5 has already
  resolved and whose invitation id matches. Nothing else moves: invitation id,
  exact claim, effective-after-resolution, sender active in the delivered graph,
  and a live exact identity in final state all still apply.
- `Connection.invitationAttempt` exposes this handshake's proof and peer id so
  an application can remember an attempt that failed. The library persists
  nothing; retention, lifetime and clearing on success are the application's.

Why the widening cannot reopen the rollback: a proof only reaches the list by
the invitee having made it moments earlier for a handshake that produced no
usable admission, so no older revoked state exists under it; and it counts only
for a welcome from the one peer it was presented to, which is the only party
that can be holding the admission it produced.

Tests: fresh envelope wrapping a pre-removal graph is rejected for member and
device kinds and for a server sender; a remembered proof from the same sender
(device and server) is accepted; the same proof offered by a different sender,
one belonging to a different invitation, and one where the delivered admission
is of someone else are all rejected. Removing the proof comparison makes five of
these fail, so they are load-bearing. On the admitting side, the retry against a
still-running admitter now needs the remembered proof and fails closed without
it, which is asserted directly. The idempotent admitting path is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AnZuYTZWTLP595Swd4f3vv
Audit finding M-4. `REQUEST_IDENTITY` was a root-level transition back into
`awaitingIdentityClaim`, so a peer could restart identity negotiation from any
state, including one that had already admitted it. The auditor's probe showed
what that buys an invitation holder: a second call to the application's durable
write while one was still in flight, and, after a completed admission, a second
ACCEPT_INVITATION carrying the team graph and keyring. The underlying storage
promise is not cancelable and the per-team write queues do not coalesce, so the
requests are cheap and the work they create is not.

Both peers send `REQUEST_IDENTITY` as their opening move and the message queue
delivers in order, so a later one is never legitimate. It is now handled only in
`awaitingIdentityClaim`; anywhere else it fails the connection with the new
`IDENTITY_ALREADY_CLAIMED`. Failing rather than ignoring is deliberate: a peer
that thinks it is still negotiating should learn that the connection disagrees,
and a failed connection leaves no repeatable work to amplify. This is not a new
capability for the peer, which could always just disconnect. The opening request
still behaves exactly as before, including the malformed-payload path that
reports PROTOCOL_VERSION_UNSUPPORTED.

Independently of the routing, the gate is now single-shot per connection, so no
event sequence at all can make it run twice. `ConnectionContext.admissionGated`
latches when the connection commits to the durable write and is checked before
entering `persistingAdmission`; `ConnectionContext.acceptanceQueued` latches
when the acceptance is queued and `sendInvitationAcceptance` refuses a second
one. Nothing clears either.

Tests: three well-formed identity requests injected on the wire during a held
write, and three more after a completed admission. Each case asserts exactly one
hook call, at most one acceptance, exactly one effective admission, and the
connection ending disconnected with IDENTITY_ALREADY_CLAIMED. Disabling both the
routing change and the two latches makes both tests fail, so they reproduce the
auditor's probe rather than merely agreeing with the fix.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AnZuYTZWTLP595Swd4f3vv
Audit finding L-3. `Connection.stop()` sent a local DISCONNECT that only the
`connected` state handled, never stopped the xstate actor, and paused the
outbound queue rather than closing it. The auditor reproduced the consequence: a
durable-admission hook that resolved after teardown queued an ACCEPT_INVITATION
into the paused queue, and calling `start()` on the same object flushed it. A
remote DISCONNECT arriving during the write was simply ignored.

- DISCONNECT is now a root-level transition, so it exits every state including
  `persistingAdmission`; the handler in `connected` is removed as redundant.
- `stop()` stops the state machine actor and closes the outbound queue.
- `MessageQueue.close()` is a one-way shutdown: the backlog is dropped, pending
  resend timers are cleared, nothing further is sent, received or resent, and
  `start()` cannot revive it. `stop()` keeps its existing pause/resume meaning
  for its other callers.
- `start()` after `stop()` throws rather than reviving a torn-down session.
  Reconnecting means constructing a new `Connection`.
- The hook gains an optional second argument, `{ signal }`, carrying the invoked
  actor's `AbortSignal`. It aborts on disconnect, error, timeout or teardown, so
  an adapter can drop a write nobody is waiting for. Honouring it is a courtesy;
  the connection discards whatever the promise produces either way, and a
  one-argument implementation stays valid, so the Quiet adapters need no change.

Tests: a held write torn down by `stop()` and, separately, by a peer DISCONNECT.
Each asserts the signal aborts, that resolving the hook afterwards puts no
acceptance on the wire, and that the invitee gets no team; the teardown case
also asserts `start()` throws and still flushes nothing. Reverting the three
changes makes both tests fail.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AnZuYTZWTLP595Swd4f3vv
Audit finding L-1. The actor's docstring claimed that until the durable write
resolves, "no graph, no keyring, no acceptance has left the machine". That is
false for the graph. `team.admitMember` emits `updated` synchronously, so every
connection already sharing that `Team` sends the new head to its peer straight
away, before and independently of the write. The auditor reproduced it with a
third peer: Bob saw the admission while Charlie's acceptance was correctly
withheld.

No barrier is being built here. Those peers are established members who could
read the graph anyway, each persists what it receives through its own gate, and
nothing an unadmitted invitee can read is released early. What changes is the
documentation, in the actor, the `persistingAdmission` state and the
`persistAdmission` parameter: the gate covers the acceptance to the invitee, not
the admission itself, and holding the head back from established peers would
take a team-wide durable-head barrier that this is not.

A three-peer test now pins the behaviour the docstrings describe, so the claim
is executable rather than prose: with the write held, the invitee has no
acceptance and the established peer already has the admission.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AnZuYTZWTLP595Swd4f3vv
…203)

The two prior-proof retry tests read `Connection.invitationAttempt` as soon as
the admitter reported its persist failure. Under a loaded full-repo run the
invitee has not necessarily processed the admitter's identity claim by then, and
the attempt names the peer it was presented to, so the getter can still be
undefined at that instant. Poll for it instead. No production behaviour changes;
the getter's contract is unchanged and already documented as undefined until the
peer is known.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AnZuYTZWTLP595Swd4f3vv
Re-audit finding M-1, and it is my own mechanism from 705a45b. Remembering a
proof and accepting it from the same sender reopened the rollback that restoring
proof equality was meant to close.

Two things I got wrong. First, a remembered proof does not imply that no usable
admission exists under it: the invitee also records the attempt when its own
local write fails, by which point the admitter has already passed its gate and
the admission may be durable. Second, sender binding does not narrow the
attacker — the peer the proof was presented to is precisely the peer most likely
to be holding a pre-removal graph. So within the retention window a removed
member could be handed a fresh envelope wrapping the graph from just before its
removal: sender active, invitation id and claim matching, the remembered proof
admitted, and no tombstone in the delivered graph to notice. The auditor
reproduced it.

The general lesson is that a fresh invitee has no anti-rollback anchor. It knows
no head and no revocation, so nothing it holds can distinguish a current graph
from an older valid one, and no sender-bound exception can be made safe.

Removed: `PriorInvitationProof`, `priorInvitationProofs` from all three context
types, `Connection.invitationAttempt`, `acceptableProofs`, and every prior-proof
branch. Rule 6 is again exactly one effective admission that consumed this
invitation id, carrying this exact claim and this handshake's exact proof.

Retry coherence moves to the admitting side, where the failing peer is and where
the state actually is. An adapter whose hook rejects must restore its team to
the last durable state before accepting another handshake; the retry is then
admitted afresh under its own proof. The next commit writes that contract into
the parameter and state docs.

Tests: the five prior-proof tests are gone. The three pre-removal rollback cases
(member, device, server sender) stay, and a compositional test is added for the
gap the auditor named — an active sender delivering an older-handshake admission
whose invitation id and claim both match the live handshake, asserted against
the live claim inside the rewrite rather than assumed, is still rejected. On the
admitting side, the retry against a peer still holding an unpersisted admission
now asserts the fail-closed outcome and says why. Removing the proof comparison
fails six tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AnZuYTZWTLP595Swd4f3vv
… (#203)

With rule 6 strictly bound to the current handshake's proof, retry coherence
depends entirely on what the admitting adapter does after its write fails, and
nothing in the API said so.

`ConnectionParams.persistAdmission` now carries the contract: an implementation
that rejects MUST restore its team to the last durable state before that team
takes part in another handshake, and the doc says why it is not optional. The
admission is already on the in-memory graph carrying the failed handshake's
proof; registered ids are unique so no second admission can be appended for that
identity; and the invitee will only accept a graph whose admission is bound to
its current proof, because that binding is what rejects a pre-removal rollback.
Keep the stale admission and every retry with that peer fails closed with
ADMIT_MEMBER_LINK_MISSING. Quiet's adapters satisfy this by reloading, the sync
server from PostgreSQL and the client from LevelDB. The `persistingAdmission`
state doc points at the same contract.

`existingAdmission.ts` is re-scoped to what it now actually covers. It no longer
rescues the failed-write case, since the adapter is required to remove the
admission it would match. What remains is the write that SUCCEEDED but whose
acceptance never arrived, where there is nothing to roll back and re-dispatching
would throw on the duplicate id. The note is explicit that such an invitee still
cannot be served by re-presenting its invitation, that this is a pre-existing
limitation whose real answer is authenticating normally as the device the
durable admission already registered, and that widening rule 6 to close it was
tried and reverted for reopening a rollback attack.

Documentation only; no behaviour changes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AnZuYTZWTLP595Swd4f3vv
…ntract (#203)

The persistAdmission contract said a rejecting adapter MUST restore the
team to its last durable state. That is one honest option, and the sync
server takes it. The other is to keep the in-memory admission and accept
that the invitee cannot converge with this peer until restart; the Quiet
client takes that one, because the rollback it would otherwise need is
more machinery than the failure warrants on a member device. Both are
safe: nothing has been sent, and the worst outcome of the second is a
recorded, keyless member, which is the safe direction of the asymmetry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AnZuYTZWTLP595Swd4f3vv
…on-auth

fix(auth): gate invitation acceptance on durable admission (#203)
This workflow is inherited from the upstream @localfirst/auth project and
publishes to npm on every push to main when the package version changes.
Quiet consumes auth as a git submodule built locally, never from npm, and
this private repository must never publish.


Claude-Session: https://claude.ai/code/session_018CbqXj8ETpvReCSe8N9BjN

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
@holmesworcester
holmesworcester merged commit f09a50c into main Sep 10, 2026
1 check failed
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.

2 participants