Skip to content

Harden invitations and fix device invites - #28

Draft
adrastaea wants to merge 27 commits into
mainfrom
fix/device-invites
Draft

Harden invitations and fix device invites#28
adrastaea wants to merge 27 commits into
mainfrom
fix/device-invites

Conversation

@adrastaea

@adrastaea adrastaea commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Secure invitation admission with transcript-bound proofs and authenticated graph validation

PR description

Summary

This PR hardens member and device invitation admission end to end. It replaces invitation proofs that were reusable independently of the claimed identity with a versioned, domain-separated protocol that binds the proof to the exact member/device claim and to a single connection handshake.

It also moves the invitation acceptance data behind authenticated encryption, validates the returned graph and the accepting peer before using team keys, requires exactly one effective admission for the signed identity, and delays joined until session-key negotiation has authenticated the connection.

Because those guarantees depend on trustworthy graph state, this PR also closes several underlying CRDX and team-state validation gaps: graph links retain authenticated sender metadata, claimed authors are matched to current member/server keys, incoming graphs are re-decrypted from authenticated ciphertext, invalid dispatches and merges are atomic, identity/key metadata must be globally unambiguous, and historical team keys are retained across synchronized rotations.

Why this is needed

The original device-linking failure came from a member-oriented admission check being applied to a first-use device claim. Fixing that check alone would restore the happy path, but it would leave the admission boundary vulnerable in several related ways:

  • An invitation proof was not bound to the public keys, device metadata, invitation kind, or connection that it was used to admit.
  • A device invitation and member invitation shared the same validation path without a trusted discriminator.
  • ACCEPT_INVITATION exposed the graph and team keyring before session encryption was established.
  • The invitee checked reduced-state presence rather than proving that one effective ADMIT_* action consumed this invitation for this exact identity.
  • Supplied plaintext links, unauthenticated claimed authors, ambiguous IDs, and ignored graph-validation failures could undermine higher-level admission checks.
  • joined could be emitted before session-key negotiation completed.

This PR treats invitation admission as one security boundary instead of addressing only the device-claim crash.

What changed

1. Versioned, transcript-bound invitation proofs

  • New invitations use protocol version 2 and publish separate ephemeral signature and encryption public keys (packages/auth/src/invitation/types.ts:20-34, packages/auth/src/invitation/create.ts:21-37).
  • Member and device claims are explicit discriminated unions. Device claims intentionally omit caller-controlled ownership; the authenticated invitation supplies the owner (packages/auth/src/invitation/types.ts:48-63, packages/auth/src/connection/Connection.ts:218-240).
  • Proofs sign one canonical, domain-separated payload containing the invitation ID and kind, acceptor and invitee nonces, username, member keys where applicable, and device metadata (packages/auth/src/invitation/invitationProofPayload.ts:10-36, packages/auth/src/invitation/generateProof.ts:8-30).
  • Validation rejects legacy or unknown protocol versions, replay against a different acceptor nonce, extra or missing fields, malformed key metadata, inconsistent member/device identities, and signatures over altered claims (packages/auth/src/invitation/validate.ts:20-54, packages/auth/src/invitation/validate.ts:60-100).
  • Invitation kind is derived from the authenticated INVITE_MEMBER or INVITE_DEVICE graph action and is enforced again for ADMIT_MEMBER and ADMIT_DEVICE (packages/auth/src/team/reducer.ts:159-170, packages/auth/src/team/validate.ts:346-382).

