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
| Commit | What it does |
|---|---|
| Persist the identity cache only when an upsert changes it | Writes 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 write | recordVouches(_: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 ID | getCryptoIdentitiesByPeerIDPrefix 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 upsert | See the fixes below. |
| Address review: fail the upsert benchmark fast, and cite saved runs | Benchmark hygiene. |
Fixes the change surfaced:
- A single failed keychain write could lose the whole identity cache.
KeychainManager.saveDatadeletes, then adds. If the add failed, the item was gone, butpendingSavewas still cleared, soforceSaveat 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.
bindAuthenticatedSigningPublicKeychanged 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.
| Path | Before | After |
|---|---|---|
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,000 | 186 / 1,741 / 7,117 / 39,645 µs | 0.4 µs at every size |
| Real BLEService ingest of an unchanged verified announce, Ed25519 included | 6,638 µs, 1 write per announce | 66-148 µs, 0 writes |
handleVouchPayload on main, 16 vouches, C = 2,000 / 10,000 | 102.5 / 581.5 ms, 16 writes | 7.3 / 41.8 ms, 1 write |
Prefix lookup (identity.lookupByPeerIDPrefix) | 43 µs | 0.5 µs |
Main-thread isVerified while another thread re-announces | p99 29 ms | p99 20 µs |
| An everyday hour (5 known peers announcing every 10 s) | 1,800 keychain rewrites | 0 |
| Noise peer-state writes, first / repeat session | 3 / 1 | 2 / 0 |
| Cache load at launch, C = 2,000 (cost of rebuilding the new short-ID index) | 6.1 ms | 6.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 thatforceSave()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,forceSaveretry, 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.
- 7
- 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
mainbefore 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 focusedSecItemUpdatechange would close that window.
🤖 Generated with Claude Code
Codex Review Summary
This comment shows the latest Codex review activity on this pull request.
d806850ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.