#1812

Persist the identity cache only when it changes

Openjackjackbitswants to merge intofrom

Summary

SecureIdentityStateManager.upsertCryptographicIdentity rewrote the whole identity cache on every verified announce, even a byte-identical re-announce. Each rewrite is a JSON encode, an AES-GCM seal, and a keychain delete+add, and it grows with every peer ever seen.

Measured (M5 Max, in-memory keychain): 1.6 ms per announce at 500 identities, 6.2 ms at 2,000 and 33 ms at 10,000, plus 3-8 ms of keychain work on a device. Main-thread isVerified reads waited behind the save barrier. A batch of 16 vouches did 16 synchronous full rewrites on the main thread: 101 ms at 2,000 identities on the Mac, roughly 0.35-0.65 s on an older iPhone.

This PR writes only when something changes, batches vouches into one write, and indexes short-ID lookups. It also fixes two persistence bugs the change exposed.

Retention (bounding the cache size) is not in this PR. It changes TOFU and privacy behavior and has its own stacked draft PR for that decision.

Changes

CommitWhat it does
Persist the identity cache only when an upsert changes itWrites only for a new identity, a first signing-key pin, a Noise key change, or a nickname change. Every trust-relevant write stays synchronous, done before the call returns.
Store a vouch batch with one identity-cache writerecordVouches(_:voucherFingerprint:) applies a batch in one barrier with at most one write. An equal or older refresh is a no-op.
Index persisted identities by short peer IDgetCryptoIdentitiesByPeerIDPrefix becomes O(1) through an in-memory index (never persisted) instead of scanning every identity on per-packet paths.
Address review: retry a failed identity-cache write on the next upsertSee the fixes below.
Address review: fail the upsert benchmark fast, and cite saved runsBenchmark hygiene.

Fixes the change surfaced:

  • A single failed keychain write could lose the whole identity cache. KeychainManager.saveData deletes, then adds. If the add failed, the item was gone, but pendingSave was still cleared, so forceSave at termination did nothing. Today the next announce papered over this by accident. With the change gate it wouldn't, so a failed write now stays pending and is retried:
    • on the next real change;
    • on forceSave / deinit;
    • at most every 30 s on unchanged upserts.
  • bindAuthenticatedSigningPublicKey changed the crypto identity's signing key without saving it. It was only persisted by the unconditional upsert that followed. It now saves what it changes.

Measured

M5 Max, in-memory keychain (securityd cost excluded), C = 2,000 identities unless noted. Before = origin/main with the same benchmarks.

PathBeforeAfter
Unchanged re-announce (identity.upsertKnownPeer)6.2 ms, 1 full write~0.4 µs, 0 writes (1.56M/s debug on this branch, ~2M/s release)
Unchanged upsert at C = 50 / 500 / 2,000 / 10,000186 / 1,741 / 7,117 / 39,645 µs0.4 µs at every size
Real BLEService ingest of an unchanged verified announce, Ed25519 included6,638 µs, 1 write per announce66-148 µs, 0 writes
handleVouchPayload on main, 16 vouches, C = 2,000 / 10,000102.5 / 581.5 ms, 16 writes7.3 / 41.8 ms, 1 write
Prefix lookup (identity.lookupByPeerIDPrefix)43 µs0.5 µs
Main-thread isVerified while another thread re-announcesp99 29 msp99 20 µs
An everyday hour (5 known peers announcing every 10 s)1,800 keychain rewrites0
Noise peer-state writes, first / repeat session3 / 12 / 0
Cache load at launch, C = 2,000 (cost of rebuilding the new short-ID index)6.1 ms6.5 ms (+0.4 ms)

On an older iPhone (CPU 3-5× slower, 3-8 ms per keychain write), an unchanged announce at 2,000 identities goes from 24-44 ms to microseconds, and a vouch batch from 355-640 ms to about 25-45 ms. A first contact still writes once, as before: that is a real change.

Behavior

  • Unchanged: persisted state and the stored format. Only no-op rewrites are skipped; no new fields.
  • Unchanged: the BLE/Nostr wire format and TOFU behavior. A pinned-key mismatch still refuses with no write.
  • Untouched: app lifecycle, Tor, AppRuntime, timers, Tasks, and background hooks. The only lifecycle-reachable change is that forceSave() at termination now retries a previously failed write.

Tests

  • New, each mutation-checked:
    • 7 SecureIdentityStateManagerTests: no rewrite on unchanged re-announce, rewrite on real changes (with reload), peer-state sequence writes, bind persistence, forceSave retry, retry on next announce, and retry backoff;
    • BLEAnnounceIdentityPersistenceTests (real handler, real signatures);
    • 4 vouch-batch tests and handleVouchPayload_recordsAcceptedAttestationsInOneCall;
    • a prefix-index equivalence test against the old linear scan;
    • 3 benchmarks with floors.
  • SwiftPM: 2,166 Swift Testing tests, 0 failures, on this branch's tree. The full branch with retention passed 2,175 + 247, twice.
  • Builds, lint, perf gate: macOS build is clean. SwiftLint and Periphery have no new findings versus main. The perf gate passes on this branch (19/19 floors).
  • iOS simulator: 2,396/2,396 passed on this branch (dedicated iPhone 17 Pro Max simulator, iOS 27.0, serial as in CI).

Merge notes

  • Conflicts: the only conflict with #1809 and the announce PR is bitchatTests/Performance/perf-floors.json, because each PR adds its own floors and reference numbers. Keep both sets when resolving.
  • Stacked draft: the retention PR is stacked on this branch. Retarget it to main before merging this one. The repo auto-deletes merged branches, and deleting a base closes the PRs stacked on it.

Follow-ups

  • Retention: the stacked draft PR (cap, TOFU and privacy trade-offs for your decision). It's what bounds C, which still drives first-contact writes and launch decode.
  • KeychainManager.saveData: still deletes, then adds. A focused SecItemUpdate change would close that window.

🤖 Generated with Claude Code

Activity

6
chatgpt-codex-connector[bot]5d ago

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

ReviewStatusCommitReview trigger
📝 Code Review🔄 Running since 2026-10-25T02:25:46.585197Zd806850PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.