2. Encrypted and authenticated invitation acceptance

  • REQUEST_IDENTITY now carries the acceptor nonce, and ACCEPT_INVITATION carries only versioned sender metadata plus an encrypted acceptance bundle (packages/auth/src/connection/message.ts:6-11, packages/auth/src/connection/message.ts:60-83).
  • The bundle includes the serialized graph, complete team keyring, invitation ID and kind, claim digest, both handshake nonces, and acceptor device ID. It is encrypted to the invitation's ephemeral encryption key using the accepting device's authenticated encryption key (packages/auth/src/connection/invitationAcceptance.ts:28-60).
  • The invitee derives the decryption key from the invitation seed and verifies the exact outer and inner schemas, transcript, claim digest, nonces, sender ID, version, and domain before using the contents (packages/auth/src/connection/invitationAcceptance.ts:70-108, packages/auth/src/connection/invitationAcceptance.ts:128-191).
  • The accepting sender must be an active identity in the returned graph and its recorded encryption key must equal the advertised sender key (packages/auth/src/connection/invitationAcceptance.ts:111-125).

3. Exact effective-admission validation

  • The acceptance graph is deserialized, cryptographically validated, and reduced before joining (packages/auth/src/connection/validateInvitationAcceptance.ts:43-60).
  • The graph must contain the invitation with the expected member/device kind and exactly one resolver-effective admission matching the invitation ID and the complete signed identity (packages/auth/src/connection/validateInvitationAcceptance.ts:62-109).
  • A member admission must match the exact username and public keys. A device admission must match the exact first-use device and the owner recorded on the invitation (packages/auth/src/connection/validateInvitationAcceptance.ts:113-141).
  • The admitted member or device must remain uniquely active in final state; an admission that was invalidated or followed by removal is rejected (packages/auth/src/connection/validateInvitationAcceptance.ts:143-173).
  • The already validated graph and state are reused when constructing the team, so joining does not independently trust or parse a second representation (packages/auth/src/connection/Connection.ts:331-384).

4. Authenticated graph authorship and fail-closed identity state

  • Decrypted and locally appended CRDX links retain the authenticated senderPublicKey (packages/crdx/src/graph/decrypt.ts:19-41, packages/crdx/src/graph/types.ts:91-124).
  • Team validation matches that key against the current encryption key of the claimed member or server; root, unknown, removed, stale-key, and ambiguous authors fail closed (packages/auth/src/team/validate.ts:39-84).
  • Incoming graph objects are reconstructed from encrypted links instead of trusting caller-supplied plaintext links (packages/auth/src/team/serialize.ts:29-42, packages/auth/src/team/Team.ts:225-236).
  • CRDX validation is performed on every derivation pass. Invalid results throw before reduction, and dispatch/merge install graph and state together only after validation succeeds (packages/crdx/src/store/makeMachine.ts:12-28, packages/crdx/src/store/Store.ts:154-191, packages/crdx/src/validator/validators.ts:6-87).
  • Team validators enforce key type/name/generation, globally unique active member/device/server identifiers, valid device ownership, and authorization for adding another member's device (packages/auth/src/team/validate.ts:99-267).
  • Singular selectors reject ambiguous legacy state instead of returning the first match (packages/auth/src/team/selectors/device.ts:7-37, packages/auth/src/team/selectors/member.ts:3-18, packages/auth/src/team/selectors/server.ts:4-18).
  • Removal tombstones are correctly cleared when a legitimate member or device is re-added (packages/auth/src/team/transforms/addMember.ts:4-19, packages/auth/src/team/transforms/addDevice.ts:11-30).
  • Grants of the administrator role remain admin-only even when other roles are self-assignable (packages/auth/src/team/isAdminOnlyAction.ts:4-26).

5. Connection lifecycle, key retention, and errors

  • joined is emitted only after the invitation has been accepted and validated, the local team has been constructed, and session-key negotiation has succeeded (packages/auth/src/connection/Connection.ts:531-563).
  • Store-retained keys are combined with state-derived keys so synchronized graphs remain decryptable across team-key rotations (packages/crdx/src/store/Store.ts:102-105, packages/auth/src/team/Team.ts:977-980, packages/auth/src/team/decryptTeamGraph.ts:54-80).
  • Seed/message decryption failures are consistently classified as ENCRYPTION_FAILURE, and peer-reported failures emit remoteError (packages/auth/src/connection/Connection.ts:540-598).
  • Admission error text now applies to both member and device admissions (packages/auth/src/connection/errors.ts:36-43).

