Fixes the prekey-bundle PR review + CI failure:
- CI root cause: the receive queue (mesh.message) is concurrent, so a
gossiped prekey bundle can be processed before the announce that binds
its owner's signing key. The old handler dropped such bundles outright,
so under CI parallel load the bundle was permanently lost and the
cache/gossip tests flaked (verifiedBundleEntersGossipStore,
prekeySealedMailTravelsViaCourierAndOpens). Bundles that arrive before
their binding are now retained per-owner (bounded) and re-attempted when
the verified announce lands, atomically to avoid a check-then-act race.
- Authenticate the OUTER prekey-bundle packet (Codex P2 / review MEDIUM):
require senderID == PeerID(bundle.noiseStaticPublicKey) and verify the
packet's Ed25519 signature (covers senderID + timestamp) against the
owner's bound signing key, in addition to the inner bundle signature.
Stops replay under a fresh timestamp / fake senderID.
- Key the gossip prekey-bundle store/dedup by the bundle's authenticated
identity (noiseStaticPublicKey), not the unauthenticated packet
senderID, so one valid bundle sprayed under many fabricated sender IDs
can't multiply entries and exhaust the 200-owner cap.
- Bump published-bundle generatedAt strictly on consume (Codex P1):
consuming a prekey shrinks the published bundle, so it now republishes
with a strictly newer generatedAt and re-gossips, so peers replace the
cached copy and stop assigning the consumed ID before its 48h grace.
- Guard the panic/clear detached Application Support tree-deletes behind
TestEnvironment.isRunningTests: the SPM test process shares that tree,
so the wipe could land mid-test and flake file-dependent tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Courier envelopes were sealed with one-way Noise X to the recipient's
long-lived static key, so a later compromise of that key exposed every
envelope captured in transit. This adds one-time prekey bundles:
- PrekeyBundle (MessageType 0x24): 8 one-time Curve25519 public prekeys
bound to the owner's Noise static key by an Ed25519 signature over
"bitchat-prekey-bundle-v1" canonical bytes; gossiped mesh-wide on its
own 60s sync round (SyncTypeFlags bit 9, 200-peer cap, 24h freshness)
and verified against the announce-bound signing key before caching.
- Sealed envelope v2: Noise X where the responder static is the one-time
prekey, prologue "bitchat-prekey-v1" || prekeyID. Sender identity rides
encrypted inside and is authenticated exactly like v1 (blocked-sender
check included). CourierEnvelope gains an optional prekeyID TLV that
v1 decoders skip as unknown.
- Local prekeys live in the Keychain; consumed privates survive a 48h
grace window for spray-and-wait redeliveries, then are deleted (the
forward-secrecy clock starts at deletion). The batch tops back up and
re-gossips when unconsumed count drops below 3, and everything is
wiped in panic mode.
- Routing: courier sealing picks a cached verified bundle when one
exists (one prekey per message, reused across deposit retries), with
the advertised .prekeys capability as a veto for on-mesh peers, and
falls back to static sealing otherwise.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Store-and-forward previously delivered to an out-of-range peer only if a
mutual favorite happened to be connected at send time and later met the
recipient directly, and everything except courier envelopes died with the
app process. This closes those gaps end to end:
- Persist the MessageRouter outbox to disk, sealed with a ChaChaPoly key
held only in the Keychain (no plaintext at rest); queued private
messages now survive an app kill and flush on next launch.
- Deposit retry: queued messages are re-deposited whenever a new eligible
courier connects, tracked per message so the same courier is never
double-burned, until 3 distinct couriers carry it or it expires.
- Tiered open couriering: signature-verified strangers can now carry mail
(2 envelopes/depositor into a 20-slot pool) alongside mutual favorites
(5 each); overflow evicts verified-tier mail before favorites'.
- Spray-and-wait: envelopes carry a copy budget (4, capped 8, new TLV,
wire-compatible with old clients); couriers split half their remaining
budget with each newly encountered courier so mail diffuses through a
moving crowd.
- Remote handover: a verified relayed announce now floods a copy toward
the multi-hop recipient (directed-relay treatment, 10-min per-envelope
cooldown) while the carried original stays put for a direct encounter.
- Public history: gossip-sync window for whole public messages widened
from 15 min to 6 h, matched on the receive-acceptance side, and the
message store persists to disk so devices bridge partitions and
restarts ("town crier").
- Privacy-safe local delivery counters (bare tallies, log-only) so the
store-and-forward stack is measurable on-device.
- Panic wipe now also clears the sealed outbox, gossip archive, and
counters.
- Rewrite WHITEPAPER.md to describe the app as implemented (Noise XX/X,
actual flood control, courier system, gossip sync, Nostr path); the old
document described a bloom filter, three fragment types, and a
MessageRetryService that don't exist.
1037 macOS tests pass (17 new); iOS builds.
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Harden REQUEST_SYNC and stop gossip-sync re-send loops
Two fixes from an end-to-end review of the sync path:
Efficiency: the GCS filter (400B, p=7) covers ~355 packet IDs, but stores
hold up to 1000 messages + 600 fragments + 200 files. Once a mesh
accumulates more than the filter can cover, responders re-sent the entire
older tail to every requester every round — ~120KB per pair per 30s during
file transfers, dropped by dedup after the airtime was already burned.
Requesters now stamp the dormant sinceTimestamp TLV with the oldest
timestamp their filter covers, and responders skip older packets (announces
exempt: they carry the signing keys needed to verify everything else).
Periodic sync also sends one request per type schedule instead of a union
filter, so fragment floods can't crowd messages out of the filter budget.
Security: a ~40-byte unsigned REQUEST_SYNC with an empty filter could elicit
a full store replay (~900KB) — an unauthenticated >10,000x amplification
vector, repeatable in a tight loop and relayable with crafted TTL to fan the
drain out of every reachable node. Requests now require ttl == 0, a valid
signature from the claimed sender's announced signing key, and a matching
link binding; REQUEST_SYNC is never relayed regardless of TTL; and responses
are rate-limited per peer (8 per 30s sliding window, ~3x the legitimate
cadence).
Cross-platform: verified against bitchat-android — it signs REQUEST_SYNC and
sends SYNC_TTL_HOPS = 0, so both gates hold; it neither sends nor honors
sinceTimestamp yet, so mixed pairs keep today's behavior with no regression.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Address Codex review: enforce no-relay on route path, exact since-cursor
Two P2 findings from Codex on the REQUEST_SYNC hardening:
- Route-forwarding bypass: handleRequestSync's early return for a rejected
(nonzero-TTL / unsigned) request still fell through to
forwardAlongRouteIfNeeded, which relays any routed packet with ttl > 1
regardless of type. The no-relay invariant was only enforced on the flood
path. BLERouteForwardingPolicy now suppresses REQUEST_SYNC outright, so a
crafted request with a route and TTL headroom can't be forwarded to the
next hop either.
- Inexact since-cursor: GCSFilter.buildFilter trimmed by hash order when the
encoding overflowed the byte budget, so the cursor (computed from the
untrimmed prefix) could claim coverage of timestamps whose packets were
dropped from the filter — re-sending exactly those every round. buildFilter
now trims from the input tail (oldest, since candidates are newest-first)
and reports includedCount; the cursor is derived from that, so the covered
set is always a contiguous newest-prefix and the cursor is exact.
Adds GCSFilter includedCount coverage (full vs trimmed), a route-forwarding
test for REQUEST_SYNC, and makes the truncated-cursor test robust to trim
variance. Full suite: 1029 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat: Implement Request Sync Manager (V2 Sync)
- Add RequestSyncManager to track and attribute sync requests
- Update BitchatPacket and BinaryProtocol to support IS_RSR flag (0x10)
- Update RequestSyncPacket with new TLV fields (sinceTimestamp, fragmentIdFilter)
- Update GossipSyncManager to use unicast sync requests and mark responses as RSR
- Update BLEService to enforce timestamp validation for normal packets and exempt valid RSRs
- Add documentation for the new sync manager mechanism
* fix: Resolve compilation errors in V2 Sync implementation
- Remove duplicate restartGossipManager in BLEService
- Add missing TransportConfig constants for sync
- Add 'sync' log category to BitLogger
- Add missing BitLogger import in GossipSyncManager
* fix: Update tests for V2 Sync changes
- Add requestSyncManager parameter to GossipSyncManager init in tests
- Implement getConnectedPeers stub in RecordingDelegate
- Remove unused variable warning in SubscriptionRateLimitTests
---------
Co-authored-by: a1denvalu3 <>
Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
* Align DM sheet toolbar with people list
* Gate stale gossip announcements
* Remove stale peer messages during gossip cleanup
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
* Noise types use PeerID
* Fix tests
* Extract `NoiseSessionManager` into a separate file
* Extract `NoiseSessionState` into a separate file
* Remove `failed` state from `NoiseSessionState`
* Extract `NoiseSessionError` into a separate file
* PeerID 12/n: `GossipSyncManager`