Skip to content

Reuse validated cryptographic facts during message and sigchain processing - #39

Merged
holmesworcester merged 10 commits into
mainfrom
fix/ios-incremental-validation
Sep 22, 2026
Merged

holmesworcester merged 10 commits into
mainfrom
fix/ios-incremental-validation

Conversation

@holmesworcester

@holmesworcester holmesworcester commented Sep 15, 2026 •

Copy link
Copy Markdown

Quiet revalidated the same lockbox key material for every message and repeated old graph cryptography when receiving a new sigchain edition. This change retains checked keys within each Team and reuses successful graph cryptographic facts for complete unchanged inputs. Ordinary message signatures and current action authorization are still checked.

Implementation

  • Each Team owns a private checked-key store. Imports copy, validate, and freeze complete keysets once; retained keys cannot be changed through caller-owned inputs. Explicit caller-owned key arguments are checked on each call.
  • Successful lockbox openings are bound to the complete encrypted delivery and checked recipient. Reconstructed copies can reuse those facts across graph replay and garbage collection. A known contents commitment alone cannot authenticate a new delivery.
  • Key selection follows the current state's lockboxes. Retaining key material does not grant access in a different branch or after access is removed. Only one selection view is retained. This replaces the global keyset, lockbox-opening, and key-map caches.
  • Reducer-owned lockboxes are snapshotted, and established commitments are indexed with an immutable radix trie.
  • Graph facts are bound to current ciphertext, signatures, resolved public keys, signed proof context, and decryption secrets. Cached plaintext is copied before returning it. Invitation and role decisions use current replay state. Facts stay attached to retained graph inputs, with bounded weak indexes and a fallback when WeakRef is unavailable.

Checked-key records last for the owning Team's lifetime; they are not serialized. A failed merge discards the store so valid prefixes of rejected graphs cannot accumulate retained keys; successful merges keep their records. Existing Team APIs and wire formats are unchanged. Standalone lockbox opening returns caller-owned key material. See the checked-key design notes.

Validation

  • 767 tests passed across auth, CRDX, crypto, and shared, with 87 existing skips. Production builds and lint of the changed TypeScript files passed.
  • The new message test decrypts 1,000 real role-encrypted messages after warm-up with zero lockbox openings, keypair revalidation, or key hashing, while verifying all 1,000 message signatures and rejecting tampering.
  • Tests cover complete delivery binding, mutable inputs, store and branch isolation, reconstructed collections without WeakRef, cycles, failed deliveries, serialized graph updates, and the retained role-removal transform (removal dispatch remains gated by protocol 4).
  • A separately generated sender plus forced-GC receiver check at 10 and 100 users verifies one new graph signature per received edition. The first edition opens four boxes, including initial local key import; the second opens one box with no keypair reconstruction. These are Linux Node 24 controls, not device benchmarks.
  • Daybreak Blue independently identified speculative key retention after rejected merges. The fix discards the store on rejection; regression tests cover repeated semantic and ciphertext failures, unchanged graph/state, and subsequent successful acceptance and reuse. Daybreak Blue reviewed the fix, reran 53 focused tests, and reported no remaining actionable findings.
  • One initial full run hit a removal-gate state-comparison failure during load; both its isolated rerun and the complete rerun passed.
  • The test-inclusive auth typecheck still has 37 pre-existing diagnostics, down from 39 at the preceding head, with no new diagnostics.

Graph traversal, hashing, filtering, and contextual replay remain proportional to history. Earlier integrated iPhone timings included a separate crypto adapter and are not performance measurements of this PR alone.

Integration

This PR changes auth only. Quiet needs a submodule pointer update to consume the latest commit; its channel index is separate. No native crypto implementation is included or required. The OpenSSL alternative is isolated in TryQuiet/quiet#3541, while native libsodium is being developed separately.

Part of TryQuiet/quiet#3536 and TryQuiet/quiet#3537; intended for TryQuiet/quiet#3539. Targets release/10.0.0.

Import immutable keysets into a private Team-owned store and retain successful delivery bindings across replay. Select keys only through the current state, keeping cryptographic material separate from authorization. Replace the global keyset, lockbox-opening, and key-map caches while preserving standalone validation and caller-owned open results.

Validate with 765 passing auth, CRDX, crypto, and shared tests (87 existing skips), production builds, scoped lint, and separate-sender forced-GC edition checks at 10 and 100 users. The test-inclusive auth typecheck has 37 existing diagnostics versus 39 at the baseline, with no new diagnostics.