6. Quiet sandbox migration

The Quiet sandbox now constructs the complete signed member claim, generates both handshake nonces, and carries the expected acceptor nonce through validation and admission (demos/quiet-sandbox/src/auth/services/members/userService.ts:36-59, demos/quiet-sandbox/src/auth/services/invites/inviteService.ts:47-114).

Security behavior: before and after

Boundary Before After
Invitation proof Signed the invitation identifier Signs the exact identity/device claim and both handshake nonces
Invitation kind Inferred from untrusted claim shape Derived from the authenticated graph action and enforced during admission
Acceptance contents Graph and keyring sent before session encryption Authenticated-encrypted to a key derived from the invitation seed
Acceptance sender Not proven against the accepted graph Must be an active device with the advertised current encryption key
Admission check Reduced-state presence Exactly one resolver-effective ADMIT_* for this invitation and identity, plus exact final state
Graph input Could trust supplied plaintext links Re-decrypted from authenticated ciphertext and fully revalidated
Identity lookup Could return the first duplicate Rejects ambiguous member, device, and server identifiers
Join completion Could precede secure-session establishment Emitted only after session-key negotiation succeeds

Compatibility and rollout

This is intentionally a breaking invitation protocol change.

  • Existing version-1 invitation records remain representable and reducible, but they cannot be used. Validation returns Legacy invitations are disabled and must be reissued (packages/auth/src/invitation/types.ts:20-34, packages/auth/src/invitation/validate.ts:20-30). Outstanding invitations must be revoked/reissued after deployment.
  • Invitation kind is derived while reducing existing authenticated actions, so stored invitation payloads do not need to be rewritten (packages/auth/src/team/reducer.ts:159-170).
  • Mixed-version peers cannot complete invitation handshakes because the identity request, proof, claim, and acceptance wire shapes changed (packages/auth/src/connection/message.ts:6-11, packages/auth/src/connection/message.ts:73-83). Invitation-capable peers should be upgraded together.
  • generateProof, Team.validateInvitation, Team.admitMember, and Team.admitDevice have breaking source signatures because callers must supply the signed claim and handshake context (packages/auth/src/invitation/generateProof.ts:8-30, packages/auth/src/team/Team.ts:653-742).
  • Link now requires senderPublicKey. Serialized encrypted links already carried it, but hand-built graph fixtures and direct consumers of the plaintext type must be updated (packages/crdx/src/graph/types.ts:91-124).
  • Previously tolerated histories containing duplicate active IDs, malformed key metadata, unknown/stale claimed authors, or plaintext/encrypted link mismatches may now fail to load or merge. This is deliberate fail-closed behavior.
  • Exact-key schema checks mean future additive protocol fields require an explicit version bump rather than a silent extension (packages/auth/src/invitation/validate.ts:32-43, packages/auth/src/connection/invitationAcceptance.ts:128-191).

Test coverage

