#1813

Evict long-unseen strangers from the identity cache

Openjackjackbitswants to merge intofrom

Stacked on #1812 (base: perf/identity-cache-writes). Before the base PR merges, retarget this one to main: the repo auto-deletes merged branches, which would close this PR.

Squash-merge with this description as the commit message. The first commit's message (e0293310) describes the earlier draft, with its direct-message record; the two commits after it remove that record and correct the docs.

Decision on the earlier draft: keep stranger eviction and the cap, and drop the direct-message record. bitchat keeps conversations in memory only, so nothing about whom you talk to should survive on disk. This version keeps no record of private conversations. The one remaining trace of Noise sessions is trade-off 3.

Why

The identity cache keeps every announced stranger forever. Its size C drives three costs:

  • every real cache write is O(C) (~6 ms at 2,000 identities on an M5, ~25-40 ms on an older iPhone);
  • the launch decode on main is O(C) (33 ms on an M5 at 10,000);
  • prefix scans were O(C) (the base PR indexes those).

At protests and festivals C grows by thousands. The base PR removes the per-announce rewrite, so what remains is the first-contact write and the launch decode, and both scale with C.

What it does

Strangers are evicted once they are long unseen, and the stranger count is capped. A stranger is anyone with none of these relationships:

  • favorited (FavoritesPersistenceService snapshot, fail-closed if favorites can't be read);
  • verified;
  • blocked, petnamed, or carrying notes or a trust level;
  • vouched for, or vouching.

Favorites, blocks, petnames and notes protect someone while they last. Removing a verification leaves a casual trust level, and vouch links never expire. So anyone you ever verified, and anyone a verified peer vouched for, is kept until a panic wipe. That is the protected set as specified; narrowing it would be a separate change.

A private conversation is not a relationship. Someone you only messaged is a stranger like anyone else: forgotten once unseen long enough, and their key is learned again from their next announce. Favoriting or verifying someone keeps them.

Retention rules:

  • lastSeen is a UTC day, refreshed at most weekly, so it lags the last sighting by up to six days.
  • A stranger whose stamp is older than 30 + 7 days is evicted at the next pass, together with its signing-key pin, Noise binding, media pin, nickname-index entry, and index entry. By UTC date, that is 32 to 38 days after the day it was last seen.
  • Above 2,000 strangers, the cache trims to 1,500 by latest possible sighting (the stamp plus a week). Strangers stamped within the last week, which includes everyone seen today or yesterday, rank as seen today, and among them the newest arrivals go first. A flood of minted identities therefore pushes out older stamps first. It reaches a stranger stamped this week only after every newer arrival, and only when more than 1,500 strangers carry such a stamp. Because the stamp lags, an older stamp can belong to a stranger seen two days ago. The announcer is never evicted.
  • A stranger without a stamp is stamped at the next pass and kept 37 days from then. That covers everyone in a cache written before this PR, and anyone whose protection just lapsed.
  • Stamps never move back, and forgetting by age needs passes on two different days to agree, so a single wrong clock reading forgets nothing. A clock that stays wrong across passes on two days ages strangers by its error.
  • A pass runs on the first verified announce after each launch, then about daily, and at once whenever strangers pass the cap. None runs without verified announces, or in a session whose stored favorites can't be read. After a relaunch, or more than two days without a pass, the first pass may hold removals back until a pass on a later day.
  • Everything runs inside the existing upsert barrier, with no timers and no lifecycle hooks.

IdentityCache.lastInteractions: nothing uses it any more.

  • Builds from mid-2025 filled it with a timestamp for every peer whose key they registered. The load now empties it and leaves the write pending. The next write of the cache clears it from the keychain too: the next upsert, any other change, or the flush at termination.
  • The key stays in the blob, always empty: v1.7.0 and earlier decode the cache with a synthesized decoder that requires it, and would discard the whole cache after a downgrade without it.
  • The test-only updateHandshakeState still does not write it (on main it does).

Privacy docs: PRIVACY_POLICY.md (Data Retention Summary) and the "Peer Identity Cache" section of docs/privacy-assessment.md describe the behavior above. That includes who is kept until a panic wipe, the exact first-seen time stored for everyone, and the residual risk below. Both documents' dates are now October 2026; adjust them if this merges in a later month.

Trade-offs

  1. TOFU. Evicting a stranger forgets its signing-key pin, so its next announce is first contact again: whoever announces its Noise key first is pinned.
    • That is the exposure every never-seen peer already has. It applies to unprotected strangers unseen for about five weeks, and past the cap to the least recently seen ones. It now includes people you messaged but never favorited or verified.
    • Someone who mints enough identities to pass the cap can push out a chosen stranger whose stamp is over a week old. That can happen two to eight days after it was last seen, depending on when its stamp was refreshed. Once it is out of range, they can replay its Noise key first.
    • The real peer's announces are then refused on this device until that pin goes. Refused announces refresh nothing, so the pin ages out once the impostor stops announcing. A Noise session with the real peer also rebinds its key.
    • While a peer is in range, the live registry still holds its pin.
  2. Carried traffic from evicted strangers. Packets an evicted stranger signed that arrive while it is out of range are checked against this cache. That covers synced public messages, broadcast files, public voice frames, leaves, and prekey bundles. They are dropped and not relayed.
  3. Remaining on-disk trace of sessions, unchanged from main. A completed Noise session stores the peer's authenticated signing-key binding and its private-media pin. Main keeps them forever; here they go when the stranger is evicted. Sessions start on their own with most directly connected peers. With peers further away they mostly carry private traffic, so until then the binding hints that some was exchanged. The binding is the copied-Noise-key defense, so this PR keeps it. The assessment's residual-risk line says so.

Costs this adds

  • No per-message costs. Sending or receiving a private message or file touches the identity cache exactly as it does without this PR. The PR changes no messaging view-model or coordinator file; it changes only ChatViewModelBootstrapper, which pushes the favorites snapshot. The earlier draft's write on main per new DM partner, and its ~2.9 µs per inbound DM, went with the marker.
  • Each full write and load: the lastSeen stamp adds 21 bytes per identity, about 5-9% at equal C (a first-contact write at 2,000 identities goes from 6.25 to 6.78 ms). Bounded by the cap, while main's C grows without limit.
  • Retention passes: the daily pass costs about 0.4 ms; a cap pass about 8-10 ms, including its write (saved release runs on the previous revision of this PR; the pass now does strictly less).
  • Once per device, on upgrade: a cache holding old lastInteractions entries is written by the next upsert even if that upsert changes nothing. After an upgrade that is usually the same write that stamps legacy strangers.

Tests

SecureIdentityStateManagerRetentionTests (18 tests) covers:

  • the eviction boundaries: kept at 36-37 days, evicted at 38;
  • every protected relationship, plus three peers that are evicted: a plain stranger, a Noise-authenticated stranger (its binding, pin, and media pin go too), and a peer with an old lastInteractions entry;
  • cap ordering, the regulars-plus-flood case, and that the cap counts only strangers;
  • the pass cadence and the O(1) cap trigger;
  • no pass before the favorites snapshot arrives;
  • a full period for strangers without a stamp: legacy entries, and a favorite unfavorited after 100 days unseen;
  • the weekly restamp;
  • clock steps forward and back, and forgetting one pass after an absence;
  • the legacy purge: the entries are gone after load, the next unchanged upsert writes once and only once, the stored blob still decodes in v1.7.0's synthesized shape with the key present and empty, and nothing else is dropped;
  • downgrade decode of the entry shape.

SecureIdentityStateManagerTests checks that a completed handshake writes nothing, and that removing a verification leaves the casual trust level. Bootstrapper, favorites, BLE-announce, and private-media end-to-end tests cover the wiring, including failing closed when stored favorites can't be read (ChatViewModelBootstrapperTests, FavoritesPersistenceServiceTests). The direct-message tests (marker record, refresh, expiry, and forget; view-model and coordinator marking) were removed with the code.

Mutation checks, each built and run against the filtered suites: 13 of 13 detected.

  • Eviction boundary (3): evicting at 37 days, at 30 without the refresh interval, and a day late.
  • Cap ordering (5): trimming by the raw stamp, most recent first, oldest arrival first on ties, an evictable announcer, and a cap that counts protected identities.
  • Legacy purge (5): no purge; a purge kept in memory only; a purge that drops the key older builds need; the handshake writing the field again; and no purge with the field protecting again.

SwiftPM on this tree (swift test --build-system native --parallel): 2,169 Swift Testing tests in 232 suites and 246 XCTest cases, all passed. SwiftLint 0.65.1 --strict: 0 violations in the 14 Swift files this PR changes. Periphery 3.8.0 --strict with the CI config and baseline, run before the last review round (which added only comments, docs, and one test method): nothing new; the only findings are the 16 local-toolchain-only ones in ContentView and ViewSmokeTests that origin/main shows too. iOS simulator (bitchat (iOS) scheme, iPhone 17 Pro Max on iOS 27.0, one test runner as CI runs it): 2,418 of 2,418 passed (246 XCTest cases and 2,172 Swift Testing tests in 232 suites), none failed or skipped.

🤖 Generated with Claude Code

Activity

4
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✅ Completed 2026-10-25T21:14:27.095676Zdf5b671Draft marked ready
ℹ️ 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.