Assisted-by: Codex
Discard the Team-owned checked-key store when decryption or merge validation fails, preventing rejected graph prefixes from retaining speculative keys for the Team lifetime. Preserve reuse after successful merges.

Add realistic repeated-rejection regressions for semantic and ciphertext failures, followed by acceptance and reuse of the valid prefix. Both regressions failed before the fix and pass afterward. Validation: 767 tests passed with 87 existing skips, auth production build and scoped lint passed, and the broad test-inclusive typecheck has the same 37 pre-existing diagnostics.

Assisted-by: Codex
holmes and others added 2 commits September 22, 2026 07:19
Resolve packages/auth/src/team/selectors/keys.ts: keep the per-team
checked-key store threading from this branch and main's redacted
missing-key diagnostics (#35).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ce keys owned after a rejected merge

Add checkedKeyProperties.test.ts covering, on real Team graphs: retained
keys are not selectable from a branch that never delivered them; a cached
opening cannot serve a different recipient or a re-addressed delivery;
a rejected merge leaves nothing selectable from its accepted prefix; no
retained secret reaches save() or state; repeated graph replays reuse
one record per delivery.

Team.merge re-imports the team's own lockbox keys into the fresh store
after a rejected merge, so later key use does not re-run keypair
validation on every call.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@holmesworcester

Copy link
Copy Markdown
Author

Landing review (merge onto main + security properties)

Merge: origin/main (c76671a) merged into this branch as 5f636c9. One conflict, packages/auth/src/team/selectors/keys.ts: kept this branch's checkedKeys threading and main's redacted missing-key diagnostics from #35 ('Requested key scope is unavailable', no keymap/lockbox dump). keyErrors.test.ts from main passes against it.

Tests (local, Node 24, pnpm test:run):

files passed skipped
main baseline (c76671a) 100 751 87
this branch after merge 108 809 87
+ new checkedKeyProperties.test.ts 109 814 87

No pre-existing or new failures.

Five properties, with evidence:

  1. Retained key never grants access where current lockboxes don't — PASS. Selection walks only the supplied lockboxes (CheckedKeyStore.visibleKeys, packages/auth/src/lockbox/CheckedKeyStore.ts:77-102); the memoised view is keyed on the immutable collection identity and recipient (keyMap, :104-107), and only reducer-owned frozen collections are memoised (:124). Every Team read goes through state.lockboxes (selectors/keyMap.ts:9-12). Tests: checkedKeyStore.test.ts "selects each branch independently…", incrementalKeySelection.test.ts "separates branches… forgets removed access", checkedKeyMessages.test.ts "…removes selection when role access is removed", and new checkedKeyProperties.test.ts "cannot select a retained role key from a real branch whose lockboxes never delivered it" (real forked graph loaded via teams.load).

  2. Cached opening cannot be replayed for a different delivery/recipient — PASS. The delivery record key is hash(encryptionKey, recipient manifest, contents manifest, ciphertext, recipient keyset commitment) (CheckedKeyStore.ts:36-44), computed before any lookup; a miss re-runs manifest checks, recipient/keys match, decryption, and contents/commitment match (:47-73). Failed opens store nothing. Same pattern in crdx: decryptLink binds hash+sender+recipient+secretKey (crdx/src/graph/decrypt.ts:32-42), verifyLinkSignature binds hash+signature+publicKey (linkSignature.ts:36-41), verifyGraphProof hashes context+payload+signature+publicKey (invitation/verifiedGraphProof.ts:15-20). Tests: checkedKeyStore.test.ts it.each over ciphertext/sender/recipient/contents mutations, "checks different ciphertext even when it advertises an already checked commitment", "binds recipient imports…"; ownedCryptoFacts.test.ts; new "cannot reuse a cached opening for a different recipient device or a re-addressed delivery".

  3. Failed merge leaves no retained keys from rejected prefixes — PASS. Team.merge replaces the store on any throw (Team.ts:247-262). Tests: checkedKeyMessages.test.ts "discards speculative keys after semantic/ciphertext rejection" (asserts re-decryption on every retry), new "leaves no selectable or rechecked material from the prefix of a rejected merge". One fix added here (3229c13): after the reset the Team's own lockboxKeys was no longer an owned record, so every later key read re-ran keypair validation (100/100 calls in the new test before the fix). merge now re-imports lockboxKeys into the fresh store.

  4. No serialization of secrets — PASS. #checkedKeys is a true private field (Team.ts:89); save() serialises only encryptedLinks/childMap/head/root (team/serialize.ts:13-21), and state.lockboxes hold ciphertext + public manifests. New test "never serializes retained secrets with the team or its state" greps 12 secret keys against save() and JSON.stringify(state), asserts no enumerable Team property is the store, and that a reloaded Team re-opens lockboxes.

  5. Memory bounded — PASS by reading, partly by test. Structural/manifest/snapshot/commitment-index caches are WeakSet/WeakMap keyed on immutable objects (lockbox/snapshot.ts:5-7, lockbox/types.ts:45, lockboxAuthorization.ts:853,909). crdx recent-fact indexes are Map<hash, WeakRef> hard-capped at 4096 (linkSignature.ts:66, decrypt.ts:150, verifiedGraphProof.ts:43) with per-owner maps capped at 8; owners are the graph's retained links (validationOwner.ts). The store's strong maps (#material, #deliveries) grow only with distinct accepted deliveries per Team lifetime and are dropped on a failed merge. New test "keeps one retained record per delivery no matter how many graph copies replay it" (50 reconstructed merges, zero lockbox re-opens). The 4096 cap itself is not asserted by a test (module-private).

Not a breaking change: public Team/selector signatures gain only optional trailing checkedKeys/owner params; wire format and save() unchanged; 751 pre-existing tests unchanged and green.

@holmesworcester
holmesworcester marked this pull request as ready for review September 22, 2026 14:24
… key store

Daybreak Blue audit M-1: CheckedKeyStore.import() strongly retained every
distinct valid caller-supplied keyset for the Team's lifetime, so explicit
key lookups with unrelated keysets grew the store without bound.

Ownership is now a WeakMap; only pin() (the Team's own lockbox keys) and
successful delivery contents enter the strong material map. The single
selection view is keyed on the recipient commitment, so repeated lookups
with the same explicit keys still reuse it. Regression tests force GC and
check transient imports are collectable while pinned material stays owned.

Also correct docs/checked-key-material.md (audit I-1): the graph
signature/decryption/proof caches and the commitment trie are part of
this change; describe their bindings, owners and caps.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@holmesworcester

Copy link
Copy Markdown
Author

Daybreak Blue adversarial audit — iteration 1: REJECT (fixed in 277792a)

Independent audit of origin/main...3229c13 (threat model: malicious peer / compromised or removed member vs. retained keys, cached openings, store poisoning, secret exfiltration, memory exhaustion). Full report kept locally at ~/.cache/quiet-auth39-audit/daybreak-auth39-iter1.md.

  • M-1 (Medium, availability): CheckedKeyStore.import() strongly retained every distinct valid caller-supplied keyset for the Team's lifetime, even one that opens no lockbox and is in no accepted graph. Team.keys/keysAllGenerations/allKeys/roleKeys with an explicit keyset therefore let a caller grow the heap without bound (probe: 512/512 imports survive forced GC). Fix 277792a: ownership is a WeakMap; only pin() (the Team's own device/server lockbox keys) and successful delivery contents enter the strong material map; the single selection view is keyed on the recipient commitment so repeated explicit lookups still reuse it. Regression tests force GC and check transient imports are collectable while pinned material stays owned.
  • I-1: docs/checked-key-material.md wrongly listed the graph signature/decryption/proof caches and the commitment trie as out of scope. Fixed: the doc now describes their bindings, owners and caps.
  • Threat-model checklist: (a) retained keys grant nothing without a current lockbox — PASS; (b) no fact reused for changed inputs — PASS; (d) no secret in save()/state/errors — PASS; (c)/(e) FAIL only on M-1.
  • Residuals noted by the auditor (not blocking): #material/#deliveries remain linear in distinct successful deliveries per Team lifetime; the 4096 / 8 caps are verified by reading, not by a cap test; one non-reproducible glibc abort on the first full-suite run.

Suite after fix: 816 passed / 87 skipped. Iteration 2 re-audit running on 277792a.

@holmesworcester
holmesworcester merged commit 62eca51 into main Sep 22, 2026
2 checks passed
@holmesworcester

Copy link
Copy Markdown
Author

Merged to main as 62eca51 after Daybreak Blue iteration 2 ACCEPT on 277792a (report: ~/.cache/quiet-auth39-audit/daybreak-auth39-iter2.md on the review box; iteration 1 summary above).

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