New and expanded coverage includes:

  • Transcript-bound proofs, replay protection, and strict version/schema validation (packages/auth/src/invitation/test/invitation.test.ts:15-119).
  • Encrypted acceptance, ciphertext tampering, transcript replay, and acceptance-sender authentication (packages/auth/src/connection/test/invitationAcceptance.test.ts:14-108).
  • End-to-end member/device admission, true first-use devices, third-party admitters, delayed joined, failed session negotiation, and incomplete returned graphs (packages/auth/src/connection/test/invitationAdmission.test.ts:14-161).
  • Wrong invitation IDs, wrong keys/owners, resolver-invalid admissions, removal after admission, and multi-use invitation matching (packages/auth/src/connection/test/validateInvitationAcceptance.test.ts:20-183).
  • Forged, unknown, removed, and stale graph authors (packages/auth/src/team/test/authorAuthentication.test.ts:7-48).
  • Invitation-kind confusion and legacy kind derivation (packages/auth/src/team/test/invitationKind.test.ts:10-68).
  • Identity collisions, key metadata, device authorization, tombstone cleanup, and ambiguous legacy selectors (packages/auth/src/team/test/identityInvariants.test.ts:10-142).
  • Rejection of duplicate member/device admissions across disconnected branches and successful merging of valid disparate admitters (packages/auth/src/connection/test/concurrentAdmitMember.test.ts:21-94, packages/auth/src/connection/test/concurrentDeviceAdmission.test.ts:23-99, packages/auth/src/connection/test/concurrentMemberAdmission.test.ts:24-64).
  • Authenticated-ciphertext reconstruction and CRDX validation/merge behavior (packages/crdx/src/graph/test/decrypt.test.ts:77-102, packages/crdx/src/store/test/createStore.test.ts:19-79, packages/crdx/src/validator/test/validate.test.ts:16-230).

Verification performed

  • pnpm exec vitest run packages/auth packages/crdx --reporter=dot
  • pnpm build
  • pnpm lint
  • Persisted pre-change graph smoke test with outstanding version-1 invitation reissue
  • Mixed-version deployment test, or an explicit coordinated-upgrade release note

The unit run passed when allowed to bind the sync-server test port. The sandboxed run's only failures were all seven sync-server cases failing with EPERM while binding 0.0.0.0:3100; the same command passed outside that restriction.

Risks and follow-up

  • Concurrent use of the same single-use invitation on disconnected branches now causes both merged histories to reject the duplicate identity rather than selecting a winner. This prevents ambiguous admission but does not provide an automatic product-level recovery policy (packages/auth/src/connection/test/concurrentAdmitMember.test.ts:31-94, packages/auth/src/connection/test/concurrentDeviceAdmission.test.ts:33-99).
  • The encrypted acceptance carries the complete retained keyring. Confidentiality is improved, but historical-key retention and acceptance payload size deserve review for large or long-lived teams (packages/auth/src/connection/invitationAcceptance.ts:38-60, packages/auth/src/team/Team.ts:977-980).
  • There is no legacy invitation fallback by design; adding one without a separate authenticated and confidential transport would reintroduce downgrade risk.
  • Repair tooling for historical graphs that violate the new identity/authorship invariants is out of scope for this PR.

Suggested review order

  1. Invitation transcript and validation: packages/auth/src/invitation/invitationProofPayload.ts:10-52, packages/auth/src/invitation/validate.ts:20-100.
  2. Acceptance encryption and transcript verification: packages/auth/src/connection/invitationAcceptance.ts:28-191.
  3. Exact admission validation and state-machine integration: packages/auth/src/connection/validateInvitationAcceptance.ts:43-183, packages/auth/src/connection/Connection.ts:262-384.
  4. Graph authorship and identity invariants: packages/auth/src/team/validate.ts:39-382.
  5. CRDX validation/atomicity and regression suites: packages/crdx/src/store/Store.ts:154-218, packages/crdx/src/store/makeMachine.ts:12-28, packages/auth/src/connection/test/validateInvitationAcceptance.test.ts:20-183.

adrastaea added 27 commits July 24, 2026 15:03
- Add role assignment for team members in tests
- Refactor admission link checks in connection logic
- Improve error messages for missing admission links
- Introduce new tests for invitation admission scenarios
…ecryption

- Added tests for decrypting only new graph links during incremental sync.
- Refactored invitation acceptance processing to improve validation logic.
- Introduced new methods for managing authenticated team graphs.
- Enhanced team graph decryption to reuse trusted plaintext and prevent tampering.
- Updated store creation to support reusable machine results, ensuring state integrity.
- Added comprehensive tests for machine result validation and rejection scenarios.
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