Compare commits

..
Author SHA1 Message Date
jackandjack d202076925 Harden Nostr envelope migration compatibility 2026-07-25 20:44:14 +02:00
jackandjack 54c73fd257 Relabel private Nostr envelopes honestly 2026-07-25 20:44:14 +02:00
jack 4bd6e3d445 Invalidate media deletion callbacks during panic 2026-07-25 20:43:07 +02:00
jack 62e9a1d83d Make private media deletion transactional 2026-07-25 20:42:15 +02:00
jack 8a6d874a22 Retry confirmed private media after reconnect 2026-07-25 20:41:40 +02:00
jack 8f4a69a685 Persist private media delivery receipts 2026-07-25 20:41:27 +02:00
jack c3074d5dd3 Correlate private media delivery receipts 2026-07-25 20:41:27 +02:00
jack 17113e6fc8 Deliver live transport events synchronously 2026-07-25 20:41:13 +02:00
jack 5ca9c41a30 Discard deferred Noise ciphertext during panic 2026-07-25 20:40:59 +02:00
jack 05b57c878f Preserve early Noise ciphertext across reconnects 2026-07-25 20:40:35 +02:00
jack 7c8ef3be2a Stabilize synchronous Noise restart tests 2026-07-25 20:39:44 +02:00
jack 74f0c96ac0 Fix ordinary Noise handshake races 2026-07-25 20:39:44 +02:00
jack fb07d73d5c Fix cached Noise reconnects atomically 2026-07-25 20:39:44 +02:00
jack 40ca914f2a Discard private-media callbacks during panic 2026-07-25 20:39:29 +02:00
jack 856ff98245 Fix private media Noise round-trip fixture 2026-07-25 20:39:29 +02:00
jackandjack 0621042103 Make Noise generation race test deterministic 2026-07-25 20:39:29 +02:00
jackandjack aee5bee8e8 Avoid authentication callback queue starvation 2026-07-25 20:39:29 +02:00
jackandjack 4ba895e4ce Bind capability state to Noise generations 2026-07-25 20:39:29 +02:00
jackandjack 8a3097fd41 Authenticate private media capabilities in Noise 2026-07-25 20:39:29 +02:00
jackandjack 86d8bb70f7 Harden private media migration compatibility 2026-07-25 20:39:29 +02:00
jackandjack 3f18768112 Encrypt private media before fragmentation 2026-07-25 20:39:06 +02:00
jackandjack 84e79f7641 Bind Noise sessions to claimed peer identities 2026-07-25 20:38:58 +02:00
jack f6081dad31 Invalidate queued BLE ingress during panic 2026-07-25 20:38:06 +02:00
jack f0c8b4e2ea Harden panic recovery and service shutdown 2026-07-25 17:43:12 +02:00
jackandjack 5ba6f32f61 Scope install markers to iOS 2026-07-25 17:15:24 +02:00
jackandjack 43c7fc52ba Make panic wipe deterministic and device-bound 2026-07-25 17:15:24 +02:00
ca18843bb0 Add Persian (fa) localization and an in-app language picker (#1443)
* Add Persian (fa) localization and an in-app language picker

Persian was the one notable gap in the 29-language catalog. Translate all
381 strings (plus the share extension) with proper plural substitutions,
and register fa in knownRegions.

Settings gains a LANGUAGE section: a picker over every language the bundle
ships (native names via Locale), backed by an AppleLanguages override so
users can run bitchat in a language different from the device's. Localization
resolves at process start, so the picker surfaces a restart note.

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

* Remove unused AppLanguageSettings.currentOverride (Periphery)

AppInfoView reads the override via @AppStorage, so the accessor was dead
code and failed the Periphery CI scan.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2026-07-25 15:42:17 +02:00
593fd7d737 Harden public intake bounds against untrusted growth (#1451)
* Bound public rate-limit buckets against attacker-keyed growth.

Keep the NIP-13 PoW sender bypass, skip content-bucket minting on
sender reject, and evict idle/oldest entries at a hard cap.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Stop Cashu-looking text from skipping long-message guards.

Oversized public content always collapses and takes the plain
formatting path so remote tokens cannot force layout/regex DoS.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Cap teleported geohash participant markers.

Bound the set with FIFO eviction, clear it on channel switch, and
prune markers that leave the visible participant list.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Bound untrusted Nostr relay frames and event tags.

Reject oversized inbound messages before JSON parse, cap tag
arrays/values at decode, and stop logging raw tag contents.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fail soft when Noise handshake state is unexpectedly missing.

Replace the initiator startHandshake force unwrap with a guard
that throws invalidState instead of crashing.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Cap geohash nickname cache from remote Nostr events.

FIFO-evict at capacity, clear on channel switch, and prune
nicknames that leave the visible participant list.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Avoid overlapping exclusive access in the rate limiter.

Make bucket helpers static so inout dictionary updates do not
conflict with a mutating call on self.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix rate-limiter tests for mutating allow under #expect.

Call allow outside the macro so Swift Testing does not capture an
immutable copy of the struct.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop unused WebSocket data helper; reset rate limiter on panic wipe.

dataWithinInboundLimit replaced the unbounded path, and panic clear
should not leave public intake buckets behind.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 12:25:46 +02:00
733098bb63 fix: use country-level resolution for low-precision geohashes (#1044)
* fix: use country-level resolution for low-precision geohashes (#887)

* fix: skip admin fallback when country exists but is duplicate

Prevents mixed labels like "United Kingdom and Scotland" for
single-country geohashes. Admin fallback now only triggers when
no country is available from the placemark.

* fix: migrate stale low-precision bookmark names so country-first logic applies

Users who bookmarked a <=2-char geohash before the country-first resolver
kept the old administrativeArea cache (e.g. "England" for `gc`) because
resolveBookmarkNameIfNeeded bails when bookmarkNames[gh] is non-nil. Add a
one-shot, versioned migration that drops cached names for <=2-char geohashes
on load; the next LocationChannelsSheet .onAppear re-resolves them via the
fixed logic. Higher-precision entries are untouched.

* Fix low-precision geohash country names

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2026-07-10 15:16:02 +02:00
jackandGitHub 820c933958 Harden PTT audio and the 1.7.1 release (#1423)
Centralize PTT and voice audio-session ownership, harden courier/bridge/outbox delivery and recovery, correct location and delivery-state races, add privacy/release metadata, and ship reproducible universal Arti slices with Release CI coverage.

Validated by the full iOS suite, repeated audio/fragment/performance regressions, BitFoundation tests, strict lint and dead-code analysis, universal iOS Release builds, and iOS/macOS archives.
2026-07-10 14:04:09 +02:00
b205a55523 Consolidate duplicate BLE links after restore; duplicate fragment-stream suppression; onChange coalescing (#1426)
* Consolidate duplicate BLE links after restore; suppress duplicate fragment streams; onChange coalescing

Field evidence (two-phone test after BLE state-restoration relaunches):
both phones held 2-3 simultaneous same-role connections to each other —
one side received every packet from three distinct centrals all bound to
the same peer — so every PTT voice frame arrived 3x, and a 41KB voice
file went out as TWO complete independent 89-fragment streams (different
assembly ids), both fully reassembled and the duplicate only dropped at
the very end by messageID dedup. 2-3x airtime/battery on all traffic
plus doubled reassembly memory.

Root causes and fixes:

- Duplicate links stayed unbound forever: announces (the only packet
  that binds a link to a peer) went through the per-peer duplicate-link
  collapse, so a peer's second/third link never received the announce it
  needed to become bound — and unbound links pass the collapse untouched,
  so every broadcast sprayed down all of them. Direct announces now
  bypass the collapse and reach every live link (relayed announces keep
  it); once bound, the existing collapse dedups all traffic.

- Same-role link retirement: a verified direct announce now consolidates
  our central-role connections to that peer — keep the link the announce
  arrived on (or the most recently bound one), cancel other connected
  links bound to the same peer. One connection per role per peer is the
  normal dual-role topology; only same-role duplicates are touched, only
  links already announce-bound are retired (never pre-announce links),
  at most one retirement per peer per rebind-cooldown window, and the
  peer keeps a live link either way. Directness stays forgeable (TTL is
  unsigned), so a replay could nominate the survivor — bounded by the
  cooldown and by DM routing's existing canDeliverSecurely gate. A
  rotation rebind now also cancels other stale links still bound to the
  rotated-away ID, so the ghost identity retires promptly.

- Deterministic preferred-link collapse: when several bound links to one
  peer are candidates, collapse now keeps the peer's most recently bound
  link (the reverse-mapped one) instead of dictionary order; links
  without a discovered characteristic are excluded from fanout (they
  cannot be written to, and could silently eat a peer's collapsed copy).
  links(to:) now reports all bound peripheral links, and removing one
  duplicate no longer clobbers the reverse map of the survivor.

- Duplicate fragment streams: a transferId-less resend (gossip-sync
  replay, spool) of file content already being fragmented out to a
  covering audience is dropped at the outbound scheduler (broadcast
  covers everyone; directed covers its recipient). App-initiated sends
  carry an explicit transferId the progress UI tracks and always run.
  A peer that asks after the stream completes still gets a resend.

- didUnsubscribeFrom no longer flaps a peer that is still live on other
  links (the far side retiring its duplicate arrives as an unsubscribe);
  didDisconnectPeripheral bookkeeping is skipped for self-retired links
  by removing the store entry before cancelling.

Also: guard the DeliveryStatus onChange state write in TextMessageView/
MediaMessageView (unconditional per-row writes under a message storm
tripped SwiftUI's "tried to update multiple times per frame" warning).

The restore-path bgRemaining=∞ log was already fixed on main (#1425
review follow-up 07b5ac31: init-time seed + sampler-routed restore
captures); verified, no change needed.

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

* Review fixes: multi-link disconnect guard, characteristic-aware retirement

Adversarial review of the duplicate-link PR (merge-with-nits):

- didDisconnectPeripheral gets the same multi-link guard as the
  unsubscribe path: when a duplicate link drops naturally while the peer
  stays live on another (dual-role central link, or a second bound link
  during the post-restore consolidation window), peer-disconnect
  bookkeeping (markDisconnected + disconnect notify) no longer runs — a
  UI blip until the next announce re-marked the peer connected. The
  reverse map was just repaired onto a connected survivor, so
  directLinkState is the accurate probe. Scan restart and connect-slot
  refill stay unguarded: they respond to the physical drop regardless of
  remaining logical links. (No unit test: driving the CBCentralManager
  delegate requires CBPeripheral instances, which cannot be constructed
  in tests; the policy pieces backing the guard are covered.)

- Retirement is now characteristic-aware: the policy snapshot carries
  characteristic presence, and keptPeripheralUUID selects anchors only
  among writable links while any exist — consolidation must not keep a
  link mid-service-rediscovery (didModifyServices cleared its
  characteristic) and cancel the writable duplicate, stranding outbound
  traffic on the central link until rediscovery finishes. When neither
  anchor is writable but a writable duplicate exists, consolidation
  defers to a later announce instead of guessing. The reverse-map
  survivor repair in removePeripheral prefers writable links for the
  same reason. Three new policy tests cover charless-vs-writable.

Deferred with code comments per review: central-side collapse keeps the
oldest subscription (no recency signal; remote consolidates within its
cooldown), and the extra preferred-bindings bleQueue hop in
sendOnAllLinks (fold into a combined snapshot if profiling flags it).

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

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 19:35:18 +02:00
f8ab0a7dc0 Fix restore-path main↔bleQueue deadlock and courier drop amplification (#1425)
* Fix restore-path main↔bleQueue deadlock and courier drop amplification

A device froze permanently in a two-phone test. Debugger stacks showed an
ABBA deadlock: the main actor was in bleQueue.sync (delivery-ack send →
broadcastPacket → readLinkState) while bleQueue was in main.sync
(captureBluetoothStatus reading backgroundTimeRemaining). The load that
lined the two edges up came from a courier-drop amplification storm:
drop dedup was in-memory only while the outbox driving 120s re-deposits
is persisted, so every relaunch republished the same undelivered DM as a
fresh 24h relay drop and every gateway relaunch re-fetched the whole
backlog — ~20 copies of one DM delivered in 40ms, each triggering
decrypt + delivery + ack + handshake work.

Fixes, in rank order:
- Edge B (P0): captureBluetoothStatus no longer main.syncs from bleQueue;
  backgroundTimeRemaining is sampled on main and cached behind a lock.
  Invariant documented: bleQueue must NEVER sync-dispatch to main.
- Edge A (P0, defense in depth): sendDeliveryAck / sendReadReceipt /
  sendPrivateMessage / sendNoisePayload / triggerHandshake hop to
  messageQueue like sendMessage, so no main-actor call path reaches
  readLinkState's bleQueue.sync.
- Drop dedup (P1): publishedDropKeys and seenDropEventIDs persist across
  relaunches (new BridgeDropDedupStore, entries expire with the 24h
  NIP-40 drop window; wiped on panic) — one drop per message ID per 24h
  regardless of relaunch count.
- Receiver dedup (P1): openCourierEnvelope dedups on the inner private
  message ID before delivery, so a duplicate copy costs one decrypt and
  never re-delivers, re-acks, or re-triggers a handshake.
- Handshake gating (P2): queued acks initiate a Noise handshake only for
  reachable peers; mail from absent/rotated identities no longer turns
  each copy into a mesh-wide handshake flood (the ack stays queued and
  flushes when a session eventually establishes).
- Outbox (P3): re-enqueueing a queued message ID carries over its
  depositedCourierKeys so resends stop re-burning the same courier slots.

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

* Review fixes: offline-drop durability, gateway handoff retry, restore-log freshness, coalesced persist

Adversarial review of the storm/deadlock PR surfaced four issues:

- Offline blackhole (must-fix): a deposit made while relays were down
  persisted its dedup key even though the drop only sat in the in-memory
  pending queue — app killed before reconnect meant the relaunch lost the
  drop but the persisted key blocked every re-deposit for 24h. The
  persisted snapshot now excludes keys still pending; they become durable
  only when flushPendingDrops actually publishes them.
- Gateway handoff: seen-event IDs were consumed before the deliverToPeer
  handoff; a failed handoff (peer walked away) permanently dropped the
  event for a single-gateway island. deliverToPeer now reports whether
  the handoff was attempted, and a failure releases the seen slot so a
  relaunch or backlog redelivery retries.
- Restore-path logs: central/peripheral-restore captures logged the init
  sentinel bgRemaining=∞. The cache is now seeded in init's main-thread
  branch and restore captures route through the sampler, which refreshes
  the cached budget before logging.
- Persist cost: the dedup record was a full JSON encode + atomic write on
  the main actor per mutation (once per event during a backlog re-fetch).
  Writes now coalesce behind a 1s window, flushed immediately on
  background/terminate; panic wipe stays immediate.

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

* Remove BoundedIDSet.remove, orphaned by the ExpiringIDSet migration

The drop-dedup sets that needed slot release moved to ExpiringIDSet;
remaining BoundedIDSet users only insert and check. Periphery caught it.

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

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 18:23:46 +02:00
304460ee83 Nearby notes: tap-to-reveal (consent-gate the location subscription) + subscription hygiene (#1422)
* Nearby notes: tap-to-reveal before any relay REQ, plus shared subscription pool

The nearby-notes counter used to open a live building-precision (precision-8)
geohash REQ to the closest geo relays whenever the mesh public timeline was
visible — a passive location side-channel with no opt-in. Now nothing
subscribes until one explicit act reveals the counter for the session: tapping
the new "check for notes left here" line on the empty mesh timeline (static,
no network), opening the notices sheet's geo tab, or a successful /drop. The
app-info setting stays the default-ON kill switch and still gates /drop.

Subscription hygiene alongside:
- LocationNotesManager.deinit now unsubscribes its live REQ (hopping to the
  main actor like the timer teardown) instead of only invalidating timers.
- New refcounted LocationNotesPool dedupes the counter's and the notices
  sheet's identical 9-cell kind-1 REQs into one shared manager per geohash;
  both callers release-and-reacquire instead of retargeting in place, and the
  sheet releases its ref on dismissal.

The reveal affordance is localized across all 29 catalog locales, and new
NearbyNotesCounterTests cover the no-REQ-before-reveal contract, the 9-cell
filter, NIP-40 expiry handling, single unsubscribe on deactivate, and pool
refcounting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Review fixes: permission-gate the hint, exclude building cell from pre-reveal sampling, explicit-act reveal only

Fixes the verified review findings on the tap-to-reveal PR:

- Permission dead-end: the "check for notes left here" hint now renders
  only when location permission is already authorized (it never prompts).
  Previously a location-denied install could tap it, flip the sticky
  revealed flag, and get nothing for the rest of the session because
  retarget() guards on authorization. Predicate lives on
  NearbyNotesCounter.offersRevealHint(permissionState:) and is reactive
  to the published permission state.

- Building-cell sampling: background geohash sampling subscribed
  geo-sample-<gh> for every regional level including the precision-8
  building cell, pre-reveal — contradicting the claim that nothing
  building-precision hits a relay before the explicit act.
  GeoChannelCoordinator now excludes the building level until
  NearbyNotesCounter.revealed (injectable publisher for tests); coarser
  levels keep the nearby-conversation hint and participant counts
  working, and bookmarks stay exempt (bookmarking is explicit).

- Implicit reveal: opening the notices sheet no longer reveals — the
  sheet auto-lands on the geo tab whenever a location channel is
  selected, so browsing a remote geohash and opening notices revealed
  the LOCAL building subscription. reveal() now fires only on the
  person actively picking the geo segment, and only when the sheet has
  a geo scope (the empty-mesh hint tap and /drop stay as before).

- Subscription hygiene: switching the sheet geo → mesh releases the
  pooled notes manager (the REQ was left streaming behind the mesh
  board); switching back re-acquires from the pool. The dismissal
  release stays balanced — liveGeoManager is nil after the tab-switch
  release.

- VoiceOver: the hint button exposes the plain localized action text
  instead of the decorated "* 📍 … *" label.

- Removed LocationNotesManager.setGeohash: zero callers, and calling it
  on a pooled instance would corrupt the pool's keying and refcounts.

New tests: hint permission gate, explicit-geo-tab reveal contract,
building-cell sampling exclusion before/after reveal, pool
release/re-acquire round trip. Full suite (1467) green; iOS simulator
build green.

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

* Deflake peer-snapshot binding waits: longTimeout for the multi-hop pipeline

CI failed once on initialization_bindsPeerSnapshotsIntoAllPeers: the
snapshot -> allPeers binding crosses the mock transport's unstructured
Task, UnifiedPeerService.updatePeers, a receive(on: main), and another
Task { @MainActor } in bindPeerService — all contending with every
parallel worker. On the failing runner the whole suite took 10.1s
(usually ~4.4s locally), so the positive 5s defaultTimeout wait lost
the race. That's exactly the case TestConstants.longTimeout documents;
passing runs return as soon as the condition holds and never pay it.

Not caused by the tap-to-reveal changes: nothing on that pipeline was
touched, and 20 full parallel-suite loops each on the branch and on
origin/main reproduce zero failures locally. The two sibling waits on
the same pipeline in this file get the same timeout.

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

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 16:47:08 +02:00
b7c6f42b3a Close forged-directness link-rebind DoS on DM routing (#1421)
* Gate connected-link trust on a secure session; deny forged direct announces the connected shortcut

Residual gap after the rotation-heal containment (#1401): "verified
direct" announces prove the signature but not directness — TTL is
unsigned — so a malicious connected peer can replay a victim's fresh
announce with its TTL restored. When the victim has no live link, the
replayer's link rebinds to the victim's ID and reads as "connected",
and MessageRouter's connected fast-path then trusts it outright: every
DM stalls on a Noise handshake the replayer can never complete and is
silently lost while showing "sent".

Router-level trust gate (no wire change):
- Transport gains canDeliverSecurely(to:) — BLE answers with an
  established Noise session; Nostr keeps its prompt-delivery predicate;
  the protocol default forwards to canDeliverPromptly for transports
  without a forgeable link layer.
- MessageRouter.sendPrivate only trusts a connected link outright when
  it can deliver securely; otherwise it still sends (kicking the
  handshake on a genuine link) but retains a copy and hands a sealed
  copy to couriers, like the reachable path. flushOutbox gets the same
  gate so a flush over an insecure link resends instead of dropping the
  retained copy.

Presence hardening (defense in depth): a "direct" announce arriving on
a link already bound to a different peer no longer shortcuts the
claimed peer into "connected" — only real link state does. Genuine
first-contact and direct announces are unaffected; only the ambiguous
heal path loses the forgeable shortcut.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Harden the insecure-link outbox bound; document the second-replay presence gap and the courier metadata tradeoff

Review follow-ups on the canDeliverSecurely gate:

- flushOutbox no longer counts connected-but-insecure flushes toward the
  maxSendAttempts drop: the message was actually transmitted over a live
  link, so a peer whose Noise handshake stalls across reconnect flapping
  must not burn through the cap and lose the store-and-forward copy the
  gate exists to preserve. Retention stays bounded by the 24h outbox TTL
  and the per-peer FIFO cap; acks still clear it. Attempt-counting stays
  for reachable-only (heuristic) sends. Regression test: >8 connected-
  insecure flushes keep the retained copy, drop callback never fires.

- Document the known second-replay presence gap: linkBoundToOtherPeer
  reads the binding before rebindLinkAfterVerifiedDirectAnnounce steals
  the link, so once a first replay has rebound a link to an absent
  victim's ID, a second replay marks the victim connected. Presence
  display only — DMs stay on the retain+courier path via the router
  gate. Not closed at the announce layer because the post-rebind state
  is indistinguishable from a legitimate rotation/reconnect heal (a
  supported, field-verified flow). Covered by a two-announce test.

- Note the accepted courier-spray metadata tradeoff at the connected-
  insecure send: nearby verified peers receive a sealed copy (they learn
  a DM exists, never its content), cleared on ack.

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

* Promote a healed rotation to connected; normalize the secure-delivery probe; retain Codable properties in Periphery

Codex review follow-ups on 5017c232:

- P1: a legitimate rotation announce necessarily arrives on a link still
  bound to the OLD peer ID, so the linkBoundToOtherPeer denial stored the
  new identity as disconnected — and the rebind only fixed link state,
  never the registry. A healed rotation then read as disconnected until
  the peer happened to announce again. rebindLinkAfterVerifiedDirect-
  Announce now promotes the rebound identity to connected after the
  containment checks pass (registry markConnected + topology/snapshot/
  peer-list refresh; the .peerConnected UI event already fired from the
  announce path). This consciously moves the forged-presence residue
  from second-replay to first-successful-rebind — bounded by the rebind
  containment (never steals a live identity, one rebind per link per
  cooldown) and still display-only: DMs stay gated on canDeliverSecurely.
  Comments and the pinned tests updated; new regression test covers
  rotate-on-open-link -> rebind -> isPeerConnected(new) == true.

- P2: BLEService.canDeliverSecurely probed the Noise session with the
  peer ID as given, but sessions are keyed by the short wire ID — a send
  keyed by the full 64-hex Noise key (favorites resolution) misread an
  established session as insecure and needlessly retained + couriered
  every DM until ack. Normalize with toShort() like isPeerConnected.
  Test drives a real XX handshake and asserts both ID forms pass.

- Periphery: the PrekeyBundleStore.StoredBundle.noiseKey "assign-only"
  flake fired again and slipped past its baselined USR (read exists in
  loadFromDisk; the indexer intermittently misses it). Replace the
  baseline entry with retain_codable_properties: true — deterministic,
  and a truly-dead Codable field is a persisted-format change anyway.
  Local periphery scan --strict: no unused code detected.

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

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 16:46:33 +02:00
c8479c15e3 PTT hardening: per-peer burst keys + live-capture storage quota (#1420)
* PTT hardening: burst-ID collision hijack + live-capture quota bypass

Fix C — burst-ID collision hijack: inbound live-voice assemblies and the
finished-burst registry were keyed by the sender-chosen burst ID alone, so
an attacker who observed a public burst ID could race a START and capture
the real talker's frames (packets from the true sender were dropped as
"collisions"). Assemblies now key on (peerID, scope, burstID): a colliding
START from another peer opens its own capped assembly and can never divert
the victim's frames; finalized-note absorption matches on the same triple,
preserving the sender/scope binding. Live capture files also gain the peer
ID in their name so colliding bursts land on distinct paths (still rejected
by burstID(fromVoiceFileName:), so live names remain unabsorbable).

Fix B — live-burst files bypassed the incoming-media quota: progressive
voice_live_*.aac captures were written via raw FileHandle without ever
touching BLEIncomingFileStore.enforceQuota, growing disk unbounded outside
the 100 MB LRU accounting. The coordinator now takes an injected
BLEIncomingFileStore, reserves pttMaxBurstBytes before opening a capture,
and sweeps orphaned voice_live_* partials from previous sessions at
startup. enforceQuota gains an `excluding:` set so in-flight partials are
never LRU-evicted mid-stream; existing callers are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Review fixes: pattern-guard live captures in quota, scope in capture names

Quota-eviction gap: BLEFileTransferHandler enforces the quota for every
finalized arrival via enforceQuota(reservingBytes:) with no exclusion set,
so a file landing at quota could LRU-evict an in-flight live capture —
unlinking the inode under the coordinator's open FileHandle and leaving a
dead bubble. Protection is now layer-independent: enforceQuota itself skips
voice_live_* names (they still count toward usage), and the excluding:
parameter is gone since the pattern guard covers its only caller. Orphaned
partials are still reclaimed by the coordinator's startup sweep, which the
quota deliberately never touches.

Same-peer cross-scope truncation: makeIncomingURL omitted the scope, so one
peer running a DM burst and a public burst with the same burst ID mapped
both assemblies onto one path — and FileManager.createFile truncates, so
each START corrupted the other capture. Names now mirror the assembly key:
voice_live_<burstHex>_<peerID>_<dm|mesh>.aac. The sweep and quota guard
match on the voice_live_ prefix and burstID(fromVoiceFileName:) still
rejects every live name, so live captures remain unabsorbable;
sameBurstIDCoexistsAcrossScopes now asserts both files survive with intact
contents.

Nits: the coordinator's file operations route through the store's
injectable FileManager instead of FileManager.default, and the
TestEnvironment.isRunningTests branch in init is replaced by a
sweepsOnInit parameter (tests sharing the real application-support
directory pass false for hermeticity).

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

* Promote kept live captures off the voice_live_ name at finalize

Codex P1 follow-up: when a burst finalizes with frames but its .m4a note
never arrives, the voice_live_*.aac capture stays behind as the bubble's
replayable audio — yet the startup sweep deletes every voice_live_* file
and the quota guard skips them forever, so a kept fallback was both
quota-immune for the rest of the session and doomed at the next launch.

finalize now promotes the capture to a plain voice_ name (same suffix) and
republishes the row pointing at it; the finished-burst registry tracks the
promoted URL so a late note still absorbs and deletes it. voice_live_ is
thereby scoped to genuinely in-flight captures: the sweep never touches a
referenced fallback, and promoted files age out of the quota like any
finalized media. If the move fails the live name is kept — exactly the
pre-promotion behavior.

Premise correction, verified while tracing the reference model: chat rows
are NOT persisted across restarts (ConversationStore is in-memory; the
gossip archive replays MessageType.message packets only), so today's sweep
never orphaned a persisted row — the fix removes the in-session quota
immunity and makes the invariant hold if row persistence ever lands. Crash
case stays deliberate and documented: a mid-burst partial from a dead
process is swept because no surviving row can reference it.

Tests: finalize-as-fallback promotes the file, repoints the row, and
survives a later coordinator's startup sweep; promoted fallbacks are
LRU-evictable by the quota; the sweep still removes true orphans while
sparing promoted names; existing burst tests updated for the promoted
paths.

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

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 16:45:45 +02:00
84d315e62d Bridge dedup: content-derived stable mesh message ID (#1419)
* Bridge dedup keys on a content-derived stable mesh message ID

Public mesh messages carry no message ID on the BLE wire, so every
non-origin device minted a fresh UUID and the bridge's m-tag dedup only
matched on the origin device: duplicate bridged rows, misattributed
"across the bridge" counts, redundant downlink rebroadcasts, and an
m-tag spoof vector (an attacker could claim a victim's message ID).

Every device now derives the same stable ID from the signed wire fields
(sender ID + ms timestamp + trimmed content, SHA256/32 hex) via the new
MeshMessageIdentity — zero BLE wire change. The bridge event's m tag
carries the origin coordinates ["m", senderIDHex, timestampMs] and
receivers recompute the key from those plus the event's own content
instead of trusting a claimed ID; old-format/absent tags fall back to
the event ID as before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Fix mixed-version message loss: m tag leads with the derived stable ID

The previous layout (["m", senderIDHex, timestampMs]) broke v1.7.0
receivers: their parser takes m[1] unconditionally as the timeline
dedup key whenever the tag has >= 2 elements, so every bridged message
from a new-version sender keyed on the CONSTANT sender hex and
inject-dedup dropped all but the first. The tag is now
["m", <derived stable ID>, senderIDHex, timestampMs]: old parsers get a
per-message-unique m[1] (exactly today's semantics), while the new
parser recomputes the ID from elements 2-3 plus the event's own content
and never trusts element 1, keeping the recompute-don't-trust property.

Also:
- Soften the overstated security claim in MeshMessageIdentity and the
  BridgeService classify comment: forging a chosen ID onto different
  content is infeasible, but all three hash inputs are cleartext on the
  radio, so identical-content front-running by a radio-local attacker
  remains possible (no worse than the unbridged mesh).
- Fix the stale archivedEchoKeys rationale: re-synced copies of others'
  messages now carry the derived stable ID (insert-by-ID catches them);
  the content key remains for echo--prefixed archive rows + self echoes.
- Tests: old-parser semantics on the new tag (m[1] per-message-unique
  and equal to the derived ID) and a forged-m[1] event that cannot
  pre-poison a genuine message's dedup slot.

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

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 16:44:47 +02:00
dd6b624cae Quality pass on the 1.7.0 batch: fix confirmed bugs, bump to 1.7.1 (#1418)
* Quality pass on the 1.7.0 batch: fix confirmed bugs, bump to 1.7.1

Post-merge review of PRs #1400–#1417 (push-to-talk, mesh bridging, DM
store-and-forward, empty-mesh liveliness, geo-notes). Fixes the confirmed,
well-scoped findings; deeper architectural/security items are tracked
separately.

- PTT hot-mic leak: releasing the mic during VoiceCaptureSession.start()'s
  150ms retry pause left the mic live and streaming for up to 120s, because
  cancel() no-op'd once `completed` was set. Bail after the sleep if the hold
  was released, and make cancel() always tear down a late-started capture.
- Bridge courier depositDrop reported success and burned the dedup slot before
  the drop was actually published (evicted/compose-fail = lying 📦 "carried"
  with no retry). Only consume publishedDropKeys on durable accept; add
  BoundedIDSet.remove() to release evicted/failed slots (uses the dead dedupKey).
- Blocked senders resurfaced via archived "heard here earlier" echoes, the one
  path that bypassed the live block filter — filter at seed time.
- A late optimistic .sent clobbered the router's .carried state; extend
  ConversationStore.shouldSkipStatusUpdate to a full precedence guard
  (sending < sent < carried < delivered < read).
- Read receipts were permanently burned when the router dropped them (marked
  sent then dropped). sendReadReceipt/routeReadReceipt now return Bool; only
  record as sent on a successful route, else retry on the next read scan.
- MessageRouter.cleanupExpiredMessages() had no production caller, so DMs to a
  peer that never reconnects sat on .sending until relaunch — run it in the
  120s bridge sweep.
- Sightings tally now rolls over at midnight while idle; wave notification
  action localized across all 29 locales; bridged anon#tag uses suffix(4) like
  everything else; makeThrowawayIdentity delegates to NostrIdentity.generate();
  .swiftlint.yml excludes .claude worktrees.
- Add regression tests: carried→sent no-downgrade, carried→delivered upgrade,
  evicted pending drop stays retryable.
- Bump MARKETING_VERSION to 1.7.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Fix CI: adjust delivery-status benchmark and drop now-dead addSystemMessage

The stricter no-downgrade guard (delivered/carried never regress to sent) broke
two things the earlier commit didn't catch locally (perf tests are skipped in
the default run, and Periphery runs only in CI):

- PerformanceBaselineTests delivery benchmarks alternated sent <-> delivered
  assuming both directions apply; the delivered -> sent half is now correctly
  skipped, so the pass measured 0 updates. Alternate two delivered timestamps
  instead — every update is real, no downgrade.
- Routing the geoDM "not in a location channel" error into the thread removed
  the only caller of ChatPrivateConversationContext.addSystemMessage, leaving
  it (and its mock) dead per Periphery. Drop the protocol requirement, the mock
  impl, and the now-vacuous systemMessages.isEmpty assertions (the invariant is
  compile-time enforced: the context can no longer emit a public system line).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Address review findings: read-receipt dedup, carried-vs-sending, block-time echo purge

- Read receipts: claim the receipt in sentReadReceipts synchronously
  before spawning the routing task (chat open runs two read scans in one
  MainActor stretch, so the async insert let every unread message route
  twice), and release the claim when the route fails so the
  retry-on-failed-route behavior is preserved.

- Delivery status: extend the no-downgrade guard so the `.sending`
  stamp a pre-handshake resend emits can no longer clobber
  carried/delivered/read (the 📦 indicator survived `.sent` but not
  `.sending`).

- Archived echoes: blocking a peer now purges their carried public
  messages from the gossip archive at block time (UnifiedPeerService
  and /block), while the fingerprint-to-peerID mapping is still known —
  the seed-time filter can't resolve offline non-favorite strangers and
  stays only as defense-in-depth. New Transport hook (default no-op) +
  GossipSyncManager.removePublicMessages with immediate persist.

- Bridge courier: an envelope that can't encode within the drop size
  caps fails identically on every attempt; consume the dedup slot so
  the 120s retry sweep stops re-running Noise sealing on it.

- MeshSightingsTracker: cache the day-key DateFormatter instead of
  building one per call.

Tests: double-markAsRead dedup + failed-route retry, carried→sending
no-downgrade matrix, block-time purge (manager + service wiring),
oversize-drop slot consumption.

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

* Also skip the sent→sending downgrade in the delivery-status guard

Codex review follow-up: sendPrivateMessage without an established Noise
session emits `.sending` asynchronously, so it can land after the
message already reached `.sent` and visibly walk "Sent" back to
"Sending...". Treat `.sending` as weaker than `.sent` too — the status
was already truthful. `.failed` → `.sending` stays allowed so a retry
after a real failure remains visible.

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

* Retain Codable properties in Periphery scan (same fix as #1421)

The noiseKey assign-only false positive fired persistently on this branch
(twice, including a rerun) despite the baselined USR. Byte-identical to the
fix on fix/announce-replay-link-steal so the branches merge cleanly in
either order.

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

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 16:44:02 +02:00
235 changed files with 38679 additions and 2615 deletions
+20 -5
View File
@@ -131,10 +131,10 @@ jobs:
echo "No coverage data found; skipping summary."
fi
# SPM tests above only compile the macOS slice; this job covers the
# iOS-conditional code paths (UIKit, CoreBluetooth restoration, etc.).
# SPM tests do not link the shipping app targets. This job covers the
# iOS-conditional paths and both universal Release link configurations.
ios-build:
name: Build iOS app (simulator)
name: Build Release apps (universal)
runs-on: macos-latest
timeout-minutes: 15
@@ -143,14 +143,29 @@ jobs:
uses: actions/checkout@v5
- name: Build iOS (simulator, no signing)
# arm64 only: the vendored arti.xcframework has no x86_64 simulator slice.
# Build both simulator architectures so CI validates every vendored
# Arti simulator slice and the configuration that ships.
run: |
set -o pipefail
xcodebuild -project bitchat.xcodeproj \
-scheme "bitchat (iOS)" \
-configuration Release \
-sdk iphonesimulator \
-destination 'generic/platform=iOS Simulator' \
ARCHS=arm64 \
ARCHS='arm64 x86_64' \
ONLY_ACTIVE_ARCH=NO \
CODE_SIGNING_ALLOWED=NO \
build
- name: Build macOS (universal, no signing)
run: |
set -o pipefail
xcodebuild -project bitchat.xcodeproj \
-scheme "bitchat (macOS)" \
-configuration Release \
-destination 'generic/platform=macOS' \
ARCHS='arm64 x86_64' \
ONLY_ACTIVE_ARCH=NO \
CODE_SIGNING_ALLOWED=NO \
build
+1 -1
View File
@@ -1 +1 @@
{"v1":{"usrs":["param-buf-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-dataDir-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","param-len-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-socksPort-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","s:13BitFoundation16PeerCapabilitiesV8wifiBulkACvpZ","s:13BitFoundation18KeychainReadResultO18isRecoverableErrorSbvp","s:13BitFoundation23KeychainManagerProtocolP11secureClearyySSzF","s:18bitchatTests_macOS12MockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC11resetCountsyyF","s:18bitchatTests_macOS20TrackingMockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC25totalSecureClearCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC26secureClearStringCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC27_secureClearStringCallCount06_AB6D1M24FD239F2969C82F4108818260LLSivp","s:18bitchatTests_macOS24FailingCacheSaveKeychain33_22380C7A11A569A0B83FA83F34C498A7LLC11secureClearyySSzF","s:18bitchatTests_macOS24MockGeohashPresenceTimer33_483587EFB96650EE130EFB09BBA2A1AALLC7handleryycvp","s:3Tor0A7ManagerC21goDormantOnBackgroundyyF","s:7bitchat10AppRuntimeC24handleScreenshotCaptured33_C8B369AD8BC1D9963A50CEDA77A4332ALLyyF","s:7bitchat10AppRuntimeC33handleDidBecomeActiveNotificationyyF","s:7bitchat10BLEServiceC18logBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LLyySSF","s:7bitchat10BLEServiceC20centralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC22captureBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LL7contextySS_tF","s:7bitchat10BLEServiceC23peripheralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC29scheduleBluetoothStatusSample33_69191C53E68500C17D98DBCF2BDA7100LL5after7contextySd_SStF","s:7bitchat10QRScanViewV8isActiveSbvp","s:7bitchat15BLEPeerRegistryV5countSivp","s:7bitchat15KeychainManagerC11secureClearyySSzF","s:7bitchat15PaymentChipViewV7openURL33_10AC50641B1EBCD52E5092A2E521D236LL7SwiftUI13OpenURLActionVvp","s:7bitchat15TransportConfigO29uiBatchDispatchStaggerSecondsSdvpZ","s:7bitchat15TransportConfigO35uiShareExtensionDismissDelaySecondsSdvpZ","s:7bitchat15TransportConfigO38bleBackgroundPendingConnectSlotReserveSivpZ","s:7bitchat17GossipSyncManagerC10persistNowyyF","s:7bitchat17NostrRelayManagerC15InboundEventKey33_E4160FE8A9A2C9D6308EAAD5A8B5CB07LLV7eventIDSSvp","s:7bitchat17PrekeyBundleStoreC12StoredBundleV8noiseKey10Foundation4DataVvp","s:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}}
{"v1":{"usrs":["param-buf-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-dataDir-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","param-len-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-socksPort-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","s:13BitFoundation16PeerCapabilitiesV8wifiBulkACvpZ","s:13BitFoundation18KeychainReadResultO18isRecoverableErrorSbvp","s:13BitFoundation23KeychainManagerProtocolP11secureClearyySSzF","s:18bitchatTests_macOS12MockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC11resetCountsyyF","s:18bitchatTests_macOS20TrackingMockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC25totalSecureClearCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC26secureClearStringCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC27_secureClearStringCallCount06_AB6D1M24FD239F2969C82F4108818260LLSivp","s:18bitchatTests_macOS24FailingCacheSaveKeychain33_22380C7A11A569A0B83FA83F34C498A7LLC11secureClearyySSzF","s:18bitchatTests_macOS24MockGeohashPresenceTimer33_483587EFB96650EE130EFB09BBA2A1AALLC7handleryycvp","s:3Tor0A7ManagerC21goDormantOnBackgroundyyF","s:7bitchat10AppRuntimeC24handleScreenshotCaptured33_C8B369AD8BC1D9963A50CEDA77A4332ALLyyF","s:7bitchat10AppRuntimeC33handleDidBecomeActiveNotificationyyF","s:7bitchat10BLEServiceC18logBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LLyySSF","s:7bitchat10BLEServiceC20centralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC22captureBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LL7contextySS_tF","s:7bitchat10BLEServiceC23peripheralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC29scheduleBluetoothStatusSample33_69191C53E68500C17D98DBCF2BDA7100LL5after7contextySd_SStF","s:7bitchat10QRScanViewV8isActiveSbvp","s:7bitchat15BLEPeerRegistryV5countSivp","s:7bitchat15KeychainManagerC11secureClearyySSzF","s:7bitchat15PaymentChipViewV7openURL33_10AC50641B1EBCD52E5092A2E521D236LL7SwiftUI13OpenURLActionVvp","s:7bitchat15TransportConfigO29uiBatchDispatchStaggerSecondsSdvpZ","s:7bitchat15TransportConfigO35uiShareExtensionDismissDelaySecondsSdvpZ","s:7bitchat15TransportConfigO38bleBackgroundPendingConnectSlotReserveSivpZ","s:7bitchat17GossipSyncManagerC10persistNowyyF","s:7bitchat17NostrRelayManagerC15InboundEventKey33_E4160FE8A9A2C9D6308EAAD5A8B5CB07LLV7eventIDSSvp","s:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}}
+7
View File
@@ -10,5 +10,12 @@ project: bitchat.xcodeproj
schemes:
- bitchat (macOS)
retain_swift_ui_previews: true
# Codable properties are (de)serialized via synthesized conformances the
# indexer doesn't always attribute reads to: PrekeyBundleStore.StoredBundle
# .noiseKey flaked CI as "assign-only" even while read in loadFromDisk —
# and slipped past its baselined USR. Retaining Codable properties outright
# is deterministic; a truly-dead Codable field is a persisted-format change
# anyway, never a safe mechanical delete.
retain_codable_properties: true
relative_results: true
baseline: .periphery.baseline.json
+1
View File
@@ -2,6 +2,7 @@
# (CI checkouts are fresh, so this only matters in a working tree).
excluded:
- .build
- .claude
- .swiftpm
- .DerivedData
- DerivedData
+1 -1
View File
@@ -1,4 +1,4 @@
MARKETING_VERSION = 1.7.0
MARKETING_VERSION = 1.7.1
CURRENT_PROJECT_VERSION = 1
IPHONEOS_DEPLOYMENT_TARGET = 16.0
+107 -117
View File
@@ -1,165 +1,155 @@
# bitchat Privacy Policy
*Last updated: June 2026*
*Last updated: July 2026*
## Our Commitment
bitchat is designed with privacy as its foundation. We believe private communication is a fundamental human right. This policy explains how bitchat protects your privacy.
bitchat is designed for private, account-free communication. This policy describes what the app keeps on your device, what it sends when you use mesh or optional internet features, and how long local data can remain.
## Summary
- **No personal data collection** - We don't collect names, emails, or phone numbers
- **No accounts or company servers** - Mesh chat works peer-to-peer; optional Nostr features use public or user-selected relays
- **No tracking** - We have no analytics, telemetry, or user tracking
- **Open source** - You can verify these claims by reading our code
- **No project-operated accounts or messaging servers** — Bluetooth mesh is peer-to-peer; optional internet features use public or user-selected Nostr relays.
- **No analytics, advertising, telemetry, or tracking** — the app does not contain an analytics or advertising SDK.
- **No sale of data** — the project does not sell user data or build advertising profiles.
- **Open source** — the storage, networking, and cryptography described here can be inspected in the source code.
## What Information bitchat Stores
## What bitchat Stores on Your Device
### On Your Device Only
1. **Identity and cryptographic keys**
- Noise, signing, group, prekey, and optional Nostr identity material is generated locally.
- Secret keys are stored in the system keychain as device-only items. Public keys are shared when required for messaging, verification, groups, or Nostr events.
- Keys remain until they are rotated, removed by the relevant feature, or erased with panic wipe. Because operating-system keychains can outlive an uninstall, bitchat records a non-secret install marker and deletes surviving app keys before use after a later reinstall.
1. **Identity Keys**
- Cryptographic private keys generated on first launch or when optional Nostr identities are created
- Stored locally in your device's secure storage
- Allows you to maintain "favorite" relationships across app restarts
- Private keys never leave your device; public keys are shared when needed for messaging
2. **Nickname, preferences, and relationships**
- Your nickname, settings, favorites, petnames, read-receipt identifiers, and bounded operational metadata are stored locally.
- The share extension briefly places content you choose to share in the app-group preferences so the main app can import it.
2. **Nickname**
- The display name you choose (or auto-generated)
- Stored only on your device
- Shared with peers you communicate with
3. **Private group state**
- Group names, rosters, creator identity, and key epoch are stored as protected files in Application Support.
- Current group keys are stored in the keychain. Group state remains until you leave or remove the group, panic-wipe the app, or remove the app.
3. **Message History** (if enabled)
- When room owners enable retention, messages are saved locally
- Stored encrypted on your device
- You can delete this at any time
4. **Queued and carried private messages**
- An outgoing private message that has not been acknowledged may remain for up to 24 hours in a bounded, encrypted outbox. The outbox is sealed with ChaCha20-Poly1305 and its key is stored in the keychain.
- A device acting as a courier may store a bounded opaque end-to-end encrypted envelope for another user for up to 24 hours. The courier cannot read its message content.
- A panic wipe deletes both stores.
4. **Favorite Peers**
- Public keys of peers you mark as favorites
- Stored only on your device
- Allows you to recognize these peers in future sessions
5. **Recent public mesh messages and notices**
- Signed public mesh messages may be kept in a protected local gossip archive for up to 15 minutes so they can cross mesh partitions and survive a short relaunch.
- Public bulletin-board posts and deletion tombstones persist until the post's author-selected expiry, at most seven days. Both stores are bounded and panic-wipeable.
- These items are public to the mesh or board where they are posted; they are not confidential messages.
5. **Optional Location Channel State**
- Your selected geohash channel, bookmarked geohashes, teleport flags, and bookmark display names
- Stored locally on your device so the location-channel UI can restore your choices
- Per-geohash Nostr identities are derived locally from a device seed stored in secure storage
- Exact latitude and longitude are not persisted by bitchat
6. **Media attachments**
- Voice notes and images you send or receive can be stored under Application Support so they remain playable while referenced by the app.
- Incoming media is subject to a 100 MB quota with oldest-file eviction. Media is deleted by panic wipe or app removal; some outgoing media can otherwise remain on disk.
### Temporary Session Data
7. **Optional location-channel state**
- Your selected geohash channel, bookmarks, teleport flags, and bookmark display names are stored locally so the UI can restore them.
- Per-geohash Nostr identities are derived locally from a device seed stored in the keychain.
- bitchat does not persist exact latitude or longitude and does not include exact coordinates in mesh or Nostr messages.
During each session, bitchat temporarily maintains:
- Active peer connections (forgotten when app closes)
- Routing information for message delivery
- Cached messages for offline peers (12 hours max)
- Your current location while optional location channels are enabled, used locally to compute geohash channels and friendly place names
## Temporary Session Data
## What Information is Shared
While running, bitchat maintains active connections, routing state, deduplication state, and bounded in-memory conversation timelines. Closing the app clears the in-memory timelines and active connections, but it does not erase the persistent stores listed above.
### With Other bitchat Users
## What Is Shared
When you use bitchat, nearby peers can see:
- Your chosen nickname
- Your ephemeral public key (changes each session)
- Messages you send to public rooms or directly to them
- Your approximate Bluetooth signal strength (for connection quality)
### With Nearby Mesh Users
### With Room Members
Depending on the feature you use, nearby peers can receive:
When you join a password-protected room:
- Your messages are visible to others with the password
- Your nickname appears in the member list
- Room owners can see you've joined
- Your chosen nickname and public Noise/signing identity material.
- Announce metadata such as supported capability flags and a bounded list of short direct-neighbor identifiers. When the bridge is enabled, an announce can also include its coarse rendezvous geohash cell.
- Public mesh messages, public notices, and group-control packets you intentionally send.
- Private ciphertext addressed to them, or opaque courier ciphertext they agree to carry.
- Radio metadata available to the receiver, such as approximate Bluetooth signal strength.
### With Nostr Relays (Optional Features)
Noise identity keys can persist across sessions; do not treat them as anonymous identifiers. Panic wipe rotates local identity state.
If you enable Nostr-backed features:
- Private fallback messages to mutual favorites are sent as encrypted NIP-17 gift wraps. Relays can see event metadata, but not message content.
- Public location-channel messages, location notes, and presence are scoped with geohash tags. Relays and other participants can see the geohash tag, event kind, timestamp, and public key used for that geohash.
- Exact GPS coordinates are not included in Nostr events by bitchat. The geohash precision you choose can still reveal an approximate area, from region-level to building-level.
- Automatic presence heartbeats are limited to low-precision geohashes (region, province, and city). More precise geohash posts happen only when you use those channels or location notes.
### With Private Group Members
## What We DON'T Do
Private group members receive the group's name, roster, key epoch, and encrypted group traffic needed to participate. Group messages are confidential to devices holding the current group key, subject to the security of those devices and members.
bitchat **never**:
- Collects personal information
- Sells or shares your exact GPS location
- Stores data on servers we operate
- Sells your data to advertisers or data brokers
- Uses analytics or telemetry
- Creates user profiles
- Requires registration
### With Nostr Relays and Internet Gateways
## Encryption
Internet-backed features are optional. When enabled or used:
All private messages use end-to-end encryption:
- **X25519** for key exchange
- **AES-256-GCM** for message encryption
- **Ed25519** for digital signatures
- **Argon2id** for password-protected rooms
- Private fallback messages use BitChat's app-specific encrypted envelopes. This format is not NIP-17, NIP-44, or NIP-59 compatible. Relays can observe the recipient public-key tag, event timing and size, and network metadata, but not the message plaintext or stable sender identity.
- Public location-channel messages, notes, notices, and presence include a geohash tag, event kind, timestamp, and a public key. A geohash reveals an approximate area; finer precision reveals a smaller area.
- The optional mesh bridge publishes bridge-enabled public mesh messages and presence to a neighborhood rendezvous cell. Those messages are public to participants and relays for that cell. A per-message “nearby only” choice prevents that message from crossing the bridge.
- Bridge courier drops contain opaque end-to-end encrypted envelopes and a rotating recipient tag. Relays still observe timing and network metadata.
- A device with gateway features enabled may relay signed bridge/location traffic or opaque courier envelopes for nearby mesh devices.
## Your Rights
Nostr relays are operated by third parties. Their retention, logging, availability, and privacy practices are outside the project's control. Public events and encrypted events may remain on relays according to each relay's policy.
You have complete control:
- **Delete Local State**: Triple-tap the logo to instantly wipe local keys, sessions, caches, and preferences
- **Leave Anytime**: Close the app and local presence stops; relay-backed presence ages out
- **No Account**: No account record exists for you to delete from us
- **Portability**: Your local state stays on your device unless you send messages, use optional relay-backed features, or export it
## Location and Apple Services
## Bluetooth & Permissions
Location permission is optional and requested as when-in-use access. It is used to compute geohash channels, bridge rendezvous cells, and nearby place labels.
bitchat requires Bluetooth permission to function:
- Used only for peer-to-peer communication
- Bluetooth is not used for tracking
- You can revoke this permission at any time in system settings
- Exact coordinates are not included in bitchat mesh or Nostr payloads and are not persisted by bitchat.
- A selected geohash can still reveal an approximate area to peers and relays.
- When bitchat asks the operating system for a friendly place name, Apple's `CLGeocoder` service may process the location under Apple's privacy terms.
- Revoking location permission stops live location sampling. Saved bookmarks remain until you remove them, panic-wipe the app, or remove the app.
## Location Permission
## Microphone, Camera, and Media Permissions
Location permission is optional and is used only for location channels:
- Used to compute local geohash channels and display names
- Requested as when-in-use permission
- Exact coordinates are not shared in messages or stored by bitchat
- Selected and bookmarked geohashes may persist locally until you remove them, use panic wipe, or delete the app
- You can revoke this permission at any time in system settings
- Microphone access is used only while you record a voice note or actively hold live push-to-talk. The resulting audio is sent to the mesh conversation you selected; public-conversation audio is public to that mesh, while private-conversation audio uses the private transport protections described below.
- Voice-note and live-audio files can remain in Application Support under the media retention rules above.
- Camera access is used to scan peer-verification QR codes. Photo-library access is used when you choose an image to send.
- These permissions can be revoked in system settings. bitchat does not record microphone or camera input while the related capture UI is inactive.
## Cryptography
Private and public features use different protections:
- Mesh private sessions use Noise XX with X25519, ChaCha20-Poly1305, and SHA-256.
- Private group messages use ChaCha20-Poly1305; group state and relevant mesh packets use Ed25519 signatures.
- Nostr events use secp256k1 Schnorr signatures. BitChat private envelopes use secp256k1 key agreement, HKDF-SHA256 with a BitChat-specific domain separator, and XChaCha20-Poly1305. The envelope format is proprietary, only interoperates with BitChat clients, and does not provide forward secrecy against later compromise of the recipient's static Nostr private key.
- The persistent private-message outbox uses ChaCha20-Poly1305 with a key held in the keychain. Some other protected local identity state uses AES-GCM.
- Public mesh, bridge, geohash, and board content is signed or authenticated as appropriate but is intentionally not confidential.
No cryptographic system can protect content after a recipient reads, copies, screenshots, or exports it.
## Data Retention Summary
- **In-memory chat timelines and active connections:** until the app closes or state is cleared.
- **Queued outgoing private messages:** until acknowledged, dropped by bounded policy, or 24 hours, whichever comes first.
- **Opaque courier envelopes:** until handed off, evicted by bounded policy, or 24 hours, whichever comes first.
- **Recent public mesh gossip:** up to 15 minutes.
- **Public board posts and tombstones:** until expiry, at most seven days.
- **Groups, favorites, preferences, identity keys, bookmarks, and media:** until removed by the feature, panic wipe, quota eviction where applicable, or app removal.
- **Nostr data:** according to the policies of the relays that receive it.
## Your Controls
- **Panic wipe:** Triple-tap the logo to synchronously cancel in-flight media work and clear local keys, sessions, preferences, groups, queues, carried mail, public archives, board data, and media managed by the app.
- **Feature controls:** Location channels, mesh bridge, internet gateway, and related internet behaviors can be disabled in the app. Some already-published relay data cannot be recalled.
- **System permissions:** Bluetooth, location, microphone, camera, and photo-library access can be revoked in system settings.
- **No account:** The project operates no account record for you to request or export.
## What the Project Does Not Do
bitchat does not:
- Operate an account database or project-owned messaging backend.
- Include advertising, analytics, or tracking SDKs.
- Sell user data or create advertising profiles.
- Include exact GPS coordinates in bitchat mesh or Nostr message payloads.
## Children's Privacy
bitchat does not knowingly collect information from children. The app has no age verification because it collects no personal information from anyone.
## Data Retention
- **Messages**: Deleted from memory when app closes (unless room retention is enabled)
- **Identity Key**: Persists until you delete the app
- **Favorites**: Persist until you remove them or delete the app
- **Location channel choices**: Selected/bookmarked geohashes persist locally until removed, panic-wiped, or the app is deleted
- **Nostr relay data**: Public geohash events and encrypted gift wraps may be retained by relays according to each relay's policy
- **Everything Else**: Exists only during active sessions
## Security Measures
- All communication is encrypted
- No accounts or company servers
- Optional Nostr relays receive only the events needed for Nostr-backed private fallback or public location channels
- Open source code for public audit
- Regular security updates
- Cryptographic signatures prevent tampering
The project does not knowingly operate a service that collects children's personal data. The app has no account registration or age-verification system. Users and guardians should understand that public mesh, board, bridge, and location-channel posts are visible to other participants and may be relayed.
## Changes to This Policy
If we update this policy:
- The "Last updated" date will change
- The updated policy will be included in the app
- No retroactive changes can make us collect data already held only in your app
Material behavior changes will be reflected in this document and its “Last updated” date. Updating this policy cannot retroactively retrieve data that remained only on a user's device.
## Contact
bitchat is an open source project. For privacy questions:
- View our source code: [https://github.com/permissionlesstech/bitchat/tree/main](https://github.com/permissionlesstech/bitchat/tree/main)
- Open an issue on GitHub
- Join the discussion in public rooms
## Philosophy
Privacy isn't just a feature—it's the entire point. bitchat proves that modern communication doesn't require surrendering your privacy. No accounts, no company servers, no analytics. Just people talking freely.
- View the source: [https://github.com/permissionlesstech/bitchat](https://github.com/permissionlesstech/bitchat)
- Open an issue on GitHub.
---
*This policy is released into the public domain under The Unlicense, just like bitchat itself.*
*This policy is released into the public domain under The Unlicense, like the project itself.*
+5 -1
View File
@@ -63,7 +63,11 @@ let package = Package(
// Only the vector fixture: declaring the whole "Noise"
// directory would claim its .swift test files as resources
// and silently drop them from compilation.
.process("Noise/NoiseTestVectors.json")
.process("Noise/NoiseTestVectors.json"),
// Frozen output produced by the released 733098bb private-DM
// implementation; proves receive compatibility independently
// of the refactored legacy generator.
.process("Nostr/Fixtures")
]
)
]
+44 -3
View File
@@ -19,7 +19,7 @@ This project is released into the public domain. See the [LICENSE](LICENSE) file
- **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers
- **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, NIP-17 for Nostr
- **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, BitChat private envelopes for Nostr fallback
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
- **Universal App**: Native support for iOS and macOS
- **Emergency Wipe**: Triple-tap to instantly clear all data
@@ -44,9 +44,50 @@ BitChat uses a **hybrid messaging architecture** with two complementary transpor
- **Global Reach**: Connect with users worldwide via internet relays
- **Location Channels**: Geographic chat rooms using geohash coordinates
- **290+ Relay Network**: Distributed across the globe for reliability
- **NIP-17 Encryption**: Gift-wrapped private messages for internet privacy
- **BitChat Private Envelopes**: App-specific encrypted private messages over Nostr relays
- **Ephemeral Keys**: Fresh cryptographic identity per geohash area
BitChat's private-envelope format is proprietary and is **not** NIP-17,
NIP-44, or NIP-59 compatible. It uses Nostr as a relay transport but only
interoperates with BitChat clients. New envelopes use provisional,
BitChat-specific public kind 1402 (not a formally reserved Nostr kind),
encrypted inner kinds 1403/1404, and the `bitchat-pm-v1:` content prefix.
For mixed-version delivery, clients publish both the primary kind-1402
envelope and a compatibility kind-1059 copy. There is no date-based cutoff:
kind 1059 must remain enabled until a coordinated iOS/Android release confirms
that supported older clients have migrated. Receivers subscribe to both kinds
and deduplicate the authenticated embedded BitChat payload.
Private-envelope migration compatibility:
| Sender | Receiver | Delivery path |
| --- | --- | --- |
| New iOS | New iOS | Kind 1402 is primary; the kind-1059 twin is deduplicated |
| New iOS | Released iOS | Compatibility kind 1059 |
| New iOS | Current Android | Compatibility kind 1059 |
| Released iOS | New iOS | Kind 1059 with the released empty inner-tag shape |
| Current Android | New iOS | Kind 1059 with exactly the authenticated recipient `p` tag |
New kind-1402 envelopes require an empty inner tag list. The Android recipient
tag exception is intentionally confined to legacy kind 1059 and accepts only
the exact addressed recipient. Mailbox subscriptions cover the 24-hour
delivery window plus Android's full 48-hour timestamp randomization and 15
minutes of clock skew. Recovery uses
one independent 500-event relay filter per wire kind so either format cannot
consume the other's result budget.
The two outbound migration copies are admitted to the relay queue as one
protected batch. Queue pressure evicts ephemeral traffic first, never one copy
of a private pair; if protected capacity is exhausted, the entire new pair is
rejected as a whole. User-message rejection becomes a visible failed delivery;
acknowledgements and favorite notifications retain the exact pair in a
process-wide 256-entry bounded retry queue. A sustained outage beyond that
bound evicts the oldest whole control pair with an explicit warning, never half
a pair.
If either socket write fails, the same queued pair remains pending and both
copies are replayed on the replacement connection. A terminal relay target is
pruned after bounded retries so one dead relay cannot wedge healthy delivery.
### Channel Types
#### `mesh #bluetooth`
@@ -80,7 +121,7 @@ Private messages use **intelligent transport selection**:
2. **Nostr Fallback** (when Bluetooth unavailable)
- Uses recipient's Nostr public key
- NIP-17 gift-wrapping for privacy
- BitChat's app-specific private-envelope encryption
- Routes through global relay network
3. **Smart Queuing** (when neither available)
+10 -4
View File
@@ -25,7 +25,7 @@ bitchat is a decentralized, peer-to-peer messaging application for secure, priva
Two transports implement a common `Transport` interface and are coordinated by a `MessageRouter`:
* **BLE mesh** — every device is simultaneously a GATT central and peripheral, relaying packets in a controlled flood. No infrastructure, pairing, or accounts.
* **Nostr** — private messages to mutual favorites travel as NIP-17 gift-wrapped events over public relays (over Tor where enabled), bridging separate meshes through the internet.
* **Nostr** — private messages to mutual favorites travel in BitChat's app-specific encrypted envelopes over public relays (over Tor where enabled), bridging separate meshes through the internet.
The router prefers a live mesh link, falls back to Nostr, and engages the courier system when neither can deliver promptly.
@@ -78,7 +78,9 @@ Courier envelopes are sealed to the recipient's *static* key with the one-way No
### 5.3 Nostr Path
Private messages to mutual favorites are wrapped per NIP-17/NIP-59: a rumor (kind 14) sealed (kind 13) and gift-wrapped (kind 1059) under a throwaway ephemeral key, so relays learn neither sender nor content.
Private messages to mutual favorites use BitChat's proprietary private-envelope protocol. An unsigned inner message (kind 1404) is encrypted and placed in a sender-signed seal (kind 1403); that seal is encrypted again inside a public envelope (kind 1402) signed by a one-time key. Kind 1402 is a provisional BitChat-specific assignment, not a formally reserved Nostr kind. Each encrypted content field is `bitchat-pm-v1:` followed by base64url of a 24-byte nonce, XChaCha20-Poly1305 ciphertext, and its 16-byte tag. Keys come from secp256k1 ECDH and HKDF-SHA256 with a BitChat-specific domain separator.
This format is **not NIP-17, NIP-44, or NIP-59 compatible** and interoperates only with BitChat clients. The outer `p` tag exposes the recipient's Nostr public key to relays; the stable sender identity and plaintext remain inside authenticated ciphertext. New-format seal and envelope timestamps are randomized up to 15 minutes into the past, while the actual message timestamp is encrypted. Legacy Android envelopes can carry public-layer timestamps randomized across the preceding 48 hours. The protocol does not provide forward secrecy: compromise of the recipient's static Nostr private key can expose stored envelopes.
## 6. Store and Forward
@@ -105,7 +107,11 @@ Public broadcast messages are cached (1000 packets) and reconciled between peers
### 6.4 Nostr Mailboxes
Gift-wrapped messages rest on Nostr relays; clients re-subscribe with a 24-hour lookback on reconnect, covering the both-devices-offline case for mutual favorites whenever either side touches the internet.
BitChat private envelopes rest on Nostr relays; clients re-subscribe across the 24-hour delivery window plus the full 48-hour timestamp randomization used by deployed Android clients and 15 minutes of clock skew (72 hours 15 minutes total). During the rolling format migration, clients subscribe to both the provisional BitChat-specific kind 1402 and historical kind 1059. Recovery places the kinds in separate filters within one REQ, each with an independent 500-event limit, so traffic in one format cannot starve the other. Each logical payload is published first in the primary kind-1402 format and then as a compatibility legacy copy for older iOS and current Android clients. There is no calendar cutoff: legacy publication and reception remain until a coordinated cross-platform release confirms supported clients have migrated. Bounded dedup of the authenticated embedded payload collapses the migration pair at receivers.
The relay send queue treats those two events as one protected batch. Capacity pressure removes ephemeral events before regular traffic and never evicts only one private-envelope copy. A queue containing only protected batches rejects a new pair as a whole: user messages surface a failed delivery, while acknowledgements and favorite notifications retain the exact pair in one process-wide 256-entry bounded-backoff retry queue shared by account and short-lived geohash transports. Sustained control-payload overflow evicts the oldest whole pair with an explicit warning rather than growing memory or splitting formats. After either socket write fails, the same pair remains pending and both copies are replayed on the replacement connection. Terminal relay targets are pruned after bounded connection retries; a batch that succeeded elsewhere retires normally, while an all-target failure returns to the transport's failure/retry policy. Receive-side dedup suppresses the replay if one copy had already reached the relay.
Released iOS legacy envelopes use an empty inner tag list. Current Android legacy envelopes use exactly one inner `p` tag naming the recipient. The kind-1059 decoder accepts only those two shapes after authenticating the outer recipient and sender-signed seal; alternate recipients, duplicate tags, and extra tags are rejected. Kind 1402 remains strict and permits no inner tags.
### 6.5 Delivery Metrics
@@ -127,7 +133,7 @@ Bare local counters (deposits, handovers, sprays, opens, outbox flushes and drop
* **Flooding abuse** is bounded by TTL clamps, deduplication, per-depositor quotas, connect-rate limits, and announce-rate limiting.
* **Replay** of public broadcasts is bounded by the 6-hour acceptance window plus deduplication; private payloads are protected by Noise nonces.
* **Metadata.** BLE proximity is inherently observable; ephemeral IDs and daily-rotating courier tags limit long-term correlation. Nostr traffic can ride Tor.
* **No forward secrecy for sealed mail** (§5.2) is the main cryptographic trade-off of the offline path.
* **No forward secrecy for sealed mail or Nostr envelopes** (§5.25.3) means compromise of a recipient's static key can expose retained ciphertext addressed to that key.
## 9. Future Work
+14 -1
View File
@@ -92,7 +92,8 @@
A6E32D232E762EAB0032EA8A /* Exceptions for "bitchatShareExtension" folder in "bitchatShareExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
ShareViewController.swift,
Info.plist,
bitchatShareExtension.entitlements,
);
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
};
@@ -258,9 +259,13 @@
buildConfigurationList = E4EA6DC648DF55FF84032EB5 /* Build configuration list for PBXNativeTarget "bitchatShareExtension" */;
buildPhases = (
0A08E70F08F55FD5BA8C7EF3 /* Sources */,
7E9B64F63F93443FB7BA12DF /* Resources */,
);
buildRules = (
);
fileSystemSynchronizedGroups = (
A6E32D212E762EAB0032EA8A /* bitchatShareExtension */,
);
name = bitchatShareExtension;
productName = bitchatShareExtension;
productReference = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */;
@@ -332,6 +337,7 @@
es,
ar,
de,
fa,
fr,
he,
id,
@@ -388,6 +394,13 @@
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */,
);
};
7E9B64F63F93443FB7BA12DF /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
+8
View File
@@ -21,6 +21,9 @@ final class AppChromeModel: ObservableObject {
private let chatViewModel: ChatViewModel
private var cancellables = Set<AnyCancellable>()
/// The composer owns capture state above ChatViewModel. ContentView
/// installs this hook so both panic entry points synchronously stop it.
private var prepareForPanic: (@MainActor () -> Void)?
/// Bulletin-board coordinator, created on first use of the board sheet.
private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService)
@@ -97,7 +100,12 @@ final class AppChromeModel: ObservableObject {
showScreenshotPrivacyWarning = true
}
func setPanicPreparation(_ preparation: (@MainActor () -> Void)?) {
prepareForPanic = preparation
}
func panicClearAllData() {
prepareForPanic?()
chatViewModel.panicClearAllData()
}
+33 -7
View File
@@ -107,12 +107,15 @@ final class AppRuntime: ObservableObject {
)
)
GeoRelayDirectory.shared.prefetchIfNeeded()
if chatViewModel.networkActivationAllowed {
GeoRelayDirectory.shared.prefetchIfNeeded()
}
bindRuntimeObservers()
NotificationDelegate.shared.runtime = self
}
func start() {
guard chatViewModel.networkActivationAllowed else { return }
guard !started else {
checkForSharedContent()
return
@@ -151,12 +154,14 @@ final class AppRuntime: ObservableObject {
}
func handleDidBecomeActiveNotification() {
guard chatViewModel.networkActivationAllowed else { return }
chatViewModel.handleDidBecomeActive()
checkForSharedContent()
}
#if os(macOS)
func handleMacDidBecomeActiveNotification() {
guard chatViewModel.networkActivationAllowed else { return }
record(.scenePhaseChanged(.active))
chatViewModel.handleDidBecomeActive()
checkForSharedContent()
@@ -175,6 +180,7 @@ final class AppRuntime: ObservableObject {
didEnterBackground = true
case .active:
guard chatViewModel.networkActivationAllowed else { return }
record(.scenePhaseChanged(.active))
chatViewModel.meshService.startServices()
TorManager.shared.setAppForeground(true)
@@ -222,6 +228,7 @@ final class AppRuntime: ObservableObject {
actionIdentifier: String = UNNotificationDefaultActionIdentifier,
userInfo: [AnyHashable: Any]
) {
guard chatViewModel.networkActivationAllowed else { return }
if actionIdentifier == NotificationService.waveActionID {
chatViewModel.sendMeshWave()
return
@@ -273,6 +280,8 @@ private extension AppRuntime {
NotificationCenter.default.publisher(for: .TorWillRestart)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard self?.chatViewModel.networkActivationAllowed == true
else { return }
self?.record(.torLifecycleChanged(.willRestart))
self?.chatViewModel.handleTorWillRestart()
}
@@ -281,6 +290,8 @@ private extension AppRuntime {
NotificationCenter.default.publisher(for: .TorDidBecomeReady)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard self?.chatViewModel.networkActivationAllowed == true
else { return }
self?.record(.torLifecycleChanged(.didBecomeReady))
self?.chatViewModel.handleTorDidBecomeReady()
}
@@ -289,6 +300,8 @@ private extension AppRuntime {
NotificationCenter.default.publisher(for: .TorWillStart)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard self?.chatViewModel.networkActivationAllowed == true
else { return }
self?.record(.torLifecycleChanged(.willStart))
self?.chatViewModel.handleTorWillStart()
}
@@ -297,6 +310,8 @@ private extension AppRuntime {
NotificationCenter.default.publisher(for: .TorUserPreferenceChanged)
.receive(on: DispatchQueue.main)
.sink { [weak self] notification in
guard self?.chatViewModel.networkActivationAllowed == true
else { return }
self?.record(.torLifecycleChanged(.preferenceChanged))
self?.chatViewModel.handleTorPreferenceChanged(notification)
}
@@ -313,21 +328,30 @@ private extension AppRuntime {
}
func checkForSharedContent() {
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID),
let sharedContent = userDefaults.string(forKey: "sharedContent"),
guard chatViewModel.networkActivationAllowed else { return }
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else { return }
let clearSharedContent = {
userDefaults.removeObject(forKey: "sharedContent")
userDefaults.removeObject(forKey: "sharedContentType")
userDefaults.removeObject(forKey: "sharedContentDate")
}
guard let sharedContent = userDefaults.string(forKey: "sharedContent"),
let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else {
// A partial or malformed handoff must not linger in the shared
// app-group container indefinitely.
clearSharedContent()
return
}
guard Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds else {
clearSharedContent()
return
}
let contentKind = SharedContentKind(rawValue: userDefaults.string(forKey: "sharedContentType") ?? "") ?? .text
userDefaults.removeObject(forKey: "sharedContent")
userDefaults.removeObject(forKey: "sharedContentType")
userDefaults.removeObject(forKey: "sharedContentDate")
clearSharedContent()
switch contentKind {
case .url:
@@ -351,7 +375,9 @@ private extension AppRuntime {
let becameConnected = isConnected && !lastNostrRelayConnectedState
lastNostrRelayConnectedState = isConnected
guard started, becameConnected else { return }
guard chatViewModel.networkActivationAllowed,
started,
becameConnected else { return }
let isInitialConnection = !didHandleInitialNostrConnection
didHandleInitialNostrConnection = true
+18 -1
View File
@@ -225,8 +225,25 @@ final class Conversation: ObservableObject, Identifiable {
guard let current else { return false }
if current == new { return true }
// Never downgrade to a weaker delivery state. Ordering of certainty:
// sending < sent < carried < delivered < read. A late `.sent` write
// (e.g. the optimistic stamp after routing) must not clobber the
// `.carried` the router already set when it handed a copy to a
// courier/bridge, nor a `.delivered`/`.read` ack. A late asynchronous
// failure is weaker than a confirmed recipient receipt too, so it may
// not replace `.delivered`/`.read`. Same for the
// `.sending` stamp a pre-handshake resend emits asynchronously: it
// can land after the message already reached `.sent`, and "Sent" was
// already truthful. (`.failed` `.sending` stays allowed so a real
// failure retry is visible.)
switch (current, new) {
case (.read, .delivered), (.read, .sent):
case (.read, .delivered), (.read, .carried), (.read, .sent), (.read, .sending), (.read, .failed):
return true
case (.delivered, .carried), (.delivered, .sent), (.delivered, .sending), (.delivered, .failed):
return true
case (.carried, .sent), (.carried, .sending):
return true
case (.sent, .sending):
return true
default:
return false
+12
View File
@@ -12,6 +12,7 @@ final class ConversationUIModel: ObservableObject {
@Published private(set) var currentNickname: String
@Published private(set) var isBatchingPublic = false
@Published private(set) var canSendMediaInCurrentContext = true
@Published private(set) var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest?
/// Who is talking live in the public mesh channel right now (floor
/// courtesy: the composer mic tints "busy" while someone holds the floor).
@Published private(set) var activeLiveVoiceTalker: String?
@@ -153,6 +154,13 @@ final class ConversationUIModel: ObservableObject {
chatViewModel.sendVoiceNote(at: url)
}
func resolveLegacyPrivateMediaConsent(requestID: UUID, approved: Bool) {
chatViewModel.resolveLegacyPrivateMediaConsent(
requestID: requestID,
approved: approved
)
}
/// Capture backend for the mic gesture: live PTT when the current DM
/// peer can hear it now, classic voice note otherwise.
func makeVoiceCaptureSession() -> VoiceCaptureSession {
@@ -193,6 +201,10 @@ final class ConversationUIModel: ObservableObject {
.receive(on: DispatchQueue.main)
.assign(to: &$activeLiveVoiceTalker)
chatViewModel.$legacyPrivateMediaConsentRequest
.receive(on: DispatchQueue.main)
.assign(to: &$legacyPrivateMediaConsentRequest)
conversations.$activeChannel
.receive(on: DispatchQueue.main)
.sink { [weak self] channel in
+111 -10
View File
@@ -7,45 +7,146 @@ final class LocationPresenceStore: ObservableObject {
@Published private(set) var geoNicknames: [String: String] = [:]
@Published private(set) var teleportedGeo: Set<String> = []
private let teleportedGeoCapacity: Int
private var teleportedGeoOrder: [String] = []
private let geoNicknameCapacity: Int
private var geoNicknameOrder: [String] = []
init(
teleportedGeoCapacity: Int = TransportConfig.geoTeleportedParticipantsCap,
geoNicknameCapacity: Int = TransportConfig.geoNicknameParticipantsCap
) {
self.teleportedGeoCapacity = max(0, teleportedGeoCapacity)
self.geoNicknameCapacity = max(0, geoNicknameCapacity)
}
func setCurrentGeohash(_ geohash: String?) {
currentGeohash = geohash?.lowercased()
let normalized = geohash?.lowercased()
if currentGeohash != normalized {
// Presence markers are scoped to the active geohash channel.
clearTeleportedGeo()
clearGeoNicknames()
}
currentGeohash = normalized
}
func setNickname(_ nickname: String, for pubkeyHex: String) {
geoNicknames[pubkeyHex.lowercased()] = nickname
guard geoNicknameCapacity > 0 else {
clearGeoNicknames()
return
}
let key = pubkeyHex.lowercased()
if geoNicknames[key] != nil {
geoNicknames[key] = nickname
return
}
while geoNicknameOrder.count >= geoNicknameCapacity, let oldest = geoNicknameOrder.first {
geoNicknameOrder.removeFirst()
geoNicknames.removeValue(forKey: oldest)
}
geoNicknames[key] = nickname
geoNicknameOrder.append(key)
}
func replaceGeoNicknames(_ nicknames: [String: String]) {
geoNicknames = Dictionary(
uniqueKeysWithValues: nicknames.map { key, value in
(key.lowercased(), value)
}
)
guard geoNicknameCapacity > 0 else {
clearGeoNicknames()
return
}
var seen: Set<String> = []
var ordered: [String] = []
var normalized: [String: String] = [:]
for (key, value) in nicknames {
let lower = key.lowercased()
guard seen.insert(lower).inserted else { continue }
ordered.append(lower)
normalized[lower] = value
}
if ordered.count > geoNicknameCapacity {
let kept = Array(ordered.suffix(geoNicknameCapacity))
ordered = kept
normalized = Dictionary(uniqueKeysWithValues: kept.compactMap { key in
normalized[key].map { (key, $0) }
})
}
geoNicknameOrder = ordered
geoNicknames = normalized
}
func clearGeoNicknames() {
geoNicknames.removeAll()
geoNicknameOrder.removeAll()
}
func retainGeoNicknames(keeping pubkeys: Set<String>) {
let allowed = Set(pubkeys.map { $0.lowercased() })
geoNicknameOrder = geoNicknameOrder.filter { allowed.contains($0) }
geoNicknames = geoNicknames.filter { allowed.contains($0.key) }
}
func markTeleported(_ pubkeyHex: String) {
teleportedGeo.insert(pubkeyHex.lowercased())
guard teleportedGeoCapacity > 0 else {
clearTeleportedGeo()
return
}
let key = pubkeyHex.lowercased()
guard !teleportedGeo.contains(key) else { return }
while teleportedGeoOrder.count >= teleportedGeoCapacity, let oldest = teleportedGeoOrder.first {
teleportedGeoOrder.removeFirst()
teleportedGeo.remove(oldest)
}
teleportedGeo.insert(key)
teleportedGeoOrder.append(key)
}
func clearTeleported(_ pubkeyHex: String) {
teleportedGeo.remove(pubkeyHex.lowercased())
let key = pubkeyHex.lowercased()
teleportedGeo.remove(key)
teleportedGeoOrder.removeAll { $0 == key }
}
func replaceTeleportedGeo(_ pubkeys: Set<String>) {
teleportedGeo = Set(pubkeys.map { $0.lowercased() })
guard teleportedGeoCapacity > 0 else {
clearTeleportedGeo()
return
}
var seen: Set<String> = []
var ordered: [String] = []
for key in pubkeys.map({ $0.lowercased() }) where !seen.contains(key) {
seen.insert(key)
ordered.append(key)
}
if ordered.count > teleportedGeoCapacity {
ordered = Array(ordered.suffix(teleportedGeoCapacity))
}
teleportedGeoOrder = ordered
teleportedGeo = Set(ordered)
}
func retainTeleportedGeo(keeping pubkeys: Set<String>) {
let allowed = Set(pubkeys.map { $0.lowercased() })
teleportedGeoOrder = teleportedGeoOrder.filter { allowed.contains($0) }
teleportedGeo = teleportedGeo.intersection(allowed)
}
func clearTeleportedGeo() {
teleportedGeo.removeAll()
teleportedGeoOrder.removeAll()
}
func reset() {
currentGeohash = nil
geoNicknames.removeAll()
geoNicknameOrder.removeAll()
teleportedGeo.removeAll()
teleportedGeoOrder.removeAll()
}
}
+55 -6
View File
@@ -17,16 +17,52 @@ final class NearbyNotesCounter: ObservableObject {
static let shared = NearbyNotesCounter()
@Published private(set) var noteCount = 0
/// Whether an explicit notes act (the empty-timeline "check for notes"
/// tap, opening the notices sheet's geo tab, or a successful /drop) has
/// unlocked the counter this session. Until then nothing subscribes:
/// merely looking at the mesh timeline must not open a building-precision
/// relay REQ that leaks location passively.
@Published private(set) var revealed = false
private var manager: LocationNotesManager?
private var managerCancellable: AnyCancellable?
private var channelsCancellable: AnyCancellable?
private var permissionCancellable: AnyCancellable?
private var settingCancellable: AnyCancellable?
private var activeHolders = 0
private let locationManager: LocationChannelManager
private let managerFactory: @MainActor (String) -> LocationNotesManager
private let releaseManager: @MainActor (LocationNotesManager?) -> Void
init(locationManager: LocationChannelManager = .shared) {
init(
locationManager: LocationChannelManager = .shared,
managerFactory: @escaping @MainActor (String) -> LocationNotesManager = { LocationNotesPool.shared.acquire($0) },
releaseManager: @escaping @MainActor (LocationNotesManager?) -> Void = { LocationNotesPool.shared.release($0) }
) {
self.locationManager = locationManager
self.managerFactory = managerFactory
self.releaseManager = releaseManager
}
/// Whether the empty-timeline "check for notes" hint should render.
/// The permission gate matters: `retarget()` never subscribes without
/// location authorization, so offering the hint to an unauthorized
/// install would be a silent dead-end tap, `revealed` flips, the hint
/// vanishes, and nothing else happens for the session. The hint never
/// prompts; it simply stays hidden until permission exists. The caller
/// passes its own observed permission state so the hint re-renders when
/// authorization changes.
func offersRevealHint(permissionState: LocationChannelManager.PermissionState) -> Bool {
!revealed && LocationNotesSettings.enabled && permissionState == .authorized
}
/// Marks the one explicit act that lets the counter subscribe. Sticky for
/// the rest of the session (the singleton's lifetime); `deactivate()`
/// deliberately does not reset it.
func reveal() {
guard !revealed else { return }
revealed = true
retarget()
}
/// Begins (or keeps) the notes subscription for the current building
@@ -38,6 +74,13 @@ final class NearbyNotesCounter: ObservableObject {
channelsCancellable = locationManager.$availableChannels
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.retarget() }
// CoreLocation can revoke authorization while the view remains
// mounted. `availableChannels` deliberately retains its last value,
// so permission must be an independent invalidation signal or the
// building REQ survives on stale coordinates.
permissionCancellable = locationManager.$permissionState
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.retarget() }
// The app-info kill switch must take effect immediately, not on the
// next location change or remount.
settingCancellable = NotificationCenter.default
@@ -51,33 +94,39 @@ final class NearbyNotesCounter: ObservableObject {
activeHolders = max(0, activeHolders - 1)
guard activeHolders == 0 else { return }
channelsCancellable = nil
permissionCancellable = nil
settingCancellable = nil
managerCancellable = nil
manager?.cancel()
releaseManager(manager)
manager = nil
noteCount = 0
}
private func retarget() {
guard activeHolders > 0,
revealed,
LocationNotesSettings.enabled,
locationManager.permissionState == .authorized,
let geohash = locationManager.availableChannels
.first(where: { $0.level == .building })?.geohash
else {
managerCancellable = nil
manager?.cancel()
releaseManager(manager)
manager = nil
noteCount = 0
return
}
if let manager {
manager.setGeohash(geohash)
return
guard manager.geohash != geohash.lowercased() else { return }
// Pooled managers are shared; never retarget one in place
// release the old cell and acquire the new one.
managerCancellable = nil
releaseManager(manager)
self.manager = nil
}
let fresh = LocationNotesManager(geohash: geohash)
let fresh = managerFactory(geohash)
manager = fresh
managerCancellable = fresh.$notes
.receive(on: DispatchQueue.main)
+4 -4
View File
@@ -265,10 +265,10 @@ final class PrivateConversationModel: ObservableObject {
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
let peer = chatViewModel.getPeer(byID: headerPeerID)
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
// Geo DMs are always routed over Nostr (NIP-17); their nostr_ keys
// never resolve to a reachable mesh peer, so resolveAvailability would
// report .offline. Report .nostrAvailable so the header shows the
// globe instead of a misleading "offline" tag.
// Geo DMs are always routed through BitChat private envelopes over
// Nostr; their nostr_ keys never resolve to a reachable mesh peer, so
// resolveAvailability would report .offline. Report .nostrAvailable
// so the header shows the globe instead of a misleading "offline" tag.
let availability = conversationPeerID.isGeoDM
? .nostrAvailable
: resolveAvailability(for: headerPeerID, peer: peer)
@@ -0,0 +1,472 @@
//
// AudioSessionCoordinator.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import AVFoundation
import BitLogger
import Foundation
/// The raw audio-session calls the coordinator makes, abstracted so the
/// state machine is unit-testable with a mock (and compiles on the macOS
/// test host, where `AVAudioSession` doesn't exist).
///
/// Calls arrive on the coordinator's private serial queue never the main
/// thread. `setCategory`/`setActive` block on IPC to the audio server
/// (observed >1 s under contention on device, tripping the system gesture
/// gate), and Apple explicitly recommends activating the session off the
/// main thread.
protocol SessionApplying: Sendable {
func setCategory(_ category: AudioSessionCoordinator.Category) throws
func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws
}
/// Sole owner of `AVAudioSession` category/activation for voice features.
///
/// Talk-over means capture (push-to-talk) and playback (inbound bursts,
/// voice notes) can be live simultaneously; letting each engine configure
/// the shared session directly made them stomp each other's category and
/// route mid-flight (the AURemoteIO -10851 dead-input class). Instead every
/// client acquires a `Token` and the coordinator:
///
/// - reference-counts activation: `setActive(true)` only on the first
/// holder, `setActive(false, notifyOthersOnDeactivation:)` only when the
/// last one releases no client can deactivate another's session;
/// - keeps one escalating category: playback-only holders get `.playback`,
/// any capture holder escalates to `.playAndRecord`, and the category is
/// never downgraded while anyone still holds a token (capture ending must
/// not yank the route out from under live playback);
/// - fans out `onInterrupted` on system interruptions and when the active
/// route's device disappears (no auto-resume: bursts are transient, the
/// next press or burst simply re-acquires). The escalating category change
/// fans out separately as `onCategoryEscalated` the session stays live,
/// so holders that can rebuild their engine against the new configuration
/// keep playing (talk-over is bidirectional); holders that don't provide
/// it fall back to `onInterrupted`.
///
/// Threading: all state lives on a private serial queue, which both
/// serializes rapid acquire/release pairs and keeps the blocking session IPC
/// off the main thread (`acquire` is `async` for exactly that hop; `release`
/// is fire-and-forget onto the queue). Holder callbacks always run on the
/// main actor.
///
/// Microphone *permission* queries stay with their callers; this type owns
/// only category and activation.
///
/// `@unchecked Sendable`: every mutable property is confined to `queue`.
final class AudioSessionCoordinator: @unchecked Sendable {
enum Use {
case playback
case capture
}
/// The session category the coordinator has applied (the `SessionApplying`
/// adapter maps these to concrete `AVAudioSession` category/mode/options).
enum Category {
case playback
case playAndRecord
}
/// Opaque handle for one client's hold on the session. Release exactly
/// once when done (extra releases are ignored).
///
/// `@unchecked` because the stored callbacks are `@MainActor`-isolated
/// closures (non-Sendable as stored types). Lifecycle state is protected
/// by `stateLock`, and callbacks are only ever invoked on the main actor.
final class Token: @unchecked Sendable {
fileprivate enum CallbackKind: Sendable {
case interrupted
case categoryEscalated
}
/// A callback snapshot is only valid for the lifecycle epoch in which
/// it was captured. `release` advances the epoch synchronously before
/// its queue work, so a callback already headed to the main actor can't
/// reach a client that has since released this token and reacquired a
/// different one.
fileprivate struct CallbackTicket: Sendable {
let token: Token
let kind: CallbackKind
let lifecycleEpoch: UInt64
}
private enum Lifecycle {
/// Registered on the session queue, but `acquire` has not yet
/// returned into the client's main-actor call frame.
case acquiring
case ready
case released
}
fileprivate let onInterrupted: @MainActor () -> Void
fileprivate let onCategoryEscalated: (@MainActor () -> Void)?
private let stateLock = NSLock()
private var lifecycle = Lifecycle.acquiring
private var lifecycleEpoch: UInt64 = 0
/// A terminal event that lands while the token is registered but not
/// yet handed off invalidates the acquire before its caller can start.
private var terminalEventPendingHandoff = false
fileprivate init(
onInterrupted: @escaping @MainActor () -> Void,
onCategoryEscalated: (@MainActor () -> Void)?
) {
self.onInterrupted = onInterrupted
self.onCategoryEscalated = onCategoryEscalated
}
/// Records an event at the same linearization point at which the
/// coordinator snapshots its holders. An acquiring token cannot safely
/// receive a callback yet: terminal events invalidate the acquire,
/// while category escalation needs no callback because its engine will
/// start against the already-escalated configuration.
fileprivate func record(_ kind: CallbackKind) -> CallbackTicket? {
stateLock.withLock {
switch lifecycle {
case .acquiring:
switch kind {
case .interrupted:
terminalEventPendingHandoff = true
case .categoryEscalated:
break
}
return nil
case .ready:
return CallbackTicket(token: self, kind: kind, lifecycleEpoch: lifecycleEpoch)
case .released:
return nil
}
}
}
/// Completes the main-actor ownership handoff if no terminal event
/// invalidated it. Because `acquire` itself is main-actor isolated, a
/// successful handoff returns directly into the caller without another
/// actor hop; no callback can interleave before the caller stores the
/// returned token.
fileprivate func completeHandoff() -> Bool {
stateLock.withLock {
guard lifecycle == .acquiring,
!terminalEventPendingHandoff
else { return false }
lifecycle = .ready
return true
}
}
/// Marks the token dead synchronously, before the asynchronous holder
/// removal. Returns false for an already-released token.
fileprivate func markReleased() -> Bool {
stateLock.withLock {
guard lifecycle != .released else { return false }
lifecycle = .released
lifecycleEpoch &+= 1
terminalEventPendingHandoff = false
return true
}
}
/// Revalidates a queue snapshot at the main-actor delivery boundary.
/// The lock is deliberately released before invoking client code: real
/// callbacks commonly call `release` on this same token.
@MainActor
fileprivate func deliver(_ ticket: CallbackTicket) {
let isLive = stateLock.withLock {
lifecycle == .ready && lifecycleEpoch == ticket.lifecycleEpoch
}
guard isLive else { return }
switch ticket.kind {
case .interrupted:
onInterrupted()
case .categoryEscalated:
(onCategoryEscalated ?? onInterrupted)()
}
}
}
/// Deterministic suspension points for lifecycle race tests. Production
/// instances use the nil defaults; the hooks never move session calls off
/// the coordinator queue or callback execution off the main actor.
struct TestingHooks: Sendable {
let beforeAcquireHandoff: (@Sendable () async -> Void)?
let beforeCallbackDelivery: (@Sendable () async -> Void)?
init(
beforeAcquireHandoff: (@Sendable () async -> Void)? = nil,
beforeCallbackDelivery: (@Sendable () async -> Void)? = nil
) {
self.beforeAcquireHandoff = beforeAcquireHandoff
self.beforeCallbackDelivery = beforeCallbackDelivery
}
}
static let shared = AudioSessionCoordinator(session: SystemAudioSession())
private let session: SessionApplying
private let testingHooks: TestingHooks
/// Confines all mutable state, serializes whole acquire/release
/// operations (two rapid presses can't interleave their category and
/// activation calls), and hosts the blocking session IPC off main.
private let queue = DispatchQueue(label: "chat.bitchat.audio-session", qos: .userInitiated)
// Queue-confined state.
private var holders: [ObjectIdentifier: Token] = [:]
private var currentCategory: Category?
private var sessionActive = false
/// Written once in init, read in deinit never touched concurrently.
private var observers: [NSObjectProtocol] = []
init(session: SessionApplying, testingHooks: TestingHooks = TestingHooks()) {
self.session = session
self.testingHooks = testingHooks
observeSystemNotifications()
}
deinit {
for observer in observers {
NotificationCenter.default.removeObserver(observer)
}
}
/// Configures + activates the session for `use` and registers the caller
/// as a holder. The blocking `AVAudioSession` calls run on the session
/// queue the caller suspends instead of stalling its thread (a PTT
/// press used to block main >1 s in `setActive`, tripping the system
/// gesture gate). `onInterrupted` fires (on the main actor) when the
/// client must stop using the session: a system interruption began or
/// its route's device went away. The client should stop its engine,
/// finalize any artifacts, and release resuming means acquiring again.
///
/// `onCategoryEscalated` fires instead when the session category
/// escalated underneath the holder (a capture client joined): the session
/// stays active, so a holder that can rebuild its engine against the new
/// configuration should restart and keep going. Holders that pass `nil`
/// get `onInterrupted` for escalation too. Escalation is delivered before
/// `acquire` returns, so the new holder starts its engine strictly after
/// existing ones were told to rebuild. Main-actor isolation is also the
/// ownership handoff boundary: if interruption or route loss lands after
/// queue registration but before that boundary, the provisional holder is
/// removed and `acquire` throws `CancellationError` instead of returning a
/// token whose callback already fired.
@MainActor
func acquire(
_ use: Use,
onInterrupted: @escaping @MainActor () -> Void,
onCategoryEscalated: (@MainActor () -> Void)? = nil
) async throws -> Token {
let token = Token(onInterrupted: onInterrupted, onCategoryEscalated: onCategoryEscalated)
let reconfigured: [Token.CallbackTicket] = try await withCheckedThrowingContinuation { continuation in
queue.async {
do {
continuation.resume(returning: try self.activateOnQueue(use, registering: token))
} catch {
continuation.resume(throwing: error)
}
}
}
// Escalating playback -> playAndRecord reconfigures the hardware
// route; engines started against the old configuration must restart.
if !reconfigured.isEmpty {
SecureLogger.info("AudioSession: category escalated to playAndRecord with \(reconfigured.count) live holder(s)", category: .session)
await deliver(reconfigured)
}
if let beforeAcquireHandoff = testingHooks.beforeAcquireHandoff {
await beforeAcquireHandoff()
}
guard token.completeHandoff() else {
// A call/Siri interruption or route loss landed after registration
// but before ownership handoff. Remove the provisional holder and
// fail instead of starting a client engine after the stop event.
release(token)
throw CancellationError()
}
return token
}
/// Drops one holder. Deactivates the session (notifying other apps) only
/// when the last holder releases. Safe to call more than once, from any
/// thread (including `deinit` paths): the work is fire-and-forget onto
/// the session queue, so the blocking deactivation IPC never runs on the
/// caller.
func release(_ token: Token) {
guard token.markReleased() else { return }
queue.async {
self.releaseOnQueue(token)
}
}
// MARK: - Queue-confined core
/// Returns callback tickets for pre-existing live holders whose engines
/// must restart because this acquire escalated the category.
private func activateOnQueue(_ use: Use, registering token: Token) throws -> [Token.CallbackTicket] {
let target: Category = (use == .capture || currentCategory == .playAndRecord) ? .playAndRecord : .playback
let categoryChanged = target != currentCategory
let previousCategory = currentCategory
if categoryChanged {
try session.setCategory(target)
currentCategory = target
}
if !sessionActive {
do {
try session.setActive(true, notifyOthersOnDeactivation: false)
} catch {
// Activation failed (e.g. a phone call owns the hardware):
// with no holder registered, an escalated category recorded
// here would stick and pin later playback-only acquires to
// .playAndRecord. Existing holders keep the category the
// hardware really has.
if categoryChanged, holders.isEmpty {
currentCategory = previousCategory
}
throw error
}
sessionActive = true
}
let reconfigured = categoryChanged
? holders.values.compactMap { $0.record(.categoryEscalated) }
: []
holders[ObjectIdentifier(token)] = token
return reconfigured
}
private func releaseOnQueue(_ token: Token) {
guard holders.removeValue(forKey: ObjectIdentifier(token)) != nil else { return }
guard holders.isEmpty else { return }
currentCategory = nil
guard sessionActive else { return }
sessionActive = false
do {
try session.setActive(false, notifyOthersOnDeactivation: true)
} catch {
SecureLogger.error("AudioSession: deactivation failed: \(error)", category: .session)
}
}
private func onQueue<T: Sendable>(_ body: @escaping @Sendable () -> T) async -> T {
await withCheckedContinuation { continuation in
queue.async {
continuation.resume(returning: body())
}
}
}
@MainActor
private func deliver(_ tickets: [Token.CallbackTicket]) async {
guard !tickets.isEmpty else { return }
if let beforeCallbackDelivery = testingHooks.beforeCallbackDelivery {
await beforeCallbackDelivery()
}
for ticket in tickets {
ticket.token.deliver(ticket)
}
}
// MARK: - System events (internal so tests can drive them directly)
/// A system interruption began: the session is already deactivated by the
/// OS, so just mark it inactive and tell every ready holder (on the main
/// actor) to stop. A provisional acquiring holder is invalidated instead.
/// No auto-resume the next acquire re-activates.
func handleInterruptionBegan() async {
let tickets = await onQueue { () -> [Token.CallbackTicket] in
self.sessionActive = false
return self.holders.values.compactMap { $0.record(.interrupted) }
}
await deliver(tickets)
}
/// The active route's input/output device disappeared (e.g. BT headset
/// off): ready holders' engines are wedged against a dead route stop
/// them; invalidate a holder whose acquire has not returned yet.
func handleRouteDeviceUnavailable() async {
let tickets = await onQueue {
self.holders.values.compactMap { $0.record(.interrupted) }
}
await deliver(tickets)
}
/// Test hook: suspends until every session operation enqueued before this
/// call including fire-and-forget `release`s has completed.
func drain() async {
await onQueue {}
}
private func observeSystemNotifications() {
#if os(iOS)
let center = NotificationCenter.default
observers.append(center.addObserver(
forName: AVAudioSession.interruptionNotification,
object: AVAudioSession.sharedInstance(),
queue: .main
) { [weak self] note in
guard let raw = note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
AVAudioSession.InterruptionType(rawValue: raw) == .began,
let self
else { return }
SecureLogger.info("AudioSession: interruption began", category: .session)
Task { await self.handleInterruptionBegan() }
})
observers.append(center.addObserver(
forName: AVAudioSession.routeChangeNotification,
object: AVAudioSession.sharedInstance(),
queue: .main
) { [weak self] note in
guard let raw = note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt,
AVAudioSession.RouteChangeReason(rawValue: raw) == .oldDeviceUnavailable,
let self
else { return }
SecureLogger.info("AudioSession: route device became unavailable", category: .session)
Task { await self.handleRouteDeviceUnavailable() }
})
#endif
}
}
// MARK: - Production adapter
#if os(iOS)
private struct SystemAudioSession: SessionApplying {
func setCategory(_ category: AudioSessionCoordinator.Category) throws {
let session = AVAudioSession.sharedInstance()
switch category {
case .playback:
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
case .playAndRecord:
// allowBluetoothHFP is not available on iOS Simulator
#if targetEnvironment(simulator)
try session.setCategory(
.playAndRecord,
mode: .default,
options: [.defaultToSpeaker, .allowBluetoothA2DP, .mixWithOthers]
)
#else
try session.setCategory(
.playAndRecord,
mode: .default,
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP, .mixWithOthers]
)
#endif
}
}
func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws {
try AVAudioSession.sharedInstance().setActive(
active,
options: notifyOthersOnDeactivation ? [.notifyOthersOnDeactivation] : []
)
}
}
#else
/// macOS has no app-level audio session; the coordinator still runs its
/// bookkeeping so client code is identical across platforms.
private struct SystemAudioSession: SessionApplying {
func setCategory(_ category: AudioSessionCoordinator.Category) throws {}
func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws {}
}
#endif
+397 -32
View File
@@ -6,10 +6,142 @@
// For more information, see <https://unlicense.org>
//
import AVFoundation
@preconcurrency import AVFoundation
import BitLogger
import Foundation
/// The engine operations behind live-burst playback, abstracted so the
/// player's lifecycle (jitter start, category-escalation restart, stop) is
/// unit-testable without real audio hardware.
@MainActor
protocol PTTPlaybackEngine: AnyObject {
/// The object `AVAudioEngineConfigurationChange` notifications are posted
/// for (nil for mocks no observer is registered).
var configChangeObject: AnyObject? { get }
func start() throws
func play()
func stop()
func schedule(
_ buffer: AVAudioPCMBuffer,
completionType: PTTPlaybackCompletionType,
completionHandler: @escaping @Sendable (PTTPlaybackCompletionEvent) -> Void
)
}
/// The lifecycle point requested from `AVAudioPlayerNode` for a scheduled
/// buffer. `dataConsumed` only means the node no longer needs the bytes; it
/// may arrive before the render pipeline has made the audio audible.
enum PTTPlaybackCompletionType: Equatable, Sendable {
case dataConsumed
case dataPlayedBack
}
enum PTTPlaybackCompletionEvent: Equatable, Sendable {
case dataConsumed
case dataPlayedBack
/// AVAudioPlayerNode invokes the requested callback when the node is
/// stopped too. That is not audible completion and must remain replayable.
case playbackStopped
}
/// One `AVAudioEngine` + `AVAudioPlayerNode` pair. Created fresh per (re)start:
/// an engine instantiated against an earlier audio-session configuration keeps
/// rendering to the stale route (same class of failure as the capture side's
/// fresh-engine-per-press rule).
@MainActor
private final class SystemPTTPlaybackEngine: PTTPlaybackEngine {
private let engine = AVAudioEngine()
private let node = AVAudioPlayerNode()
init(format: AVAudioFormat) {
engine.attach(node)
engine.connect(node, to: engine.mainMixerNode, format: format)
}
var configChangeObject: AnyObject? { engine }
func start() throws {
engine.prepare()
try engine.start()
}
func play() {
node.play()
}
func stop() {
node.stop()
engine.stop()
}
func schedule(
_ buffer: AVAudioPCMBuffer,
completionType: PTTPlaybackCompletionType,
completionHandler: @escaping @Sendable (PTTPlaybackCompletionEvent) -> Void
) {
let callbackType: AVAudioPlayerNodeCompletionCallbackType = switch completionType {
case .dataConsumed: .dataConsumed
case .dataPlayedBack: .dataPlayedBack
}
let scheduledEngine = engine
node.scheduleBuffer(buffer, completionCallbackType: callbackType) { [weak scheduledEngine] callbackType in
// The API invokes this callback when the player is stopped as
// well. A configuration change can stop the engine before its
// notification reaches MainActor, so do not misclassify that
// flushed tail as audible playback.
guard scheduledEngine?.isRunning == true else {
completionHandler(.playbackStopped)
return
}
switch callbackType {
case .dataConsumed:
completionHandler(.dataConsumed)
case .dataRendered:
completionHandler(.dataConsumed)
case .dataPlayedBack:
completionHandler(.dataPlayedBack)
@unknown default:
completionHandler(.playbackStopped)
}
}
}
}
/// Completion callbacks arrive off the main actor, while engine rebuilds are
/// serialized on it. This small lock-backed latch lets a rebuild atomically
/// claim only buffers whose completion has not already fired even when the
/// callback's hop back to the main actor is still queued.
private final class PTTPlaybackCompletionState: @unchecked Sendable {
private enum State {
case scheduled
case completed
case retired
}
private let lock = NSLock()
private var state: State = .scheduled
/// Returns true exactly once when playback completion wins the race with
/// an engine rebuild or stop.
func complete() -> Bool {
lock.withLock {
guard case .scheduled = state else { return false }
state = .completed
return true
}
}
/// Returns true exactly once when a rebuild or stop claims this
/// still-pending schedule. Later callbacks from that engine are stale.
func retireIfPending() -> Bool {
lock.withLock {
guard case .scheduled = state else { return false }
state = .retired
return true
}
}
}
/// Plays one inbound live voice burst with a small jitter buffer.
///
/// Frames are decoded and scheduled back-to-back on an `AVAudioPlayerNode`;
@@ -17,27 +149,77 @@ import Foundation
/// buffer arrives, which self-heals timing without explicit silence
/// insertion. Playback starts once `TransportConfig.pttJitterBufferSeconds`
/// of audio is queued or `pttJitterDeadlineSeconds` has elapsed.
///
/// Talk-over is bidirectional: when push-to-talk capture starts while this
/// burst plays, the session category escalates underneath the engine the
/// player rebuilds a fresh engine against the new configuration and keeps
/// streaming instead of dying. Real interruptions (phone call, route device
/// gone) still stop it; the burst keeps assembling to file either way.
@MainActor
final class PTTBurstPlayer {
private let engine = AVAudioEngine()
private let node = AVAudioPlayerNode()
/// Restart-on-reconfigure ceiling: a burst is at most ~2 minutes, so a
/// handful of category/route changes is plenty beyond it something is
/// thrashing and stopping cleanly beats an engine-rebuild loop.
private static let maxEngineRestarts = 8
private let makeEngine: @MainActor () -> PTTPlaybackEngine
private var engine: PTTPlaybackEngine
private let decoder: PTTFrameDecoder
private let coordinator: AudioSessionCoordinator
/// Injectable so tests don't fight over the app-wide exclusive-playback
/// slot (a parallel test's `play()` would stop this player mid-test).
private let exclusivity: VoiceNotePlaybackCoordinator
private var queuedBuffers: [AVAudioPCMBuffer] = []
private var queuedDuration: TimeInterval = 0
private var scheduledCount = 0
private struct ScheduledBuffer {
let id: UInt64
let buffer: AVAudioPCMBuffer
let completionState: PTTPlaybackCompletionState
}
/// Buffers handed to the current engine whose completion has not yet
/// been processed on the main actor. Keeping the buffers themselves lets
/// a category-escalation rebuild replay the unfinished tail in order.
private var scheduledBuffers: [ScheduledBuffer] = []
private var nextScheduledBufferID: UInt64 = 0
/// Bumped on every engine rebuild or stop so completion tasks from a
/// torn-down engine cannot mutate the current generation's pending list.
private var engineGeneration = 0
private var engineRestarts = 0
private var engineStarted = false
private var finished = false
private var stopped = false
/// Latched off (internal read so tests can await the async failure path).
private(set) var stopped = false
/// A session acquire is in flight (it suspends off-main for the blocking
/// session IPC); gates `startIfReady` against double acquisition.
private var acquiringSession = false
private var deadlineTask: Task<Void, Never>?
private var sessionToken: AudioSessionCoordinator.Token?
/// Reserved before the session acquire suspends. Activation succeeds only
/// if no newer playback request claimed the floor in the meantime.
private var playbackReservation: VoiceNotePlaybackCoordinator.Reservation?
private var configChangeObserver: NSObjectProtocol?
private(set) var isPlaying = false
init?() {
/// Fires exactly once when the player stops for good (drain-out, cancel,
/// interruption, failure). `ChatLiveVoiceCoordinator` uses it to unpark
/// the draining player it keeps alive after the assembly the player's
/// only long-lived owner is discarded on burst END.
var onStopped: (() -> Void)?
init?(
coordinator: AudioSessionCoordinator? = nil,
exclusivity: VoiceNotePlaybackCoordinator? = nil,
makeEngine: (@MainActor () -> PTTPlaybackEngine)? = nil
) {
guard let format = PTTAudioFormat.pcmFormat, let decoder = PTTFrameDecoder() else { return nil }
self.decoder = decoder
engine.attach(node)
engine.connect(node, to: engine.mainMixerNode, format: format)
self.coordinator = coordinator ?? .shared
self.exclusivity = exclusivity ?? .shared
let factory = makeEngine ?? { SystemPTTPlaybackEngine(format: format) }
self.makeEngine = factory
self.engine = factory()
deadlineTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(TransportConfig.pttJitterDeadlineSeconds * 1_000_000_000))
@@ -45,6 +227,21 @@ final class PTTBurstPlayer {
}
}
deinit {
// Backstop for an owner dropping the player before it stopped: the
// session coordinator retains registered tokens strongly, so a token
// leaked here would keep the session active (and pin any escalated
// category) for the app's lifetime. `release` is fire-and-forget
// onto the coordinator's queue, so it is deinit-safe.
if let token = sessionToken {
coordinator.release(token)
}
if let observer = configChangeObserver {
NotificationCenter.default.removeObserver(observer)
}
deadlineTask?.cancel()
}
/// Decodes and queues frames (in burst order). Starts playback when the
/// jitter buffer fills.
func enqueue(_ frames: [Data]) {
@@ -64,49 +261,119 @@ final class PTTBurstPlayer {
/// The burst ended: stop once everything scheduled has played out.
func finishAfterDrain() {
finished = true
// The complete burst is queued no jitter left to wait for. This
// also matters when END lands while the async session acquire is
// still in flight: the queued audio must play out, not be treated
// as already drained.
startIfReady(force: true)
stopIfDrained()
}
/// Immediate stop (cancel, another playback taking over, teardown).
/// Immediate stop (cancel, another playback taking over, interruption,
/// teardown).
func stop() {
guard !stopped else { return }
stopped = true
deadlineTask?.cancel()
removeConfigObserver()
queuedBuffers = []
retireScheduledBuffers()
if engineStarted {
node.stop()
engine.stop()
}
isPlaying = false
VoiceNotePlaybackCoordinator.shared.deactivate(self)
releaseSessionToken()
exclusivity.deactivate(self)
onStopped?()
}
private func startIfReady(force: Bool) {
guard !engineStarted, !stopped, !queuedBuffers.isEmpty else { return }
guard !engineStarted, !acquiringSession, !stopped, !queuedBuffers.isEmpty else { return }
guard force || queuedDuration >= TransportConfig.pttJitterBufferSeconds else { return }
#if os(iOS)
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
try session.setActive(true, options: [])
} catch {
SecureLogger.error("PTT playback session activation failed: \(error)", category: .session)
// Acquiring the session suspends for its blocking IPC (off the main
// actor); frames arriving meanwhile keep queueing and are flushed
// onto the engine once it starts.
acquiringSession = true
playbackReservation = exclusivity.reserve(self)
Task { [weak self] in
await self?.acquireSessionAndStart()
}
#endif
}
engine.prepare()
private func acquireSessionAndStart() async {
let token: AudioSessionCoordinator.Token
do {
token = try await coordinator.acquire(
.playback,
onInterrupted: { [weak self] in self?.stop() },
onCategoryEscalated: { [weak self] in self?.restartEngine() }
)
} catch {
acquiringSession = false
SecureLogger.error("PTT playback session activation failed: \(error)", category: .session)
// Playing unregistered would leave the engine exposed: another
// holder's last release deactivates the session mid-play, and no
// interruption/escalation fan-out ever reaches us. Bail like the
// engine-start failure below; the burst still assembles to file.
// (stop() also fires onStopped so a parked draining player is
// unparked instead of leaking.)
stop()
return
}
acquiringSession = false
// stop() (cancel, exclusivity, teardown) may have landed while the
// session was activating: hand the token straight back.
guard !stopped else {
coordinator.release(token)
return
}
sessionToken = token
guard let playbackReservation,
exclusivity.isCurrent(playbackReservation, for: self)
else {
// The request was superseded while audio-session activation was
// suspended. Do not even start the retired engine.
stop()
return
}
// Observe reconfiguration before starting so nothing lands between.
registerConfigObserver()
do {
try engine.start()
} catch {
SecureLogger.error("PTT playback engine failed to start: \(error)", category: .session)
stopped = true
return
// A capture racing this start can reconfigure the session while
// the engine spins up (its escalation fan-out no-ops on a player
// that never started): rebuild once against the settled
// configuration counted against the restart cap before
// giving up.
SecureLogger.warning("PTT playback engine failed to start (\(error)) — rebuilding once", category: .session)
removeConfigObserver()
engineRestarts += 1
engine = makeEngine()
registerConfigObserver()
do {
try engine.start()
} catch {
SecureLogger.error("PTT playback engine failed to start: \(error)", category: .session)
// stop() removes the observer, hands the token back, and
// fires onStopped for any parked draining owner.
stop()
return
}
}
engineStarted = true
guard exclusivity.activate(self, reservation: playbackReservation)
else {
// A newer user-initiated playback reserved the floor while this
// older PTT request was suspended in audio-session activation.
// Never let the late completion steal playback back.
stop()
return
}
isPlaying = true
VoiceNotePlaybackCoordinator.shared.activate(self)
node.play()
engine.play()
let buffered = queuedBuffers
queuedBuffers = []
@@ -116,21 +383,119 @@ final class PTTBurstPlayer {
}
}
private func schedule(_ buffer: AVAudioPCMBuffer) {
scheduledCount += 1
node.scheduleBuffer(buffer) { [weak self] in
/// The audio session was reconfigured underneath the running engine
/// (category escalation for talk-over, or an engine configuration
/// change): rebuild a fresh engine against the new configuration and
/// keep streaming. Buffers already completed stay completed; the
/// unfinished scheduled tail is replayed in order on the fresh engine,
/// and frames still arriving continue scheduling after it.
private func restartEngine() {
guard engineStarted, !stopped else { return }
engineRestarts += 1
guard engineRestarts <= Self.maxEngineRestarts else {
SecureLogger.warning("PTT playback: engine reconfigured \(engineRestarts) times in one burst — stopping", category: .session)
stop()
return
}
removeConfigObserver()
// Claim the unfinished tail before stopping the old engine. Stopping
// a player node may itself invoke its completion handlers; retiring
// the claimed entries first makes those callbacks unambiguously stale.
// A completion that fired just before this rebuild wins the latch and
// is excluded even if its MainActor task has not run yet.
let buffersToReplay = scheduledBuffers.compactMap { scheduled in
scheduled.completionState.retireIfPending() ? scheduled.buffer : nil
}
scheduledBuffers = []
engineGeneration += 1
engine.stop()
engine = makeEngine()
registerConfigObserver()
do {
try engine.start()
} catch {
SecureLogger.error("PTT playback engine failed to restart after session reconfigure: \(error)", category: .session)
stop()
return
}
engine.play()
for buffer in buffersToReplay {
schedule(buffer)
}
SecureLogger.info("PTT playback: engine restarted after session reconfigure", category: .session)
// If every old buffer completed before the rebuild, a finished burst
// can stop now. Otherwise the replayed tail keeps it alive until its
// new-generation completions arrive.
stopIfDrained()
}
private func registerConfigObserver() {
guard let object = engine.configChangeObject else { return }
configChangeObserver = NotificationCenter.default.addObserver(
forName: .AVAudioEngineConfigurationChange,
object: object,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
guard let self else { return }
self.scheduledCount -= 1
self?.restartEngine()
}
}
}
private func removeConfigObserver() {
if let observer = configChangeObserver {
NotificationCenter.default.removeObserver(observer)
configChangeObserver = nil
}
}
private func schedule(_ buffer: AVAudioPCMBuffer) {
let id = nextScheduledBufferID
nextScheduledBufferID &+= 1
let completionState = PTTPlaybackCompletionState()
scheduledBuffers.append(ScheduledBuffer(
id: id,
buffer: buffer,
completionState: completionState
))
let generation = engineGeneration
engine.schedule(buffer, completionType: .dataPlayedBack) { [weak self, completionState] event in
guard event == .dataPlayedBack else { return }
// Mark completion before hopping to MainActor. A rebuild can then
// distinguish already-completed audio from an unfinished tail
// even when this task has not run yet.
guard completionState.complete() else { return }
Task { @MainActor [weak self] in
guard let self, self.engineGeneration == generation else { return }
self.scheduledBuffers.removeAll { $0.id == id }
self.stopIfDrained()
}
}
}
private func retireScheduledBuffers() {
engineGeneration += 1
for scheduled in scheduledBuffers {
_ = scheduled.completionState.retireIfPending()
}
scheduledBuffers = []
}
private func stopIfDrained() {
guard finished, scheduledCount <= 0 else { return }
guard finished, scheduledBuffers.isEmpty else { return }
// Started: everything scheduled has played out. Never started with
// nothing queued or in flight (e.g. no decodable frames): nothing
// will ever play. Otherwise the engine start is still pending (the
// async session acquire) and the queued audio must play out first.
guard engineStarted || (!acquiringSession && queuedBuffers.isEmpty) else { return }
stop()
}
private func releaseSessionToken() {
sessionToken.map(coordinator.release)
sessionToken = nil
}
}
extension PTTBurstPlayer: ExclusivePlayback {
+179 -36
View File
@@ -10,12 +10,85 @@ import AVFoundation
import BitLogger
import Foundation
/// Owns one capture token and returns it even when the capture engine's owner
/// disappears without reaching its normal stop/cancel path. The coordinator
/// retains registered tokens strongly, so relying on `Token.deinit` cannot
/// reclaim an abandoned hold.
final class PTTCaptureSessionLease: @unchecked Sendable {
private let coordinator: AudioSessionCoordinator
private let lock = NSLock()
private var token: AudioSessionCoordinator.Token?
init(coordinator: AudioSessionCoordinator) {
self.coordinator = coordinator
}
func install(_ token: AudioSessionCoordinator.Token) {
let previous = lock.withLock {
let previous = self.token
self.token = token
return previous
}
previous.map(coordinator.release)
}
func release() {
let token = lock.withLock {
let token = self.token
self.token = nil
return token
}
token.map(coordinator.release)
}
deinit {
release()
}
}
/// Monotonic capture identity shared by main-actor lifecycle code and queued
/// engine callbacks. Removing a notification observer does not cancel a block
/// already enqueued on the main queue, so every callback must also prove it
/// still belongs to the current hold before mutating capture state.
final class PTTCaptureGeneration: @unchecked Sendable {
private let lock = NSLock()
private var value: UInt = 0
func begin() -> UInt {
lock.withLock {
value &+= 1
return value
}
}
func invalidate() {
lock.withLock { value &+= 1 }
}
func invalidate(ifCurrent generation: UInt) -> Bool {
lock.withLock {
guard value == generation else { return false }
value &+= 1
return true
}
}
func isCurrent(_ generation: UInt) -> Bool {
lock.withLock { value == generation }
}
}
/// Captures microphone audio for a live push-to-talk burst, producing both:
/// - live AAC frames via `onFrames` (called on the capture queue), and
/// - a finalized `.m4a` voice note on `stop()` the same artifact
/// `VoiceRecorder` produces, so the existing voice-note send pipeline
/// handles delivery to receivers that missed the live stream.
final class PTTCaptureEngine {
/// `@unchecked Sendable`: every mutable property is confined to one executor
/// the capture `queue` (resampler/encoder/file/counters) or the main actor
/// (`engine`, `engineStarted`, `sessionLease`, `configChangeObserver`) so
/// weak references may cross the `@Sendable` tap/notification closures, which
/// immediately hop back to the owning executor.
final class PTTCaptureEngine: @unchecked Sendable {
/// Hard cap matching `VoiceRecorder.maxRecordingDuration`: past it the
/// engine keeps running (the UI owns the gesture) but stops encoding.
private static let maxCaptureDuration: TimeInterval = 120
@@ -26,6 +99,9 @@ final class PTTCaptureEngine {
/// enable the mic (AURemoteIO -10851, observed on iPhone field tests).
private var engine = AVAudioEngine()
private let queue = DispatchQueue(label: "chat.bitchat.ptt.capture", qos: .userInitiated)
private let coordinator: AudioSessionCoordinator
private let sessionLease: PTTCaptureSessionLease
private let captureGeneration = PTTCaptureGeneration()
// Capture-queue-confined state.
private var resampler: PTTInputResampler?
@@ -35,10 +111,10 @@ final class PTTCaptureEngine {
private var encodedFrameCount = 0
private var running = false
private var captureStart = Date()
/// Whether `engine.start()` succeeded for the current capture (main-actor
/// callers only; see `stopEngineIfStarted`).
private var engineStarted = false
/// Whether `engine.start()` succeeded for the current capture
/// (see `stopEngineIfStarted`).
@MainActor private var engineStarted = false
@MainActor private var configChangeObserver: NSObjectProtocol?
/// Called on the capture queue with each batch of encoded AAC frames.
var onFrames: (([Data]) -> Void)?
@@ -47,11 +123,41 @@ final class PTTCaptureEngine {
case audioSetupFailed
}
func start(outputURL: URL) throws {
#if os(iOS)
try Self.configureAudioSession()
#endif
init(coordinator: AudioSessionCoordinator = .shared) {
self.coordinator = coordinator
self.sessionLease = PTTCaptureSessionLease(coordinator: coordinator)
}
deinit {
sessionLease.release()
}
/// Async because acquiring the session hops its blocking IPC off the main
/// actor (a PTT press used to stall main >1 s in `setActive`); the engine
/// itself still starts back on main once the session is configured.
@MainActor
func start(outputURL: URL) async throws {
let generation = captureGeneration.begin()
let token = try await coordinator.acquire(.capture) { [weak self] in
self?.handleInterruption(for: generation)
}
// The hold ended (stop/cancel) while the session was activating:
// starting the engine now would leave a hot mic after release.
guard captureGeneration.isCurrent(generation) else {
coordinator.release(token)
throw CancellationError()
}
sessionLease.install(token)
do {
try beginCapture(outputURL: outputURL, generation: generation)
} catch {
releaseSessionToken()
throw error
}
}
@MainActor
private func beginCapture(outputURL: URL, generation: UInt) throws {
// Fresh engine per capture so its input unit binds to the session
// that is active *now* (see `engine` doc comment).
engine = AVAudioEngine()
@@ -83,13 +189,30 @@ final class PTTCaptureEngine {
}
engine.inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] buffer, _ in
self?.queue.async { self?.process(buffer) }
self?.queue.async { self?.process(buffer, generation: generation) }
}
// Route/category changes reconfigure the engine underneath the tap;
// stop and finalize cleanly the .m4a captured so far still sends.
// Registered before start() so no reconfigure lands unobserved
// (handleInterruption also validates this capture generation).
configChangeObserver = NotificationCenter.default.addObserver(
forName: .AVAudioEngineConfigurationChange,
object: engine,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.handleInterruption(for: generation)
}
}
engine.prepare()
do {
try engine.start()
} catch {
SecureLogger.error("PTT: capture engine failed to start (input: \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch): \(error)", category: .session)
if let observer = configChangeObserver {
NotificationCenter.default.removeObserver(observer)
configChangeObserver = nil
}
engine.inputNode.removeTap(onBus: 0)
queue.sync { self.teardown(deleteFile: true) }
throw error
@@ -100,7 +223,9 @@ final class PTTCaptureEngine {
/// Stops capture and finalizes the `.m4a`. Returns the file URL and the
/// number of encoded AAC frames (each `PTTAudioFormat.frameDuration` long).
@MainActor
func stop() -> (url: URL?, encodedFrames: Int) {
captureGeneration.invalidate()
stopEngineIfStarted()
let result: (URL?, Int) = queue.sync {
let url = fileURL
@@ -108,34 +233,70 @@ final class PTTCaptureEngine {
teardown(deleteFile: false)
return (url, frames)
}
#if os(iOS)
Self.deactivateAudioSession()
#endif
releaseSessionToken()
return result
}
@MainActor
func cancel() {
captureGeneration.invalidate()
stopEngineIfStarted()
queue.sync { teardown(deleteFile: true) }
#if os(iOS)
Self.deactivateAudioSession()
#endif
releaseSessionToken()
}
/// Audio session interrupted (call, Siri) or the engine was reconfigured
/// mid-capture: behave like `stop()` finalize the `.m4a` container but
/// keep `fileURL`/`encodedFrameCount` so the caller's pending `stop()`
/// still returns the note for delivery.
@MainActor
private func handleInterruption(for generation: UInt) {
// Also invalidate a start whose acquire has registered its token but
// has not returned to this actor yet. Without this bump the callback
// is lost while `engineStarted == false`, and the resumed start can
// open the mic after the stop signal.
guard captureGeneration.invalidate(ifCurrent: generation) else { return }
guard engineStarted else {
releaseSessionToken()
return
}
stopEngineIfStarted()
queue.sync {
running = false
// Releasing the AVAudioFile finalizes the .m4a container.
file = nil
encoder = nil
resampler = nil
}
releaseSessionToken()
SecureLogger.info("PTT: capture interrupted — burst finalized early", category: .session)
}
/// Touching `inputNode` on an engine that never started instantiates its
/// input unit against whatever session is active and spams AURemoteIO
/// errors a canceled-before-start hold must not touch the engine.
@MainActor
private func stopEngineIfStarted() {
if let observer = configChangeObserver {
NotificationCenter.default.removeObserver(observer)
configChangeObserver = nil
}
guard engineStarted else { return }
engineStarted = false
engine.inputNode.removeTap(onBus: 0)
engine.stop()
}
@MainActor
private func releaseSessionToken() {
sessionLease.release()
}
// MARK: - Capture queue
private func process(_ buffer: AVAudioPCMBuffer) {
guard running,
private func process(_ buffer: AVAudioPCMBuffer, generation: UInt) {
guard captureGeneration.isCurrent(generation),
running,
Date().timeIntervalSince(captureStart) < Self.maxCaptureDuration,
let resampled = resampler?.resample(buffer)
else { return }
@@ -162,22 +323,4 @@ final class PTTCaptureEngine {
}
fileURL = nil
}
// MARK: - Audio session (iOS)
#if os(iOS)
private static func configureAudioSession() throws {
let session = AVAudioSession.sharedInstance()
#if targetEnvironment(simulator)
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetoothA2DP])
#else
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP])
#endif
try session.setActive(true, options: .notifyOthersOnDeactivation)
}
private static func deactivateAudioSession() {
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
#endif
}
@@ -26,30 +26,56 @@ protocol VoiceCaptureSession: AnyObject {
/// nothing valid was captured.
func finish() async -> URL?
func cancel() async
/// Stops capture and suppresses every later send before returning.
func panicCancelSynchronously()
}
/// The classic record-then-send backend, wrapping the shared `VoiceRecorder`.
@MainActor
final class VoiceNoteCaptureSession: VoiceCaptureSession {
private let recorder: VoiceRecorder
private let owner = VoiceRecorder.RecordingOwner()
var isLive: Bool { false }
init(recorder: VoiceRecorder = .shared) {
self.recorder = recorder
}
func requestPermission() async -> Bool {
await VoiceRecorder.shared.requestPermission()
await recorder.requestPermission()
}
func start() async throws {
try await VoiceRecorder.shared.startRecording()
try await recorder.startRecording(owner: owner)
}
func finish() async -> URL? {
await VoiceRecorder.shared.stopRecording()
await recorder.stopRecording(owner: owner)
}
func cancel() async {
await VoiceRecorder.shared.cancelRecording()
await recorder.cancelRecording(owner: owner)
}
func panicCancelSynchronously() {
recorder.panicCancelSynchronously(owner: owner)
}
}
/// Testable surface of the live capture engine. Production uses
/// `PTTCaptureEngine`; tests can supply captured-frame counts without opening
/// real audio hardware.
@MainActor
protocol PTTCapturing: AnyObject {
var onFrames: (([Data]) -> Void)? { get set }
func start(outputURL: URL) async throws
func stop() -> (url: URL?, encodedFrames: Int)
func cancel()
}
extension PTTCaptureEngine: PTTCapturing {}
/// Live push-to-talk backend: streams `VoiceBurstPacket`s to one peer while
/// recording, then finalizes the same audio as a standard voice note whose
/// file name carries the burst ID (`voice_<burstID>.m4a`) so receivers that
@@ -60,7 +86,8 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
let burstID: Data
private let sendPacket: (Data) -> Void
private let capture = PTTCaptureEngine()
private let capture: any PTTCapturing
private let now: () -> Date
/// Capture-queue-confined stream state: packetizes frames and lazily
/// emits START so packet order is guaranteed by queue serialization.
private final class StreamState {
@@ -79,10 +106,17 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
/// - Parameter sendPacket: delivers one encoded `VoiceBurstPacket` to the
/// target peer; must be safe to call from any queue (BLEService hops to
/// its own message queue internally).
init(sendPacket: @escaping (Data) -> Void) {
self.burstID = VoiceBurstPacket.makeBurstID()
init(
sendPacket: @escaping (Data) -> Void,
capture: (any PTTCapturing)? = nil,
now: @escaping () -> Date = Date.init,
burstID: Data? = nil
) {
self.burstID = burstID ?? VoiceBurstPacket.makeBurstID()
self.sendPacket = sendPacket
self.stream = StreamState(burstID: burstID)
self.capture = capture ?? PTTCaptureEngine()
self.now = now
self.stream = StreamState(burstID: self.burstID)
}
func requestPermission() async -> Bool {
@@ -117,16 +151,31 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
}
}
do {
try capture.start(outputURL: outputURL)
try await capture.start(outputURL: outputURL)
} catch is CancellationError {
// The hold was released/canceled while the session acquire was
// in flight: the engine never started and the capture already
// handed its token back nothing to retry. A coordinator-side
// interruption during handoff also cancels acquire, but that is
// not a successful start and must propagate to the view model.
guard completed else { throw CancellationError() }
return
} catch {
// The HAL can briefly report a dead input right after the audio
// session (re)activates while the route settles; one retry after
// a short pause covers it (observed on iPhone field tests).
SecureLogger.warning("PTT: capture start failed (\(error)) — retrying once after route settle", category: .session)
try? await Task.sleep(nanoseconds: 150_000_000)
try capture.start(outputURL: outputURL)
// The hold may have been released/canceled during the retry pause.
// Starting the mic now would leave it live and streaming after the
// user let go, so bail instead of opening a hot mic.
guard !completed else {
capture.cancel()
return
}
try await capture.start(outputURL: outputURL)
}
startDate = Date()
startDate = now()
SecureLogger.info("PTT: live burst \(burstID.hexEncodedString()) capture started", category: .session)
}
@@ -134,11 +183,16 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
guard !completed else { return nil }
completed = true
let elapsed = startDate.map { Date().timeIntervalSince($0) } ?? 0
let elapsed = startDate.map { now().timeIntervalSince($0) } ?? 0
let (url, encodedFrames) = capture.stop()
// stop() drained the capture queue, so touching `stream` is safe now.
guard elapsed >= VoiceRecorder.minRecordingDuration, let url else {
let capturedDuration = Double(encodedFrames) * PTTAudioFormat.frameDuration
guard elapsed >= VoiceRecorder.minRecordingDuration,
capturedDuration >= VoiceRecorder.minRecordingDuration,
let url
else {
sendControlPacket(.canceled)
if let url {
try? FileManager.default.removeItem(at: url)
@@ -149,17 +203,30 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
for packet in stream.packetizer.flush() {
sendPacket(packet)
}
let durationMs = UInt32((Double(encodedFrames) * PTTAudioFormat.frameDuration * 1000).rounded())
let durationMs = UInt32((capturedDuration * 1000).rounded())
sendControlPacket(.end(totalDataPackets: stream.packetizer.dataPacketCount, durationMs: durationMs))
SecureLogger.info("PTT: live burst \(burstID.hexEncodedString()) finished — \(stream.packetizer.dataPacketCount) data packets, \(encodedFrames) frames, \(durationMs) ms", category: .session)
return url
}
func cancel() async {
guard !completed else { return }
let alreadyCompleted = completed
completed = true
// Always tear down the capture, even if a quick-release already marked
// us completed: the engine can start late (during start()'s retry
// pause), and only capture.cancel() stops the mic and deactivates the
// session. It is idempotent, so a redundant call is harmless.
capture.cancel()
if !alreadyCompleted {
sendControlPacket(.canceled)
}
}
func panicCancelSynchronously() {
// Do not emit a canceled packet: it would itself be pre-panic
// conversation data racing the emergency transport reset.
completed = true
capture.cancel()
sendControlPacket(.canceled)
}
private func sendControlPacket(_ kind: VoiceBurstPacket.Kind) {
@@ -9,6 +9,9 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
@Published private(set) var duration: TimeInterval = 0
@Published private(set) var progress: Double = 0
/// Internal lifecycle visibility for deterministic acquisition tests.
var isPlaybackStartPending: Bool { sessionAcquireInFlight }
/// rounded so 4.9s shows "00:05"
var roundedDuration: Int {
guard duration.isFinite else { return 0 }
@@ -24,9 +27,24 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
private var player: AVAudioPlayer?
private var timer: Timer?
private var url: URL
/// Test seam; `AudioSessionCoordinator.shared` when nil.
private let sessionCoordinatorOverride: AudioSessionCoordinator?
/// Injectable so tests don't fight over the app-wide exclusive-playback
/// slot (a parallel test's `play()` would pause this controller mid-test).
private let exclusivity: VoiceNotePlaybackCoordinator
private var sessionToken: AudioSessionCoordinator.Token?
/// A session acquire is in flight (it suspends off-main for the blocking
/// session IPC); gates against double acquisition on rapid play taps.
private var sessionAcquireInFlight = false
init(url: URL) {
init(
url: URL,
sessionCoordinator: AudioSessionCoordinator? = nil,
exclusivity: VoiceNotePlaybackCoordinator? = nil
) {
self.url = url
self.sessionCoordinatorOverride = sessionCoordinator
self.exclusivity = exclusivity ?? .shared
super.init()
// Don't load anything eagerly - wait until user interaction or view is fully displayed
}
@@ -51,6 +69,16 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
deinit {
timer?.invalidate()
player?.stop()
// A per-row @StateObject can be discarded mid-playback (navigating
// away). Leaking the token here would hold the session forever
// never deactivating it, and pinning any escalated category for the
// app's lifetime. `release` is fire-and-forget onto the coordinator's
// queue, so it is deinit-safe: only the Sendable token crosses.
if let token = sessionToken {
sessionToken = nil
(sessionCoordinatorOverride ?? .shared).release(token)
}
}
func replaceURL(_ url: URL) {
@@ -68,11 +96,15 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
func play() {
guard ensurePlayerReady() else { return }
VoiceNotePlaybackCoordinator.shared.activate(self)
player?.play()
exclusivity.activate(self)
isPlaying = true
startTimer()
updateProgress()
isPlaying = true
// Acquired here (not in ensurePlayerReady): scrubbing a paused note
// must not hold the session while nothing is audible. The session
// calls block on audio-server IPC, so they run off the main thread;
// the player starts once the session is configured.
startPlayerAfterAcquiringSession()
}
func pause() {
@@ -80,6 +112,7 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
stopTimer()
updateProgress()
isPlaying = false
releaseSession()
}
func stop() {
@@ -88,7 +121,8 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
stopTimer()
updateProgress()
isPlaying = false
VoiceNotePlaybackCoordinator.shared.deactivate(self)
releaseSession()
exclusivity.deactivate(self)
}
func seek(to fraction: Double) {
@@ -96,8 +130,11 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
let clamped = max(0, min(1, fraction))
if let player = player {
player.currentTime = clamped * player.duration
if isPlaying {
player.play()
// While the session acquire is still in flight, don't start
// audio pre-activation the pending acquire's completion starts
// playback (from the new position) once the session resolves.
if isPlaying, !sessionAcquireInFlight {
startPreparedPlayer()
}
updateProgress()
}
@@ -112,18 +149,20 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
self.stopTimer()
self.updateProgress()
self.isPlaying = false
VoiceNotePlaybackCoordinator.shared.deactivate(self)
self.releaseSession()
self.exclusivity.deactivate(self)
}
}
// MARK: - Private Helpers
private func preparePlayer(for url: URL) {
// Prepare player synchronously (only called when playback is requested)
// Load metadata synchronously, but do not call prepareToPlay here:
// paused scrubbing reaches this path and must not acquire playback
// hardware outside the AudioSessionCoordinator token lifetime.
do {
let player = try AVAudioPlayer(contentsOf: url)
player.delegate = self
player.prepareToPlay()
self.player = player
duration = player.duration
currentTime = player.currentTime
@@ -141,18 +180,81 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
if player == nil {
preparePlayer(for: url)
}
#if os(iOS)
let session = AVAudioSession.sharedInstance()
do {
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
try session.setActive(true, options: [])
} catch {
SecureLogger.error("Failed to activate audio session: \(error)", category: .session)
}
#endif
return player != nil
}
/// All entry points (SwiftUI actions, `pauseForExclusivity`, the
/// delegate's main-queue hop) run on the main thread; the acquire itself
/// suspends while the blocking session IPC runs on the coordinator's
/// queue, and the player starts when it resolves. An acquire failure
/// leaves playback stopped: starting without a registered token would
/// bypass interruption fan-out and the coordinator's refcount. A
/// pause/stop landing mid-acquire hands the token straight back.
private func startPlayerAfterAcquiringSession() {
if sessionToken != nil {
startPreparedPlayer()
return
}
guard !sessionAcquireInFlight else { return }
sessionAcquireInFlight = true
let coordinator = sessionCoordinatorOverride ?? AudioSessionCoordinator.shared
Task { @MainActor [weak self] in
var token: AudioSessionCoordinator.Token?
do {
token = try await coordinator.acquire(.playback) { [weak self] in
self?.pause()
}
} catch {
SecureLogger.error("Failed to activate audio session: \(error)", category: .session)
}
guard let self else {
// The row was discarded while acquiring; deinit had no token
// to release yet.
token.map(coordinator.release)
return
}
self.sessionAcquireInFlight = false
guard self.isPlaying else {
// Paused/stopped while the session was activating.
token.map(coordinator.release)
return
}
guard let token else {
self.failPlaybackStart()
return
}
self.sessionToken = token
self.startPreparedPlayer()
}
}
@discardableResult
private func startPreparedPlayer() -> Bool {
guard let player,
player.prepareToPlay(),
player.play()
else {
SecureLogger.error("Voice note player refused to start " + url.lastPathComponent, category: .session)
failPlaybackStart()
return false
}
return true
}
private func failPlaybackStart() {
player?.pause()
stopTimer()
updateProgress()
isPlaying = false
releaseSession()
exclusivity.deactivate(self)
}
private func releaseSession() {
sessionToken.map((sessionCoordinatorOverride ?? .shared).release)
sessionToken = nil
}
private func startTimer() {
if timer != nil { return }
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
@@ -197,21 +299,59 @@ extension VoiceNotePlaybackController: ExclusivePlayback {
final class VoiceNotePlaybackCoordinator {
static let shared = VoiceNotePlaybackCoordinator()
struct Reservation: Equatable {
fileprivate let generation: UInt64
}
private weak var activeController: (any ExclusivePlayback)?
private weak var latestReservedController: (any ExclusivePlayback)?
private var latestReservation = Reservation(generation: 0)
private init() {}
/// Internal so tests can isolate their own exclusivity slot; the app
/// uses `shared`.
init() {}
func activate(_ controller: any ExclusivePlayback) {
/// Records playback intent without interrupting audio that is already
/// audible. Async starters reserve before suspension, then activate only
/// after their audio resource is ready.
func reserve(_ controller: any ExclusivePlayback) -> Reservation {
latestReservation = Reservation(generation: latestReservation.generation &+ 1)
latestReservedController = controller
return latestReservation
}
/// Immediate activation for synchronous/user-initiated playback.
@discardableResult
func activate(_ controller: any ExclusivePlayback) -> Reservation {
let reservation = reserve(controller)
_ = activate(controller, reservation: reservation)
return reservation
}
/// Commits an earlier reservation only when it is still the newest
/// playback request. This prevents an older async acquire from stealing
/// the floor after a newer play gesture.
@discardableResult
func activate(_ controller: any ExclusivePlayback, reservation: Reservation) -> Bool {
guard isCurrent(reservation, for: controller) else { return false }
if activeController === controller {
return
return true
}
activeController?.pauseForExclusivity()
activeController = controller
return true
}
func isCurrent(_ reservation: Reservation, for controller: any ExclusivePlayback) -> Bool {
latestReservation == reservation && latestReservedController === controller
}
func deactivate(_ controller: any ExclusivePlayback) {
if activeController === controller {
activeController = nil
}
if latestReservedController === controller {
latestReservedController = nil
}
}
}
+233 -63
View File
@@ -1,21 +1,95 @@
import Foundation
import AVFoundation
/// The small surface of `AVAudioRecorder` that `VoiceRecorder` owns. Keeping
/// it behind a protocol lets lifecycle races be tested without opening the
/// microphone on the test host.
protocol VoiceAudioRecording: AnyObject {
var isRecording: Bool { get }
var isMeteringEnabled: Bool { get set }
func prepareToRecord() -> Bool
func record(forDuration duration: TimeInterval) -> Bool
func stop()
}
extension AVAudioRecorder: VoiceAudioRecording {}
protocol VoiceAudioRecorderCreating {
func makeRecorder(url: URL) throws -> any VoiceAudioRecording
}
private struct SystemVoiceAudioRecorderFactory: VoiceAudioRecorderCreating {
func makeRecorder(url: URL) throws -> any VoiceAudioRecording {
let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 16_000,
AVNumberOfChannelsKey: 1,
AVEncoderBitRateKey: 16_000
]
return try AVAudioRecorder(url: url, settings: settings)
}
}
/// Manages audio capture for mesh voice notes with predictable encoding settings.
actor VoiceRecorder {
enum RecorderError: Error {
enum RecorderError: Error, Equatable {
case microphoneAccessDenied
case recordingInProgress
case failedToStartRecording
}
static let shared = VoiceRecorder()
private let paddingInterval: TimeInterval = 0.5
private let maxRecordingDuration: TimeInterval = 120
static let minRecordingDuration: TimeInterval = 1
private var recorder: AVAudioRecorder?
/// Identity of one press/hold. Every lifecycle mutation must present the
/// same owner that started the recorder, so a stale finish or cancel from
/// another hold cannot stop or delete the current recording.
final class RecordingOwner: @unchecked Sendable {}
/// Test-only scheduling seams for lifecycle boundaries that otherwise rely
/// on wall-clock sleeps. Production uses the real padding delay.
struct TestingHooks: Sendable {
let waitForStopPadding: (@Sendable (TimeInterval) async -> Void)?
init(waitForStopPadding: (@Sendable (TimeInterval) async -> Void)? = nil) {
self.waitForStopPadding = waitForStopPadding
}
}
private let sessionCoordinator: AudioSessionCoordinator
private let recorderFactory: any VoiceAudioRecorderCreating
private let permissionGranted: () -> Bool
private let paddingInterval: TimeInterval
private let maxRecordingDuration: TimeInterval
private let outputDirectory: URL?
private let testingHooks: TestingHooks
private var recorder: (any VoiceAudioRecording)?
private var currentURL: URL?
private var sessionToken: AudioSessionCoordinator.Token?
private var activeOwner: RecordingOwner?
/// True only while `startRecording()` is suspended in session acquire.
/// A second start is rejected instead of superseding the first one.
private var startInFlight = false
init(
sessionCoordinator: AudioSessionCoordinator = .shared,
recorderFactory: any VoiceAudioRecorderCreating = SystemVoiceAudioRecorderFactory(),
permissionGranted: (() -> Bool)? = nil,
paddingInterval: TimeInterval = 0.5,
maxRecordingDuration: TimeInterval = 120,
outputDirectory: URL? = nil,
testingHooks: TestingHooks = TestingHooks()
) {
self.sessionCoordinator = sessionCoordinator
self.recorderFactory = recorderFactory
self.permissionGranted = permissionGranted ?? Self.hasSystemPermission
self.paddingInterval = paddingInterval
self.maxRecordingDuration = maxRecordingDuration
self.outputDirectory = outputDirectory
self.testingHooks = testingHooks
}
// MARK: - Permissions
@@ -41,82 +115,130 @@ actor VoiceRecorder {
// MARK: - Recording Lifecycle
@discardableResult
func startRecording() throws -> URL {
if recorder?.isRecording == true {
func startRecording(owner: RecordingOwner) async throws -> URL {
if activeOwner != nil {
throw RecorderError.recordingInProgress
}
#if os(iOS)
let session = AVAudioSession.sharedInstance()
guard session.recordPermission == .granted else {
guard permissionGranted() else {
throw RecorderError.microphoneAccessDenied
}
#if targetEnvironment(simulator)
// allowBluetoothHFP is not available on iOS Simulator
try session.setCategory(
.playAndRecord,
mode: .default,
options: [.defaultToSpeaker, .allowBluetoothA2DP]
)
#else
try session.setCategory(
.playAndRecord,
mode: .default,
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
)
#endif
try session.setActive(true, options: .notifyOthersOnDeactivation)
#endif
#if os(macOS)
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
throw RecorderError.microphoneAccessDenied
activeOwner = owner
startInFlight = true
// The acquire suspends while the blocking session IPC runs on the
// coordinator's queue (never this actor's thread or main).
let token: AudioSessionCoordinator.Token
do {
token = try await sessionCoordinator.acquire(.capture) { [weak self] in
Task { await self?.handleSessionInterruption(for: owner) }
}
} catch {
guard activeOwner === owner else {
throw CancellationError()
}
startInFlight = false
activeOwner = nil
throw error
}
#endif
let outputURL = try makeOutputURL()
let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 16_000,
AVNumberOfChannelsKey: 1,
AVEncoderBitRateKey: 16_000
]
// Actor reentrancy: release/cancel may have ended this hold while the
// blocking session activation was still in progress.
guard activeOwner === owner, startInFlight else {
sessionCoordinator.release(token)
throw CancellationError()
}
startInFlight = false
sessionToken = token
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
audioRecorder.isMeteringEnabled = true
audioRecorder.prepareToRecord()
audioRecorder.record(forDuration: maxRecordingDuration)
var outputURL: URL?
do {
let newURL = try makeOutputURL()
outputURL = newURL
let audioRecorder = try recorderFactory.makeRecorder(url: newURL)
audioRecorder.isMeteringEnabled = true
guard audioRecorder.prepareToRecord() else {
throw RecorderError.failedToStartRecording
}
guard audioRecorder.record(forDuration: maxRecordingDuration) else {
throw RecorderError.failedToStartRecording
}
recorder = audioRecorder
currentURL = outputURL
return outputURL
recorder = audioRecorder
currentURL = newURL
return newURL
} catch {
releaseSessionToken()
recorder = nil
currentURL = nil
activeOwner = nil
if let outputURL {
try? FileManager.default.removeItem(at: outputURL)
}
throw error
}
}
func stopRecording() async -> URL? {
guard let recorder, recorder.isRecording else {
return currentURL
func stopRecording(owner: RecordingOwner) async -> URL? {
guard activeOwner === owner else { return nil }
// `finish()` can race a still-suspended start on a direct caller even
// though the UI normally routes quick releases through cancel().
if startInFlight {
activeOwner = nil
startInFlight = false
return nil
}
guard let activeRecorder = recorder else {
let sessionURL = currentURL
releaseSessionToken()
currentURL = nil
activeOwner = nil
return sessionURL
}
let sessionURL = currentURL
try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000))
recorder.stop()
// A new session may have started during the sleep don't touch its state
if self.recorder === recorder {
cleanupSession()
self.recorder = nil
currentURL = nil
if activeRecorder.isRecording, paddingInterval > 0 {
if let waitForStopPadding = testingHooks.waitForStopPadding {
await waitForStopPadding(paddingInterval)
} else {
try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000))
}
}
// Cancellation or interruption may have run during the padding sleep.
// Only the recorder whose stop began here may be finalized by it.
guard activeOwner === owner,
let recorder = self.recorder,
recorder === activeRecorder
else { return nil }
if activeRecorder.isRecording {
activeRecorder.stop()
}
releaseSessionToken()
self.recorder = nil
currentURL = nil
activeOwner = nil
return sessionURL
}
func cancelRecording() {
func cancelRecording(owner: RecordingOwner) async {
guard activeOwner === owner else { return }
// Invalidate ownership before cleanup. An actor-reentrant start whose
// session acquire resumes later will observe the mismatch and release
// its token without opening the microphone.
activeOwner = nil
startInFlight = false
if let recorder, recorder.isRecording {
recorder.stop()
}
cleanupSession()
releaseSessionToken()
if let currentURL {
try? FileManager.default.removeItem(at: currentURL)
}
@@ -124,14 +246,60 @@ actor VoiceRecorder {
currentURL = nil
}
/// Panic is a synchronous security boundary: the caller must know the
/// microphone, audio-session lease, and partial file are gone before it
/// rotates identities or deletes the media tree. VoiceRecorder is an
/// independent actor and this cleanup path never hops to MainActor, so a
/// short semaphore join is safe even when invoked by the UI actor.
nonisolated
func panicCancelSynchronously(owner: RecordingOwner) {
let finished = DispatchSemaphore(value: 0)
Task {
await cancelRecording(owner: owner)
finished.signal()
}
finished.wait()
}
/// The audio session was interrupted (call, Siri) or reconfigured: stop
/// the recorder but keep `recorder`/`currentURL` so the caller's pending
/// `stopRecording()` still returns the partial note.
private func handleSessionInterruption(for owner: RecordingOwner) async {
// A callback captured for a released token must never stop a newer
// recording. Conversely, an interruption delivered while acquire is
// still suspended invalidates that acquire before it can open the mic.
guard activeOwner === owner else { return }
if startInFlight {
activeOwner = nil
startInFlight = false
return
}
startInFlight = false
if let recorder, recorder.isRecording {
recorder.stop()
}
releaseSessionToken()
}
// MARK: - Helpers
private static func hasSystemPermission() -> Bool {
#if os(iOS)
AVAudioSession.sharedInstance().recordPermission == .granted
#elseif os(macOS)
AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
#else
true
#endif
}
private func makeOutputURL() throws -> URL {
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd_HHmmss"
let fileName = "voice_\(formatter.string(from: Date())).m4a"
let fileName = "voice_\(formatter.string(from: Date()))_\(UUID().uuidString).m4a"
let baseDirectory = try applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true)
let baseDirectory = try outputDirectory
?? applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true)
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: nil)
return baseDirectory.appendingPathComponent(fileName)
}
@@ -146,9 +314,11 @@ actor VoiceRecorder {
#endif
}
private func cleanupSession() {
#if os(iOS)
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
#endif
/// Fire-and-forget: the coordinator hops the blocking deactivation IPC
/// onto its own queue.
private func releaseSessionToken() {
guard let token = sessionToken else { return }
sessionToken = nil
sessionCoordinator.release(token)
}
}
+12
View File
@@ -189,6 +189,18 @@ struct IdentityCache: Codable {
// Fingerprint -> when we verified it (orders outgoing vouch batches;
// entries verified before this field exists sort as oldest)
var verifiedAt: [String: Date]? = nil
// Stable Noise fingerprints that proved encrypted private-media support
// inside an authenticated Noise session. Optional for decoding caches
// written before this migration. Entries are monotonic until a panic wipe
// so an old/replayed announce cannot silently downgrade a peer.
var privateMediaCapableFingerprints: Set<String>? = nil
// Noise-fingerprint -> Ed25519 announcement key, learned only from the
// authenticated peer-state payload. This prevents a self-signed announce
// containing a copied public Noise key from replacing a previously bound
// public-message signing identity. Optional for old cache compatibility.
var authenticatedSigningKeysByFingerprint: [String: Data]? = nil
}
//
@@ -140,6 +140,14 @@ protocol SecureIdentityStateManagerProtocol {
func markVouchBatchSent(to fingerprint: String, at date: Date)
func signingPublicKey(forFingerprint fingerprint: String) -> Data?
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String]
// MARK: Noise-authenticated announcement identity
func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String)
func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data?
// MARK: Private-media downgrade protection
func markPrivateMediaCapable(fingerprint: String)
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool
}
/// Singleton manager for secure identity state persistence and retrieval.
@@ -157,6 +165,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
// Thread safety
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
private let queueSpecificKey = DispatchSpecificKey<UInt8>()
// Pending-save coalescing flag. Reads/writes are serialized on `queue`.
// Persistence is done with a fire-and-forget `queue.async(.barrier)` rather
@@ -214,6 +223,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
self.encryptionKey = loadedKey
self.encryptionKeyIsEphemeral = keyIsEphemeral
queue.setSpecific(key: queueSpecificKey, value: 1)
// Only read the persisted cache when we hold the real key; with an
// ephemeral key the decrypt would fail and discard the real cache.
@@ -371,6 +381,66 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
}
}
// MARK: - Private-media downgrade protection
func markPrivateMediaCapable(fingerprint: String) {
guard !fingerprint.isEmpty else { return }
let insertAndPersist = {
var pinned = self.cache.privateMediaCapableFingerprints ?? []
guard pinned.insert(fingerprint).inserted else { return }
self.cache.privateMediaCapableFingerprints = pinned
self.saveIdentityCache()
}
// Downgrade decisions can run immediately after an authenticated
// announce. Make the pin visible before returning; merely enqueueing a
// barrier leaves a cross-queue window where a replay can look legacy.
// The queue-specific fast path prevents self-deadlock if a future
// identity-state mutation records the capability from inside `queue`.
if DispatchQueue.getSpecific(key: queueSpecificKey) != nil {
insertAndPersist()
} else {
queue.sync(flags: .barrier, execute: insertAndPersist)
}
}
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool {
guard !fingerprint.isEmpty else { return false }
return queue.sync {
cache.privateMediaCapableFingerprints?.contains(fingerprint) == true
}
}
// MARK: - Noise-authenticated announcement identity
func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String) {
guard signingPublicKey.count == AuthenticatedPeerStatePacket.signingPublicKeyLength,
!fingerprint.isEmpty else { return }
let bindAndPersist = {
var bindings = self.cache.authenticatedSigningKeysByFingerprint ?? [:]
let bindingChanged = bindings[fingerprint] != signingPublicKey
bindings[fingerprint] = signingPublicKey
self.cache.authenticatedSigningKeysByFingerprint = bindings
if var cryptoIdentity = self.cryptographicIdentities[fingerprint] {
cryptoIdentity.signingPublicKey = signingPublicKey
self.cryptographicIdentities[fingerprint] = cryptoIdentity
}
guard bindingChanged else { return }
self.saveIdentityCache()
}
if DispatchQueue.getSpecific(key: queueSpecificKey) != nil {
bindAndPersist()
} else {
queue.sync(flags: .barrier, execute: bindAndPersist)
}
}
func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data? {
guard !fingerprint.isEmpty else { return nil }
return queue.sync {
cache.authenticatedSigningKeysByFingerprint?[fingerprint]
}
}
func updateSocialIdentity(_ identity: SocialIdentity) {
queue.async(flags: .barrier) {
let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname
+4 -2
View File
@@ -31,6 +31,8 @@
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.social-networking</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSBluetoothAlwaysUsageDescription</key>
@@ -40,9 +42,9 @@
<key>NSCameraUsageDescription</key>
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
<string>bitchat uses your location to compute optional geohash channels, bridge cells, and nearby place labels. Exact coordinates are not included in bitchat messages.</string>
<key>NSMicrophoneUsageDescription</key>
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
<string>bitchat uses the microphone while you record voice notes or hold live push-to-talk, then sends that audio to your selected mesh conversation.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
<key>UIBackgroundModes</key>
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -30,7 +30,7 @@ struct NoisePayload {
// Safely get the first byte
let firstByte = data[data.startIndex]
guard let type = NoisePayloadType(rawValue: firstByte) else {
guard let type = NoisePayloadType.decoded(rawValue: firstByte) else {
return nil
}
@@ -6,15 +6,61 @@
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Foundation
enum NoiseSecurityConstants {
// Maximum message size to prevent memory exhaustion
static let maxMessageSize = 65535 // 64KB as per Noise spec
/// The extracted transport nonce (4 bytes) and Poly1305 tag (16 bytes)
/// added by `NoiseCipherState` around every transport plaintext.
static let transportCiphertextOverhead = 20
/// Private files are an explicit BitChat extension to the ordinary Noise
/// message-size ceiling. They remain bounded by the same framed-file cap
/// used by the binary and fragment decoders. Only the `.privateFile`
/// typed-payload path is allowed to use this larger budget.
private static let privateFileOuterPacketOverhead =
(BinaryProtocol.v1HeaderSize + 2) // v2 adds two length bytes
+ BinaryProtocol.senderIDSize
+ BinaryProtocol.recipientIDSize
static let maxPrivateFilePlaintextSize = FileTransferLimits.maxFramedFileBytes
- privateFileOuterPacketOverhead
- transportCiphertextOverhead
static let maxPrivateFileCiphertextSize =
maxPrivateFilePlaintextSize + transportCiphertextOverhead
// Maximum handshake message size
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
// Noise XX message 1 contains only the initiator's 32-byte ephemeral key.
static let xxInitialMessageSize = 32
// Bounds an ordinary initiator whose message 1 or 2 is lost.
static let ordinaryHandshakeTimeout: TimeInterval = 10
// Bounds the receive-only rollback quarantine created by an unauthenticated
// inbound message 1. A lost message 3 must not strand outbound traffic.
static let ordinaryResponderHandshakeTimeout: TimeInterval = 20
// A released client may immediately retry after both crossed initiators
// yielded. Give that unilateral retry a brief head start before the
// patched side spends its one bounded recovery.
static let handshakeCollisionRecoveryDelay: TimeInterval = 0.2
// Rate-limited recovery remains actionable without spinning.
static let handshakeRateLimitRecoveryDelay: TimeInterval = 60
// Covers only reordering between a winning message 3 and the losing
// crossed message 1.
static let recentInitiatorCompletionGracePeriod: TimeInterval = 1
// After unauthenticated responder rollback, reject another attempt long
// enough that paced message 1 traffic cannot keep outbound paused. A
// legitimate peer converges through the one manager-owned local retry.
static let ordinaryReconnectRollbackCooldown: TimeInterval = 60
// Session timeout - sessions older than this should be renegotiated
static let sessionTimeout: TimeInterval = 86400 // 24 hours
@@ -15,6 +15,19 @@ struct NoiseSecurityValidator {
return data.count <= NoiseSecurityConstants.maxMessageSize
}
static func validateCiphertextSize(_ data: Data) -> Bool {
data.count <= NoiseSecurityConstants.maxMessageSize
+ NoiseSecurityConstants.transportCiphertextOverhead
}
static func validatePrivateFileMessageSize(_ data: Data) -> Bool {
data.count <= NoiseSecurityConstants.maxPrivateFilePlaintextSize
}
static func validatePrivateFileCiphertextSize(_ data: Data) -> Bool {
data.count <= NoiseSecurityConstants.maxPrivateFileCiphertextSize
}
/// Validate handshake message size
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
+4 -1
View File
@@ -66,7 +66,10 @@ class NoiseSession {
// Only initiator writes the first message
if role == .initiator {
let message = try handshakeState!.writeMessage()
guard let handshake = handshakeState else {
throw NoiseSessionError.invalidState
}
let message = try handshake.writeMessage()
sentHandshakeMessages.append(message)
return message
} else {
+7
View File
@@ -11,4 +11,11 @@ enum NoiseSessionError: Error, Equatable {
case notEstablished
case sessionNotFound
case alreadyEstablished
case peerIdentityMismatch
}
/// The manager owns the exact attempt's one bounded recovery. Packet handling
/// must not launch its historical second, immediate restart for this failure.
struct NoiseManagedHandshakeFailure: Error {
let underlying: Error
}
File diff suppressed because it is too large Load Diff
+11 -4
View File
@@ -24,8 +24,12 @@ final class SecureNoiseSession: NoiseSession {
throw NoiseSecurityError.sessionExhausted
}
// Validate message size
guard NoiseSecurityValidator.validateMessageSize(plaintext) else {
// Ordinary Noise messages keep the protocol ceiling. Finalized media
// is the sole typed-payload extension and remains under the framed-file
// cap enforced again at the service and file-decoder layers.
let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: plaintext.first)
&& NoiseSecurityValidator.validatePrivateFileMessageSize(plaintext)
guard NoiseSecurityValidator.validateMessageSize(plaintext) || isPrivateFile else {
throw NoiseSecurityError.messageTooLarge
}
@@ -42,8 +46,11 @@ final class SecureNoiseSession: NoiseSession {
throw NoiseSecurityError.sessionExpired
}
// Validate message size
guard NoiseSecurityValidator.validateMessageSize(ciphertext) else {
// The payload type is encrypted, so a large candidate can only be
// bounded here; `NoiseEncryptionService.decrypt` authenticates it and
// then requires the resulting type to be `.privateFile`.
guard NoiseSecurityValidator.validateCiphertextSize(ciphertext)
|| NoiseSecurityValidator.validatePrivateFileCiphertextSize(ciphertext) else {
throw NoiseSecurityError.messageTooLarge
}
+2 -1
View File
@@ -1,7 +1,8 @@
import Foundation
import P256K
/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
/// Manages the secp256k1 identity used by BitChat's Nostr relay features,
/// including the proprietary private-envelope transport.
struct NostrIdentity: Codable {
let privateKey: Data
let publicKey: Data
+459 -220
View File
@@ -1,4 +1,3 @@
import BitLogger
import Foundation
import CryptoKit
import P256K
@@ -7,16 +6,31 @@ import Security
// Note: This file depends on Data extension from BinaryEncodingUtils.swift
// Make sure BinaryEncodingUtils.swift is included in the target
/// NIP-17 Protocol Implementation for Private Direct Messages
/// BitChat's private-envelope protocol transported over Nostr relays.
///
/// This is deliberately BitChat-specific and is not NIP-17, NIP-44, or NIP-59.
/// It uses Nostr events and secp256k1 identities, but its XChaCha20-Poly1305
/// payload layout is proprietary and interoperates only with BitChat clients.
struct NostrProtocol {
/// Nostr event kinds
enum EventKind: Int {
case metadata = 0
case textNote = 1
case dm = 14 // NIP-17 DM rumor kind
case seal = 13 // NIP-17 sealed event
case giftWrap = 1059 // NIP-59 gift wrap
// Compatibility for BitChat releases that incorrectly emitted the
// proprietary payload under standard NIP kinds. Kind 1059 continues
// to be published and read until a coordinated cross-platform release
// explicitly removes it; all three legacy layers remain readable.
case legacyNIP59Seal = 13
case legacyNIP17DirectMessage = 14
case legacyNIP59GiftWrap = 1059
// Provisional BitChat-specific regular event kinds. These are not
// formally reserved by the Nostr kind registry. Only
// `privateEnvelope` is published; message and seal exist solely
// inside ciphertext.
case privateEnvelope = 1402
case privateSeal = 1403
case privateMessage = 1404
case ephemeralEvent = 20000
case geohashPresence = 20001
case deletion = 5 // NIP-09 event deletion request
@@ -26,144 +40,285 @@ struct NostrProtocol {
case courierDrop = 1401
}
/// Create a NIP-17 private message
static func createPrivateMessage(
/// Prefix for BitChat private-envelope ciphertext. The suffix is
/// base64url(nonce24 || ciphertext || poly1305Tag).
static let privateEnvelopeContentPrefix = "bitchat-pm-v1:"
/// Bound work before Base64 decoding either encrypted layer. Current
/// private messages are normally only a few KiB; 64 KiB leaves ample
/// migration headroom without allowing an addressed relay event to drive
/// unbounded allocation.
static let maximumPrivateEnvelopeCiphertextBytes = 64 * 1024
/// Bound the inner authenticated message JSON before allocation/parsing.
static let maximumPrivateEnvelopePlaintextBytes = 32 * 1024
/// The outer authenticated seal JSON contains a Base64-encoded encrypted
/// copy of the inner JSON, so it needs expansion headroom of its own. Keep
/// the layer-specific cap below the public ciphertext ceiling.
private static let maximumPrivateEnvelopeSealPlaintextBytes = 48 * 1024
/// New clients subscribe to the provisional BitChat-specific kind and the
/// compatibility legacy kind so both sides of a rolling rollout can
/// recover stored messages. Do not remove kind 1059 here until all
/// supported iOS and Android releases have migrated.
static let acceptedPrivateEnvelopeKinds = [
EventKind.privateEnvelope.rawValue,
EventKind.legacyNIP59GiftWrap.rawValue
]
private enum PrivateEnvelopeWireFormat {
case bitchatV1
case legacyMislabelledV2
init?(outerKind: Int) {
switch outerKind {
case EventKind.privateEnvelope.rawValue:
self = .bitchatV1
case EventKind.legacyNIP59GiftWrap.rawValue:
self = .legacyMislabelledV2
default:
return nil
}
}
var messageKind: EventKind {
switch self {
case .bitchatV1: .privateMessage
case .legacyMislabelledV2: .legacyNIP17DirectMessage
}
}
var sealKind: EventKind {
switch self {
case .bitchatV1: .privateSeal
case .legacyMislabelledV2: .legacyNIP59Seal
}
}
var envelopeKind: EventKind {
switch self {
case .bitchatV1: .privateEnvelope
case .legacyMislabelledV2: .legacyNIP59GiftWrap
}
}
var contentPrefix: String {
switch self {
case .bitchatV1: NostrProtocol.privateEnvelopeContentPrefix
case .legacyMislabelledV2: "v2:"
}
}
var hkdfSalt: Data {
switch self {
case .bitchatV1: Data("bitchat-private-envelope-v1".utf8)
case .legacyMislabelledV2: Data()
}
}
var hkdfInfo: Data {
switch self {
case .bitchatV1: Data()
case .legacyMislabelledV2: Data("nip44-v2".utf8)
}
}
}
/// Create a BitChat private envelope for relay transport.
static func createPrivateEnvelope(
content: String,
recipientPubkey: String,
senderIdentity: NostrIdentity
) throws -> NostrEvent {
try createPrivateEnvelope(
content: content,
recipientPubkey: recipientPubkey,
senderIdentity: senderIdentity,
format: .bitchatV1
)
}
// Creating private message
/// Events to publish for one logical private payload. The primary
/// BitChat-specific format is always first and a legacy copy follows for
/// clients that still subscribe only to kind 1059. There is deliberately
/// no date-based cutoff: removal requires a coordinated iOS/Android
/// release after supported old clients have migrated. Both encrypt the
/// exact same embedded BitChat payload, so receive-side logical-payload
/// dedup collapses the pair.
static func createPrivateEnvelopePublicationBatch(
content: String,
recipientPubkey: String,
senderIdentity: NostrIdentity
) throws -> [NostrEvent] {
let primary = try createPrivateEnvelope(
content: content,
recipientPubkey: recipientPubkey,
senderIdentity: senderIdentity
)
let compatibilityCopy = try createPrivateEnvelope(
content: content,
recipientPubkey: recipientPubkey,
senderIdentity: senderIdentity,
format: .legacyMislabelledV2
)
return [primary, compatibilityCopy]
}
// 1. Create the rumor (unsigned event)
let rumor = NostrEvent(
private static func createPrivateEnvelope(
content: String,
recipientPubkey: String,
senderIdentity: NostrIdentity,
format: PrivateEnvelopeWireFormat,
messageTags: [[String]] = []
) throws -> NostrEvent {
// 1. Create the unsigned inner BitChat message.
let message = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .dm, // NIP-17: DM rumor kind 14
tags: [],
kind: format.messageKind,
tags: messageTags,
content: content
)
// 2. Seal the rumor (encrypt to recipient) and sign it with the SENDER'S
// real identity key. NIP-17 requires the seal be signed by the sender
// so the recipient can authenticate who sent the message; signing with
// a throwaway key leaves DMs forgeable/impersonatable.
// 2. Encrypt the message to the recipient and sign the private seal
// with the sender's stable Nostr identity for sender authentication.
let senderKey = try senderIdentity.schnorrSigningKey()
let sealedEvent = try createSeal(
rumor: rumor,
let sealedEvent = try createPrivateSeal(
message: message,
recipientPubkey: recipientPubkey,
senderKey: senderKey
senderKey: senderKey,
format: format
)
// 3. Gift wrap the sealed event with a throwaway ephemeral key (the wrap
// layer hides the sender's identity from relays; createGiftWrap mints
// its own ephemeral key internally).
let giftWrap = try createGiftWrap(
// 3. Encrypt the seal under a one-time key so the public envelope does
// not reveal the stable sender identity.
return try createPrivateEnvelopeEvent(
seal: sealedEvent,
recipientPubkey: recipientPubkey
recipientPubkey: recipientPubkey,
format: format
)
// Created gift wrap
return giftWrap
}
/// Decrypt a received NIP-17 message
/// Returns the content, sender pubkey, and the actual message timestamp (not the randomized gift wrap timestamp)
static func decryptPrivateMessage(
giftWrap: NostrEvent,
/// Decrypt a BitChat private envelope. Legacy proprietary envelopes that
/// older BitChat releases placed under kinds 1059/13/14 are accepted only
/// through the format-isolated receive path.
static func decryptPrivateEnvelope(
envelope: NostrEvent,
recipientIdentity: NostrIdentity
) throws -> (content: String, senderPubkey: String, timestamp: Int) {
// Starting decryption
// 1. Unwrap the gift wrap
let seal: NostrEvent
do {
seal = try unwrapGiftWrap(
giftWrap: giftWrap,
recipientKey: recipientIdentity.schnorrSigningKey()
)
// Successfully unwrapped gift wrap
} catch {
SecureLogger.error("❌ Failed to unwrap gift wrap: \(error)", category: .session)
throw error
}
// 2. Authenticate the seal. The seal MUST be signed by the sender's real
// identity key (NIP-17); without this check a DM is forgeable by anyone
// who knows the recipient's npub. Verify the seal's own signature.
guard seal.isValidSignature() else {
SecureLogger.error("❌ Rejecting DM: seal signature is missing or invalid", category: .session)
throw NostrError.invalidEvent
}
// 3. Open the seal
let rumor: NostrEvent
do {
rumor = try openSeal(
seal: seal,
recipientKey: recipientIdentity.schnorrSigningKey()
)
// Successfully opened seal
} catch {
SecureLogger.error("❌ Failed to open seal: \(error)", category: .session)
throw error
}
// 4. The sender claimed inside the rumor must match the key that actually
// signed the seal, otherwise the sender field is unauthenticated and
// spoofable.
guard seal.pubkey == rumor.pubkey else {
SecureLogger.error("❌ Rejecting DM: rumor pubkey does not match seal signer", category: .session)
throw NostrError.invalidEvent
}
// Return the seal signer's pubkey as the authenticated sender.
return (content: rumor.content, senderPubkey: seal.pubkey, timestamp: rumor.created_at)
let layers = try decodePrivateEnvelopeLayers(
envelope: envelope,
recipientIdentity: recipientIdentity
)
return (
content: layers.message.content,
senderPubkey: layers.seal.pubkey,
timestamp: layers.message.created_at
)
}
#if DEBUG
static func createPrivateMessageWithInvalidSealSignatureForTesting(
static func createPrivateEnvelopeWithInvalidSealSignatureForTesting(
content: String,
recipientPubkey: String,
senderIdentity: NostrIdentity
) throws -> NostrEvent {
let rumor = NostrEvent(
let format = PrivateEnvelopeWireFormat.bitchatV1
let message = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .dm,
kind: format.messageKind,
tags: [],
content: content
)
var seal = try createSeal(
rumor: rumor,
var seal = try createPrivateSeal(
message: message,
recipientPubkey: recipientPubkey,
senderKey: senderIdentity.schnorrSigningKey()
senderKey: senderIdentity.schnorrSigningKey(),
format: format
)
seal.sig = String(repeating: "0", count: 128)
return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey)
return try createPrivateEnvelopeEvent(
seal: seal,
recipientPubkey: recipientPubkey,
format: format
)
}
static func createPrivateMessageWithMismatchedSealRumorPubkeyForTesting(
static func createPrivateEnvelopeWithMismatchedSealMessagePubkeyForTesting(
content: String,
recipientPubkey: String,
rumorIdentity: NostrIdentity,
messageIdentity: NostrIdentity,
sealSignerIdentity: NostrIdentity
) throws -> NostrEvent {
let rumor = NostrEvent(
pubkey: rumorIdentity.publicKeyHex,
let format = PrivateEnvelopeWireFormat.bitchatV1
let message = NostrEvent(
pubkey: messageIdentity.publicKeyHex,
createdAt: Date(),
kind: .dm,
kind: format.messageKind,
tags: [],
content: content
)
let seal = try createSeal(
rumor: rumor,
let seal = try createPrivateSeal(
message: message,
recipientPubkey: recipientPubkey,
senderKey: sealSignerIdentity.schnorrSigningKey()
senderKey: sealSignerIdentity.schnorrSigningKey(),
format: format
)
return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey)
return try createPrivateEnvelopeEvent(
seal: seal,
recipientPubkey: recipientPubkey,
format: format
)
}
static func createPrivateEnvelopeWithInnerTagsForTesting(
content: String,
recipientPubkey: String,
senderIdentity: NostrIdentity,
innerMessageTags: [[String]]
) throws -> NostrEvent {
try createPrivateEnvelope(
content: content,
recipientPubkey: recipientPubkey,
senderIdentity: senderIdentity,
format: .bitchatV1,
messageTags: innerMessageTags
)
}
static func createLegacyPrivateEnvelopeForTesting(
content: String,
recipientPubkey: String,
senderIdentity: NostrIdentity,
innerMessageTags: [[String]] = []
) throws -> NostrEvent {
// Current Android legacy envelopes use exactly one recipient `p` tag
// on the unsigned inner kind-14 event; released iOS envelopes use no
// inner tags. Tests pass the Android shape explicitly so this helper
// cannot silently make the production encoder depend on that quirk.
try createPrivateEnvelope(
content: content,
recipientPubkey: recipientPubkey,
senderIdentity: senderIdentity,
format: .legacyMislabelledV2,
messageTags: innerMessageTags
)
}
static func decodePrivateEnvelopeLayersForTesting(
envelope: NostrEvent,
recipientIdentity: NostrIdentity
) throws -> (seal: NostrEvent, message: NostrEvent) {
try decodePrivateEnvelopeLayers(
envelope: envelope,
recipientIdentity: recipientIdentity
)
}
static func decodePrivateEnvelopeEventJSONForTesting(_ json: String) throws -> NostrEvent {
try decodePrivateEnvelopeEventJSON(json)
}
#endif
@@ -264,22 +419,32 @@ struct NostrProtocol {
/// Create a mesh-bridge public message (kind 20000) for a geohash-cell
/// rendezvous. The distinct `r` tag keeps bridge traffic out of geohash
/// channel subscriptions (which filter on `#g`); `m` carries the original
/// mesh message ID so receivers dedup the bridged copy against the radio
/// copy by timeline ID.
/// channel subscriptions (which filter on `#g`); `m` is
/// `[stable ID, mesh sender ID, wire timestamp in ms]`. Element 1 is the
/// content-stable mesh message ID (`MeshMessageIdentity`) for v1.7.0
/// parsers, which key their dedup on `m[1]` unconditionally and need it
/// per-message-unique. Current parsers key bridge rows by the authenticated
/// event ID and recompute elements 2-3 only as a radio-copy hint; the mesh
/// coordinates are public and cannot authenticate the Nostr signer.
static func createBridgeMeshEvent(
content: String,
cell: String,
senderIdentity: NostrIdentity,
nickname: String? = nil,
meshMessageID: String? = nil
meshSenderID: String? = nil,
meshTimestampMs: UInt64? = nil
) throws -> NostrEvent {
var tags = [["r", cell]]
if let nickname = nickname?.trimmedOrNilIfEmpty {
tags.append(["n", nickname])
}
if let meshMessageID = meshMessageID?.trimmedOrNilIfEmpty {
tags.append(["m", meshMessageID])
if let meshSenderID = meshSenderID?.trimmedOrNilIfEmpty, let meshTimestampMs {
let stableID = MeshMessageIdentity.stableID(
senderIDHex: meshSenderID,
timestampMs: meshTimestampMs,
content: content
)
tags.append(["m", stableID, meshSenderID, String(meshTimestampMs)])
}
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
@@ -325,7 +490,7 @@ struct NostrProtocol {
) throws -> NostrEvent {
let tags = [
["x", recipientTagHex],
["expiration", String(Int(expiresAt.timeIntervalSince1970))],
["expiration", String(Int(expiresAt.timeIntervalSince1970))]
]
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
@@ -390,151 +555,207 @@ struct NostrProtocol {
// MARK: - Private Methods
private static func createSeal(
rumor: NostrEvent,
private static func createPrivateSeal(
message: NostrEvent,
recipientPubkey: String,
senderKey: P256K.Schnorr.PrivateKey
senderKey: P256K.Schnorr.PrivateKey,
format: PrivateEnvelopeWireFormat
) throws -> NostrEvent {
let rumorJSON = try rumor.jsonString()
let encrypted = try encrypt(
plaintext: rumorJSON,
plaintext: message.jsonString(),
recipientPubkey: recipientPubkey,
senderKey: senderKey
senderKey: senderKey,
format: format,
maximumPlaintextBytes: maximumPrivateEnvelopePlaintextBytes
)
let seal = NostrEvent(
pubkey: Data(senderKey.xonly.bytes).hexEncodedString(),
createdAt: randomizedTimestamp(),
kind: .seal,
createdAt: randomizedPastTimestamp(),
kind: format.sealKind,
tags: [],
content: encrypted
)
// Sign the seal with the sender's Schnorr private key
return try seal.sign(with: senderKey)
}
private static func createGiftWrap(
private static func createPrivateEnvelopeEvent(
seal: NostrEvent,
recipientPubkey: String
recipientPubkey: String,
format: PrivateEnvelopeWireFormat
) throws -> NostrEvent {
let sealJSON = try seal.jsonString()
// Create new ephemeral key for gift wrap
let wrapKey = try P256K.Schnorr.PrivateKey()
// Creating gift wrap with ephemeral key
// Encrypt the seal with the new ephemeral key (not the seal's key)
// A fresh signing/encryption key for every public envelope keeps the
// stable sender identity inside ciphertext.
let envelopeKey = try P256K.Schnorr.PrivateKey()
let encrypted = try encrypt(
plaintext: sealJSON,
plaintext: seal.jsonString(),
recipientPubkey: recipientPubkey,
senderKey: wrapKey // Use the gift wrap ephemeral key
senderKey: envelopeKey,
format: format,
maximumPlaintextBytes: maximumPrivateEnvelopeSealPlaintextBytes
)
let giftWrap = NostrEvent(
pubkey: Data(wrapKey.xonly.bytes).hexEncodedString(),
createdAt: randomizedTimestamp(),
kind: .giftWrap,
tags: [["p", recipientPubkey]], // Tag recipient
let envelope = NostrEvent(
pubkey: Data(envelopeKey.xonly.bytes).hexEncodedString(),
createdAt: randomizedPastTimestamp(),
kind: format.envelopeKind,
tags: [["p", recipientPubkey]],
content: encrypted
)
// Sign the gift wrap with the wrap Schnorr private key
return try giftWrap.sign(with: wrapKey)
return try envelope.sign(with: envelopeKey)
}
private static func unwrapGiftWrap(
giftWrap: NostrEvent,
recipientKey: P256K.Schnorr.PrivateKey
) throws -> NostrEvent {
// Unwrapping gift wrap
let decrypted = try decrypt(
ciphertext: giftWrap.content,
senderPubkey: giftWrap.pubkey,
recipientKey: recipientKey
)
guard let data = decrypted.data(using: .utf8),
let sealDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
private static func decodePrivateEnvelopeLayers(
envelope: NostrEvent,
recipientIdentity: NostrIdentity
) throws -> (seal: NostrEvent, message: NostrEvent) {
guard envelope.content.utf8.count <= maximumPrivateEnvelopeCiphertextBytes else {
throw NostrError.invalidCiphertext
}
guard let format = PrivateEnvelopeWireFormat(outerKind: envelope.kind),
envelope.tags == [["p", recipientIdentity.publicKeyHex]],
envelope.isValidSignature() else {
throw NostrError.invalidEvent
}
let seal = try NostrEvent(from: sealDict)
// Unwrapped seal
let recipientKey = try recipientIdentity.schnorrSigningKey()
let sealJSON = try decrypt(
ciphertext: envelope.content,
senderPubkey: envelope.pubkey,
recipientKey: recipientKey,
format: format,
maximumPlaintextBytes: maximumPrivateEnvelopeSealPlaintextBytes
)
let seal = try decodePrivateEnvelopeEventJSON(
sealJSON,
maximumBytes: maximumPrivateEnvelopeSealPlaintextBytes
)
guard seal.kind == format.sealKind.rawValue,
seal.tags.isEmpty,
seal.isValidSignature() else {
throw NostrError.invalidEvent
}
return seal
}
private static func openSeal(
seal: NostrEvent,
recipientKey: P256K.Schnorr.PrivateKey
) throws -> NostrEvent {
let decrypted = try decrypt(
let messageJSON = try decrypt(
ciphertext: seal.content,
senderPubkey: seal.pubkey,
recipientKey: recipientKey
recipientKey: recipientKey,
format: format,
maximumPlaintextBytes: maximumPrivateEnvelopePlaintextBytes
)
let message = try decodePrivateEnvelopeEventJSON(
messageJSON,
maximumBytes: maximumPrivateEnvelopePlaintextBytes
)
guard let data = decrypted.data(using: .utf8),
let rumorDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
// The inner message is intentionally unsigned; sender authentication
// comes from the seal. Bind its claimed sender and custom kind to that
// authenticated layer before exposing content.
guard message.kind == format.messageKind.rawValue,
validInnerMessageTags(
message.tags,
format: format,
recipientPubkey: recipientIdentity.publicKeyHex
),
message.sig == nil,
seal.pubkey == message.pubkey else {
throw NostrError.invalidEvent
}
return try NostrEvent(from: rumorDict)
return (seal, message)
}
// MARK: - Encryption (NIP-44 v2)
/// Released iOS legacy envelopes used no inner tags, while current
/// Android legacy envelopes use exactly the authenticated recipient tag.
/// Accept only those two historical shapes for kind 1059. The new kind
/// 1402 format remains strict and rejects every inner tag.
private static func validInnerMessageTags(
_ tags: [[String]],
format: PrivateEnvelopeWireFormat,
recipientPubkey: String
) -> Bool {
switch format {
case .bitchatV1:
return tags.isEmpty
case .legacyMislabelledV2:
return tags.isEmpty || tags == [["p", recipientPubkey]]
}
}
private static func decodePrivateEnvelopeEventJSON(
_ json: String,
maximumBytes: Int = maximumPrivateEnvelopePlaintextBytes
) throws -> NostrEvent {
// Check UTF-8 size before allocating Data or invoking the general JSON
// parser. `decrypt` enforces the same cap on authenticated bytes; this
// local guard keeps the parser boundary explicit and independently
// testable.
guard json.utf8.count <= maximumBytes else {
throw NostrError.invalidCiphertext
}
guard let data = json.data(using: .utf8),
let dictionary = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw NostrError.invalidEvent
}
return try NostrEvent(from: dictionary)
}
// MARK: - BitChat private-envelope encryption
private static func encrypt(
plaintext: String,
recipientPubkey: String,
senderKey: P256K.Schnorr.PrivateKey
senderKey: P256K.Schnorr.PrivateKey,
format: PrivateEnvelopeWireFormat,
maximumPlaintextBytes: Int
) throws -> String {
guard let recipientPubkeyData = Data(hexString: recipientPubkey) else {
throw NostrError.invalidPublicKey
}
// Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned)
// Derive shared secret
let sharedSecret = try deriveSharedSecret(
privateKey: senderKey,
publicKey: recipientPubkeyData
)
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info)
let key = try deriveNIP44V2Key(from: sharedSecret)
let key = derivePrivateEnvelopeKey(from: sharedSecret, format: format)
// 24-byte random nonce for XChaCha20-Poly1305
var nonce24 = Data(count: 24)
_ = nonce24.withUnsafeMutableBytes { ptr in
let randomStatus = nonce24.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
}
guard randomStatus == errSecSuccess else {
throw NostrError.cryptographicFailure
}
let pt = Data(plaintext.utf8)
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24)
let plaintextData = Data(plaintext.utf8)
guard plaintextData.count <= maximumPlaintextBytes else {
throw NostrError.invalidCiphertext
}
let sealed = try XChaCha20Poly1305Compat.seal(
plaintext: plaintextData,
key: key,
nonce24: nonce24
)
// v2: base64url(nonce24 || ciphertext || tag)
var combined = Data()
combined.append(nonce24)
combined.append(sealed.ciphertext)
combined.append(sealed.tag)
return "v2:" + Base64URLCoding.encode(combined)
return format.contentPrefix + Base64URLCoding.encode(combined)
}
private static func decrypt(
ciphertext: String,
senderPubkey: String,
recipientKey: P256K.Schnorr.PrivateKey
recipientKey: P256K.Schnorr.PrivateKey,
format: PrivateEnvelopeWireFormat,
maximumPlaintextBytes: Int
) throws -> String {
// Expect NIP-44 v2 format
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
let encoded = String(ciphertext.dropFirst(3))
guard ciphertext.utf8.count <= maximumPrivateEnvelopeCiphertextBytes,
ciphertext.hasPrefix(format.contentPrefix) else {
throw NostrError.invalidCiphertext
}
let encoded = String(ciphertext.dropFirst(format.contentPrefix.count))
guard let data = Base64URLCoding.decode(encoded),
data.count > (24 + 16),
let senderPubkeyData = Data(hexString: senderPubkey) else {
@@ -544,33 +765,40 @@ struct NostrProtocol {
let nonce24 = data.prefix(24)
let rest = data.dropFirst(24)
let tag = rest.suffix(16)
let ct = rest.dropLast(16)
let ciphertextBytes = rest.dropLast(16)
// Try decryption with even-Y then odd-Y when sender pubkey is x-only
func attemptDecrypt(using pubKeyData: Data) throws -> Data {
let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData)
let key = try deriveNIP44V2Key(from: ss)
func attemptDecrypt(using publicKeyData: Data) throws -> Data {
let sharedSecret = try deriveSharedSecret(
privateKey: recipientKey,
publicKey: publicKeyData
)
let key = derivePrivateEnvelopeKey(from: sharedSecret, format: format)
return try XChaCha20Poly1305Compat.open(
ciphertext: Data(ct),
ciphertext: Data(ciphertextBytes),
tag: Data(tag),
key: key,
nonce24: Data(nonce24)
)
}
// If 32 bytes (x-only) try both parities, otherwise single try
let plaintext: Data
if senderPubkeyData.count == 32 {
let even = Data([0x02]) + senderPubkeyData
if let pt = try? attemptDecrypt(using: even) {
return String(data: pt, encoding: .utf8) ?? ""
let evenKey = Data([0x02]) + senderPubkeyData
if let opened = try? attemptDecrypt(using: evenKey) {
plaintext = opened
} else {
let oddKey = Data([0x03]) + senderPubkeyData
plaintext = try attemptDecrypt(using: oddKey)
}
let odd = Data([0x03]) + senderPubkeyData
let pt = try attemptDecrypt(using: odd)
return String(data: pt, encoding: .utf8) ?? ""
} else {
let pt = try attemptDecrypt(using: senderPubkeyData)
return String(data: pt, encoding: .utf8) ?? ""
plaintext = try attemptDecrypt(using: senderPubkeyData)
}
guard plaintext.count <= maximumPlaintextBytes,
let decoded = String(data: plaintext, encoding: .utf8) else {
throw NostrError.invalidCiphertext
}
return decoded
}
private static func deriveSharedSecret(
@@ -630,29 +858,17 @@ struct NostrProtocol {
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
// ECDH shared secret derived
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
// Return raw ECDH shared secret; the wire-format-specific HKDF is
// applied by derivePrivateEnvelopeKey.
return sharedSecretData
}
private static func randomizedTimestamp() -> Date {
// Add random offset to current time for privacy
// This prevents timing correlation attacks while the actual message timestamp
// is preserved in the encrypted rumor
let offset = TimeInterval.random(in: -900...900) // +/- 15 minutes
let now = Date()
let randomized = now.addingTimeInterval(offset)
// Log with explicit UTC and local time for debugging
let formatter = DateFormatter()
//
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
formatter.timeZone = TimeZone(abbreviation: "UTC")
formatter.timeZone = TimeZone.current
// Timestamp randomized for privacy
return randomized
private static func randomizedPastTimestamp() -> Date {
// Keep public timestamps in the past: future-dated events are rejected
// by some relays. The actual message timestamp remains encrypted.
Date().addingTimeInterval(
-TimeInterval.random(in: 0...TransportConfig.nostrPrivateEnvelopeTimestampFuzzSeconds)
)
}
}
@@ -691,6 +907,10 @@ struct NostrEvent: Codable {
throw NostrError.invalidEvent
}
guard Self.isWithinInboundTagLimits(tags) else {
throw NostrError.invalidEvent
}
self.id = dict["id"] as? String ?? ""
self.pubkey = pubkey
self.created_at = createdAt
@@ -700,6 +920,21 @@ struct NostrEvent: Codable {
self.sig = dict["sig"] as? String
}
/// Bounds untrusted relay tag arrays so attackers cannot force large
/// allocations or expensive joins on the inbound hot path.
static func isWithinInboundTagLimits(_ tags: [[String]]) -> Bool {
guard tags.count <= TransportConfig.nostrMaxEventTags else { return false }
for tag in tags {
guard tag.count <= TransportConfig.nostrMaxEventTagValues else { return false }
guard tag.allSatisfy({ $0.utf8.count <= TransportConfig.nostrMaxEventTagValueBytes }) else {
return false
}
}
return true
}
func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent {
let (eventId, eventIdHash) = try calculateEventId()
@@ -765,16 +1000,20 @@ enum NostrError: Error {
case invalidPublicKey
case invalidEvent
case invalidCiphertext
case cryptographicFailure
}
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
// MARK: - BitChat private-envelope key derivation
private extension NostrProtocol {
static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data {
private static func derivePrivateEnvelopeKey(
from sharedSecretData: Data,
format: PrivateEnvelopeWireFormat
) -> Data {
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
salt: Data(),
info: Data("nip44-v2".utf8),
salt: format.hkdfSalt,
info: format.hkdfInfo,
outputByteCount: 32
)
return derivedKey.withUnsafeBytes { Data($0) }
+616 -103
View File
@@ -106,10 +106,11 @@ private extension NostrRelayManagerDependencies {
@MainActor
final class NostrRelayManager: ObservableObject {
static let shared = NostrRelayManager()
// Track gift-wraps (kind 1059) we initiated so we can log OK acks at info
private(set) static var pendingGiftWrapIDs = Set<String>()
static func registerPendingGiftWrap(id: String) {
pendingGiftWrapIDs.insert(id)
// Track BitChat private envelopes we initiated so relay rejections can be
// reported without misclassifying ordinary public-event failures.
private(set) static var pendingPrivateEnvelopeIDs = Set<String>()
static func registerPendingPrivateEnvelope(id: String) {
pendingPrivateEnvelopeIDs.insert(id)
}
struct Relay: Identifiable {
@@ -124,7 +125,7 @@ final class NostrRelayManager: ObservableObject {
var nextReconnectTime: Date?
}
// Default relays carry NIP-17 gift wraps, so avoid relays known to reject kind 1059.
// Default relays carry persisted BitChat private-envelope events.
private static let defaultRelays = [
"wss://relay.damus.io",
"wss://nos.lol",
@@ -137,7 +138,7 @@ final class NostrRelayManager: ObservableObject {
@Published private(set) var relays: [Relay] = []
@Published private(set) var isConnected = false
/// Whether a relay that carries private messages is connected. DMs
/// target the default (gift-wrap-capable) relay set, so a connected
/// target the default private-envelope relay set, so a connected
/// geohash/custom relay alone must not count sends would still queue.
@Published private(set) var isDMRelayConnected = false
@@ -174,6 +175,7 @@ final class NostrRelayManager: ObservableObject {
private var subscribeCoalesce: [String: Date] = [:]
private var pendingTorConnectionURLs = Set<String>()
private var awaitingTorForConnections = false
private var awaitingTorForQueueFlush = false
private var torReadyWaitAttempts = 0
private var cancellables = Set<AnyCancellable>()
@@ -208,14 +210,44 @@ final class NostrRelayManager: ObservableObject {
private var eoseTrackerEpoch = 0
private var pendingEOSECallbacks: [String: () -> Void] = [:]
// Message queue for reliability
// Pending sends held only for relays that are not yet connected.
// Message queue for reliability. Pending sends are grouped so a private
// envelope's primary and legacy migration copies can never be split by
// queue eviction. Private batches are protected; lower-priority traffic
// is evicted first and a full protected queue rejects the entire new pair.
private enum PendingSendPriority: Int {
case ephemeral
case regular
case privateEnvelope
}
private struct PendingSend {
var event: NostrEvent
let id: UUID
let events: [NostrEvent]
var pendingRelays: Set<String>
var inFlightConnections: [String: ObjectIdentifier]
var hasSuccessfulDelivery: Bool
let priority: PendingSendPriority
let terminalFailure: (() -> Void)?
}
private struct PendingDelivery {
let itemID: UUID
let events: [NostrEvent]
let priority: PendingSendPriority
let relayUrl: String
let connection: NostrRelayConnectionProtocol
}
private var messageQueue: [PendingSend] = []
private let messageQueueLock = NSLock()
/// Non-queued sends whose callers require relay durability. A WebSocket
/// write only proves bytes left this process; NIP-01 `OK` is the relay's
/// accept/reject acknowledgment.
private struct ConfirmedSendState {
let token: UUID
var awaitingRelays: Set<String>
let completion: (Bool) -> Void
}
private var confirmedSends: [String: ConfirmedSendState] = [:]
// Total pending sends dropped at the queue cap; drives the sampled
// overflow warning (first + every Nth drop).
private var pendingSendDropCount = 0
@@ -295,7 +327,8 @@ final class NostrRelayManager: ObservableObject {
/// Disconnect from all relays
func disconnect() {
connectionGeneration &+= 1
for (_, task) in connections {
for (relayURL, task) in connections {
clearPendingSendInFlight(relayUrl: relayURL, connection: task)
task.cancel(with: .goingAway, reason: nil)
}
connections.removeAll()
@@ -312,8 +345,12 @@ final class NostrRelayManager: ObservableObject {
for (_, tracker) in trackers {
tracker.callback()
}
let confirmed = confirmedSends.values.map(\.completion)
confirmedSends.removeAll()
confirmed.forEach { $0(false) }
pendingTorConnectionURLs.removeAll()
awaitingTorForConnections = false
awaitingTorForQueueFlush = false
torReadyWaitAttempts = 0
updateConnectionStatus()
}
@@ -338,13 +375,15 @@ final class NostrRelayManager: ObservableObject {
pendingEOSECallbacks.removeAll()
pendingTorConnectionURLs.removeAll()
awaitingTorForConnections = false
awaitingTorForQueueFlush = false
torReadyWaitAttempts = 0
recentInboundEventKeys.removeAll()
recentInboundEventKeyOrder.removeAll()
duplicateInboundEventDropCount = 0
duplicateInboundEventDropCountBySubscription.removeAll()
inboundEventLogCount = 0
Self.pendingGiftWrapIDs.removeAll()
Self.pendingPrivateEnvelopeIDs.removeAll()
confirmedSends.removeAll()
messageQueueLock.lock()
messageQueue.removeAll()
@@ -385,100 +424,458 @@ final class NostrRelayManager: ObservableObject {
connectToRelays(targets)
}
/// Send an event to specified relays (or all if none specified)
/// Send an event to specified relays (or all if none specified).
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
// Global network policy gate
guard dependencies.activationAllowed() else { return }
_ = sendEvents(
[event],
to: relayUrls,
priority: Self.pendingSendPriority(for: event),
terminalFailure: nil
)
}
/// Atomically admits a complete private-envelope migration pair. The
/// queue stores the pair as one protected item, so capacity pressure can
/// neither retain only kind 1402 nor only kind 1059. A `false` result means
/// the entire pair was rejected before any connected-relay sends began.
@discardableResult
func sendPrivateEnvelopeBatch(
_ events: [NostrEvent],
to relayUrls: [String]? = nil,
terminalFailure: (() -> Void)? = nil
) -> Bool {
guard events.map(\.kind) == [
NostrProtocol.EventKind.privateEnvelope.rawValue,
NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue
] else {
SecureLogger.error(
"Refusing malformed private-envelope migration batch kinds=\(events.map(\.kind))",
category: .session
)
return false
}
return sendEvents(
events,
to: relayUrls,
priority: .privateEnvelope,
terminalFailure: terminalFailure
)
}
@discardableResult
private func sendEvents(
_ events: [NostrEvent],
to relayUrls: [String]?,
priority: PendingSendPriority,
terminalFailure: (() -> Void)?
) -> Bool {
guard dependencies.activationAllowed(), !events.isEmpty else { return false }
let requestedRelays = relayUrls ?? Self.defaultRelays
var targetRelays = allowedRelayList(from: requestedRelays)
if priority == .privateEnvelope {
// Do not admit a protected batch against a relay already in its
// terminal cooldown. Such a target has no connection attempt that
// could ever retire the queue record; retry callers can try it
// again after the cooldown decays.
targetRelays.removeAll(where: isPermanentlyFailed)
}
guard !targetRelays.isEmpty else { return false }
if priority == .privateEnvelope {
// Protected pairs are admitted for every target before any socket
// write, including targets already connected. The queue entry
// remains authoritative until both writes succeed per relay.
guard enqueuePendingSendBatch(
events,
pendingRelays: Set(targetRelays),
priority: priority,
terminalFailure: terminalFailure
) != nil else { return false }
ensureConnections(to: targetRelays)
// Preserve the same fail-closed Tor gate as ordinary sends. The
// queue is already durable; a stale socket that still appears
// connected must not be written while enforced Tor is unready.
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
flushMessageQueueWhenTorIsReady()
} else {
for relayUrl in targetRelays where connectedConnection(for: relayUrl) != nil {
flushMessageQueue(for: relayUrl)
}
}
return true
}
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
// Fail-closed: nothing touches the network until Tor is up. Queue the
// event locally so it survives a slow bootstrap (queued sends flush
// when relays connect), then kick off connection setup, which itself
// waits for Tor readiness.
let targetRelays = allowedRelayList(from: relayUrls ?? Self.defaultRelays)
guard !targetRelays.isEmpty else { return }
enqueuePendingSend(event, pendingRelays: Set(targetRelays))
// complete item locally so it survives a slow bootstrap, then kick
// off connection setup, which itself waits for Tor readiness.
guard enqueuePendingSendBatch(
events,
pendingRelays: Set(targetRelays),
priority: priority,
terminalFailure: nil
) != nil else { return false }
ensureConnections(to: targetRelays)
return
return true
}
let requestedRelays = relayUrls ?? Self.defaultRelays
let targetRelays = allowedRelayList(from: requestedRelays)
guard !targetRelays.isEmpty else { return }
ensureConnections(to: targetRelays)
// Attempt immediate send to relays with active connections; queue the rest
// Ordinary single events retain their existing immediate-send path.
var connectedTargets: [(String, NostrRelayConnectionProtocol)] = []
var stillPending = Set<String>()
for relayUrl in targetRelays {
if let connection = connectedConnection(for: relayUrl) {
sendToRelay(event: event, connection: connection, relayUrl: relayUrl)
connectedTargets.append((relayUrl, connection))
} else {
stillPending.insert(relayUrl)
}
}
if !stillPending.isEmpty {
enqueuePendingSend(event, pendingRelays: stillPending)
guard enqueuePendingSendBatch(
events,
pendingRelays: stillPending,
priority: priority,
terminalFailure: nil
) != nil else { return false }
}
ensureConnections(to: targetRelays)
for (relayUrl, connection) in connectedTargets {
for event in events {
sendToRelay(event: event, connection: connection, relayUrl: relayUrl)
}
}
return true
}
/// Attempts an event only on currently connected target relays and
/// reports whether at least one relay explicitly accepted it via NIP-01
/// `OK`. A successful WebSocket write alone is not durable acceptance.
/// Unlike `sendEvent`, this never enters the process-local pending queue;
/// callers use it when success unlocks durable state or user-visible
/// delivery progress.
func sendEventImmediately(
_ event: NostrEvent,
to relayUrls: [String]? = nil,
completion: @escaping (Bool) -> Void
) {
guard dependencies.activationAllowed() else {
completion(false)
return
}
guard !(shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady()) else {
completion(false)
return
}
let requestedRelays = relayUrls ?? Self.defaultRelays
let targetRelays = allowedRelayList(from: requestedRelays)
let connectedTargets = targetRelays.compactMap { relayUrl -> (String, NostrRelayConnectionProtocol)? in
guard let connection = connectedConnection(for: relayUrl) else { return nil }
return (relayUrl, connection)
}
guard !connectedTargets.isEmpty else {
completion(false)
return
}
let token = UUID()
let eventID = event.id
if let replaced = confirmedSends.removeValue(forKey: eventID) {
replaced.completion(false)
}
confirmedSends[eventID] = ConfirmedSendState(
token: token,
awaitingRelays: Set(connectedTargets.map(\.0)),
completion: completion
)
dependencies.scheduleAfter(TransportConfig.nostrConfirmedSendAckTimeoutSeconds) { [weak self] in
Task { @MainActor [weak self] in
self?.timeoutConfirmedSend(eventID: eventID, token: token)
}
}
for (relayUrl, connection) in connectedTargets {
sendToRelay(event: event, connection: connection, relayUrl: relayUrl) { [weak self] succeeded in
guard let self else { return }
// Success only means the bytes reached the socket; wait for
// the matching relay OK. A failed write settles this target
// as rejected because no OK can arrive for it.
if !succeeded {
self.resolveConfirmedSend(
eventID: eventID,
relayURL: relayUrl,
accepted: false,
token: token
)
}
}
}
}
private func enqueuePendingSend(_ event: NostrEvent, pendingRelays: Set<String>) {
messageQueueLock.lock()
messageQueue.append(PendingSend(event: event, pendingRelays: pendingRelays))
let overflow = messageQueue.count - TransportConfig.nostrPendingSendQueueCap
if overflow > 0 {
messageQueue.removeFirst(overflow)
private func resolveConfirmedSend(
eventID: String,
relayURL: String,
accepted: Bool,
token: UUID? = nil
) {
guard var state = confirmedSends[eventID],
token == nil || state.token == token,
state.awaitingRelays.remove(relayURL) != nil else { return }
if accepted {
confirmedSends.removeValue(forKey: eventID)
state.completion(true)
} else if state.awaitingRelays.isEmpty {
confirmedSends.removeValue(forKey: eventID)
state.completion(false)
} else {
confirmedSends[eventID] = state
}
}
private func timeoutConfirmedSend(eventID: String, token: UUID) {
guard let state = confirmedSends[eventID], state.token == token else { return }
confirmedSends.removeValue(forKey: eventID)
state.completion(false)
}
private static func pendingSendPriority(for event: NostrEvent) -> PendingSendPriority {
switch event.kind {
case NostrProtocol.EventKind.ephemeralEvent.rawValue,
NostrProtocol.EventKind.geohashPresence.rawValue:
return .ephemeral
default:
return .regular
}
}
/// Atomically append a whole send item while preserving protected private
/// batches. Capacity is measured in events, not queue records.
private func enqueuePendingSendBatch(
_ events: [NostrEvent],
pendingRelays: Set<String>,
priority: PendingSendPriority,
terminalFailure: (() -> Void)?
) -> UUID? {
guard !events.isEmpty, !pendingRelays.isEmpty,
events.count <= TransportConfig.nostrPendingSendQueueCap else {
SecureLogger.error("Refusing invalid or oversized relay send batch", category: .session)
return nil
}
messageQueueLock.lock()
let queuedEventCount = messageQueue.reduce(0) { $0 + $1.events.count }
let overflow = queuedEventCount + events.count - TransportConfig.nostrPendingSendQueueCap
var removalIndexes: [Int] = []
var evictedEventCount = 0
if overflow > 0 {
var eventsToFree = overflow
let candidates = messageQueue.indices
.filter {
messageQueue[$0].priority != .privateEnvelope &&
messageQueue[$0].priority.rawValue <= priority.rawValue
}
.sorted {
let left = messageQueue[$0].priority.rawValue
let right = messageQueue[$1].priority.rawValue
return left == right ? $0 < $1 : left < right
}
for index in candidates where eventsToFree > 0 {
removalIndexes.append(index)
let count = messageQueue[index].events.count
evictedEventCount += count
eventsToFree -= count
}
if eventsToFree > 0 {
messageQueueLock.unlock()
SecureLogger.error(
"Relay send queue protected-capacity exhausted; rejected entire \(events.count)-event batch",
category: .session
)
return nil
}
for index in removalIndexes.sorted(by: >) {
messageQueue.remove(at: index)
}
}
let itemID = UUID()
messageQueue.append(PendingSend(
id: itemID,
events: events,
pendingRelays: pendingRelays,
inFlightConnections: [:],
hasSuccessfulDelivery: false,
priority: priority,
terminalFailure: terminalFailure
))
messageQueueLock.unlock()
guard overflow > 0 else { return }
// Dropped events are ephemeral (presence/geo), so no status surfacing
// is needed but the drops should be visible. Sampled so a sustained
// relay stall can't flood the log.
pendingSendDropCount += overflow
if pendingSendDropCount == 1 ||
guard evictedEventCount > 0 else { return itemID }
let isFirstEviction = pendingSendDropCount == 0
pendingSendDropCount += evictedEventCount
if isFirstEviction ||
pendingSendDropCount.isMultiple(of: TransportConfig.nostrPendingSendDropLogInterval) {
SecureLogger.warning(
"📤 Relay send queue full — dropped \(pendingSendDropCount) oldest event(s)",
"📤 Relay send queue full — evicted \(pendingSendDropCount) lower-priority event(s)",
category: .session
)
}
return itemID
}
/// Try to flush any queued messages for relays that are now connected.
private func flushMessageQueue(for relayUrl: String? = nil) {
guard !(shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady()) else {
flushMessageQueueWhenTorIsReady()
return
}
var deliveries: [PendingDelivery] = []
messageQueueLock.lock()
defer { messageQueueLock.unlock() }
guard !messageQueue.isEmpty else { return }
if let target = relayUrl {
// Flush only for a specific relay
for i in (0..<messageQueue.count).reversed() {
var item = messageQueue[i]
if item.pendingRelays.contains(target), let conn = connectedConnection(for: target) {
sendToRelay(event: item.event, connection: conn, relayUrl: target)
for index in (0..<messageQueue.count).reversed() {
var item = messageQueue[index]
let targets = relayUrl.map { [$0] } ?? Array(item.pendingRelays)
for target in targets {
guard item.pendingRelays.contains(target),
item.inFlightConnections[target] == nil,
let connection = connectedConnection(for: target) else { continue }
deliveries.append(PendingDelivery(
itemID: item.id,
events: item.events,
priority: item.priority,
relayUrl: target,
connection: connection
))
if item.priority == .privateEnvelope {
// Keep the relay pending until both asynchronous writes
// report success. `inFlightConnections` prevents duplicate
// flushes while those callbacks are outstanding.
item.inFlightConnections[target] = ObjectIdentifier(connection)
} else {
item.pendingRelays.remove(target)
if item.pendingRelays.isEmpty {
messageQueue.remove(at: i)
} else {
messageQueue[i] = item
}
}
}
} else {
// Flush for any relays that now have connections
for i in (0..<messageQueue.count).reversed() {
var item = messageQueue[i]
for url in item.pendingRelays {
if let conn = connectedConnection(for: url) {
sendToRelay(event: item.event, connection: conn, relayUrl: url)
item.pendingRelays.remove(url)
}
if item.pendingRelays.isEmpty {
messageQueue.remove(at: index)
} else {
messageQueue[index] = item
}
}
messageQueueLock.unlock()
// Never invoke WebSocket callbacks while holding messageQueueLock.
for delivery in deliveries {
if delivery.priority == .privateEnvelope {
sendPrivateEnvelopeBatchToRelay(
delivery.events,
connection: delivery.connection,
relayUrl: delivery.relayUrl
) { [weak self] succeeded in
self?.completePrivateEnvelopeBatchDelivery(
itemID: delivery.itemID,
relayUrl: delivery.relayUrl,
connection: delivery.connection,
succeeded: succeeded
)
}
if item.pendingRelays.isEmpty {
messageQueue.remove(at: i)
} else {
messageQueue[i] = item
} else {
for event in delivery.events {
sendToRelay(
event: event,
connection: delivery.connection,
relayUrl: delivery.relayUrl
)
}
}
}
}
private func flushMessageQueueWhenTorIsReady() {
guard !awaitingTorForQueueFlush else { return }
awaitingTorForQueueFlush = true
let generation = connectionGeneration
dependencies.awaitTorReady { [weak self] ready in
guard let self else { return }
guard generation == self.connectionGeneration else { return }
self.awaitingTorForQueueFlush = false
guard ready else { return }
self.flushMessageQueue(for: nil)
}
}
/// Send both copies in order and report one result for the complete pair.
/// Stop on the first failure; the still-pending queue item retries both
/// after reconnection, which is safe because receivers deduplicate twins.
private func sendPrivateEnvelopeBatchToRelay(
_ events: [NostrEvent],
connection: NostrRelayConnectionProtocol,
relayUrl: String,
index: Int = 0,
completion: @escaping (Bool) -> Void
) {
guard index < events.count else {
completion(true)
return
}
sendToRelay(
event: events[index],
connection: connection,
relayUrl: relayUrl
) { [weak self] succeeded in
guard succeeded,
let self,
self.connections[relayUrl] === connection else {
completion(false)
return
}
self.sendPrivateEnvelopeBatchToRelay(
events,
connection: connection,
relayUrl: relayUrl,
index: index + 1,
completion: completion
)
}
}
private func completePrivateEnvelopeBatchDelivery(
itemID: UUID,
relayUrl: String,
connection: NostrRelayConnectionProtocol,
succeeded: Bool
) {
messageQueueLock.lock()
if let index = messageQueue.firstIndex(where: { $0.id == itemID }) {
var item = messageQueue[index]
guard item.inFlightConnections[relayUrl] == ObjectIdentifier(connection) else {
messageQueueLock.unlock()
return
}
item.inFlightConnections.removeValue(forKey: relayUrl)
if succeeded {
item.hasSuccessfulDelivery = true
item.pendingRelays.remove(relayUrl)
}
if item.pendingRelays.isEmpty {
messageQueue.remove(at: index)
} else {
messageQueue[index] = item
}
}
messageQueueLock.unlock()
guard !succeeded else { return }
// A failed WebSocket write means this connection cannot advance the
// durable queue. Keep the original pair pending and reconnect with the
// manager's bounded backoff; the next successful ping flushes it.
handleDisconnection(
relayUrl: relayUrl,
error: NSError(
domain: "NostrRelayPrivateEnvelopeBatch",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "private-envelope batch write failed"]
),
connection: connection
)
}
private func connectedConnection(for relayUrl: String) -> NostrRelayConnectionProtocol? {
guard let connection = connections[relayUrl],
relays.first(where: { $0.url == relayUrl })?.isConnected == true else {
@@ -494,9 +891,28 @@ final class NostrRelayManager: ObservableObject {
relayUrls: [String]? = nil,
handler: @escaping (NostrEvent) -> Void,
onEOSE: (() -> Void)? = nil
) {
subscribe(
filters: [filter],
id: id,
relayUrls: relayUrls,
handler: handler,
onEOSE: onEOSE
)
}
/// Subscribe with independent Nostr filters in one REQ. Each filter keeps
/// its own relay-side limit; this is required for mixed-version mailbox
/// recovery so kind 1402 cannot starve kind 1059 (or the reverse).
func subscribe(
filters: [NostrFilter],
id: String = UUID().uuidString,
relayUrls: [String]? = nil,
handler: @escaping (NostrEvent) -> Void,
onEOSE: (() -> Void)? = nil
) {
// Global network policy gate
guard dependencies.activationAllowed() else { return }
guard dependencies.activationAllowed(), !filters.isEmpty else { return }
// Coalesce rapid duplicate subscribe requests even while Tor readiness is pending.
let now = dependencies.now()
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < subscribeCoalesceInterval {
@@ -505,7 +921,7 @@ final class NostrRelayManager: ObservableObject {
subscribeCoalesce[id] = now
messageHandlers[id] = handler
let req = NostrRequest.subscribe(id: id, filters: [filter])
let req = NostrRequest.subscribe(id: id, filters: filters)
do {
let message = try encoder.encode(req)
@@ -572,6 +988,7 @@ final class NostrRelayManager: ObservableObject {
ensureConnections(to: Self.defaultRelays)
}
} else {
var terminalFailures: [() -> Void] = []
for url in Self.defaultRelays {
if let connection = connections[url] {
connection.cancel(with: .goingAway, reason: nil)
@@ -579,18 +996,9 @@ final class NostrRelayManager: ObservableObject {
connections.removeValue(forKey: url)
subscriptions.removeValue(forKey: url)
pendingSubscriptions.removeValue(forKey: url)
terminalFailures.append(contentsOf: retirePendingSendRelay(url))
}
messageQueueLock.lock()
for index in (0..<messageQueue.count).reversed() {
var item = messageQueue[index]
item.pendingRelays.subtract(Self.defaultRelaySet)
if item.pendingRelays.isEmpty {
messageQueue.remove(at: index)
} else {
messageQueue[index] = item
}
}
messageQueueLock.unlock()
terminalFailures.forEach { $0() }
relays.removeAll { Self.defaultRelaySet.contains($0.url) }
updateConnectionStatus()
}
@@ -923,6 +1331,7 @@ final class NostrRelayManager: ObservableObject {
// Send initial ping to verify connection
task.sendPing { [weak self] error in
DispatchQueue.main.async {
guard self?.connections[urlString] === task else { return }
if error == nil {
SecureLogger.debug("✅ Connected to Nostr relay: \(urlString)", category: .session)
self?.updateRelayStatus(urlString, isConnected: true)
@@ -932,7 +1341,11 @@ final class NostrRelayManager: ObservableObject {
SecureLogger.error("❌ Failed to connect to Nostr relay \(urlString): \(error?.localizedDescription ?? "Unknown error")", category: .session)
self?.updateRelayStatus(urlString, isConnected: false, error: error)
// Trigger disconnection handler for proper backoff
self?.handleDisconnection(relayUrl: urlString, error: error ?? NSError(domain: "NostrRelay", code: -1, userInfo: nil))
self?.handleDisconnection(
relayUrl: urlString,
error: error ?? NSError(domain: "NostrRelay", code: -1, userInfo: nil),
connection: task
)
}
}
}
@@ -990,18 +1403,20 @@ final class NostrRelayManager: ObservableObject {
Task.detached(priority: .utility) {
guard let parsed = ParsedInbound(message) else { return }
await MainActor.run {
guard self.connections[relayUrl] === task else { return }
self.handleParsedMessage(parsed, from: relayUrl)
}
}
// Continue receiving
Task { @MainActor in
guard self.connections[relayUrl] === task else { return }
self.receiveMessage(from: task, relayUrl: relayUrl)
}
case .failure(let error):
DispatchQueue.main.async {
self.handleDisconnection(relayUrl: relayUrl, error: error)
self.handleDisconnection(relayUrl: relayUrl, error: error, connection: task)
}
}
}
@@ -1028,7 +1443,7 @@ final class NostrRelayManager: ObservableObject {
guard shouldDeliverInboundEvent(subscriptionID: subId, eventID: event.id) else {
return
}
if event.kind != 1059 {
if !NostrProtocol.acceptedPrivateEnvelopeKinds.contains(event.kind) {
// Per-event logging floods dev builds in busy geohashes; sample it.
inboundEventLogCount += 1
if inboundEventLogCount == 1 || inboundEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
@@ -1055,12 +1470,13 @@ final class NostrRelayManager: ObservableObject {
}
}
case .ok(let eventId, let success, let reason):
resolveConfirmedSend(eventID: eventId, relayURL: relayUrl, accepted: success)
if success {
_ = Self.pendingGiftWrapIDs.remove(eventId)
_ = Self.pendingPrivateEnvelopeIDs.remove(eventId)
SecureLogger.debug("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)", category: .session)
} else {
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
if isGiftWrap {
let isPrivateEnvelope = Self.pendingPrivateEnvelopeIDs.remove(eventId) != nil
if isPrivateEnvelope {
SecureLogger.warning("📮 Rejected id=\(eventId.prefix(16))… relay=\(relayUrl) reason=\(reason)", category: .session)
} else {
SecureLogger.error("📮 Rejected id=\(eventId.prefix(16))… relay=\(relayUrl) reason=\(reason)", category: .session)
@@ -1071,7 +1487,12 @@ final class NostrRelayManager: ObservableObject {
}
}
private func sendToRelay(event: NostrEvent, connection: NostrRelayConnectionProtocol, relayUrl: String) {
private func sendToRelay(
event: NostrEvent,
connection: NostrRelayConnectionProtocol,
relayUrl: String,
completion: ((Bool) -> Void)? = nil
) {
let req = NostrRequest.event(event)
do {
@@ -1084,17 +1505,20 @@ final class NostrRelayManager: ObservableObject {
DispatchQueue.main.async {
if let error = error {
SecureLogger.error("❌ Failed to send event to \(relayUrl): \(error)", category: .session)
completion?(false)
} else {
// SecureLogger.debug(" Event sent to relay: \(relayUrl)", category: .session)
// Update relay stats
if let index = self?.relays.firstIndex(where: { $0.url == relayUrl }) {
self?.relays[index].messagesSent += 1
}
completion?(true)
}
}
}
} catch {
SecureLogger.error("Failed to encode event: \(error)", category: .session)
completion?(false)
}
}
@@ -1158,9 +1582,21 @@ final class NostrRelayManager: ObservableObject {
eoseTrackers[id] = tracker
}
private func handleDisconnection(relayUrl: String, error: Error) {
private func handleDisconnection(
relayUrl: String,
error: Error,
connection: NostrRelayConnectionProtocol? = nil
) {
if let connection, connections[relayUrl] !== connection { return }
clearPendingSendInFlight(relayUrl: relayUrl, connection: connection)
connections.removeValue(forKey: relayUrl)
subscriptions.removeValue(forKey: relayUrl)
let awaitingConfirmation = confirmedSends.compactMap { eventID, state in
state.awaitingRelays.contains(relayUrl) ? eventID : nil
}
for eventID in awaitingConfirmation {
resolveConfirmedSend(eventID: eventID, relayURL: relayUrl, accepted: false)
}
updateRelayStatus(relayUrl, isConnected: false, error: error)
settleEOSETrackers(droppingRelay: relayUrl)
// If networking is disallowed, do not schedule reconnection
@@ -1173,7 +1609,10 @@ final class NostrRelayManager: ObservableObject {
let ns = error as NSError
if errorDescription.contains("hostname could not be found") ||
errorDescription.contains("dns") ||
(ns.domain == NSURLErrorDomain && ns.code == NSURLErrorBadServerResponse) {
(ns.domain == NSURLErrorDomain && (
ns.code == NSURLErrorBadServerResponse ||
ns.code == NSURLErrorCannotFindHost
)) {
if relays.first(where: { $0.url == relayUrl })?.lastError == nil {
SecureLogger.warning("Nostr relay permanent failure for \(relayUrl) - not retrying (code=\(ns.code))", category: .session)
}
@@ -1183,6 +1622,8 @@ final class NostrRelayManager: ObservableObject {
relays[index].nextReconnectTime = nil
}
pendingSubscriptions[relayUrl] = nil
let terminalFailures = retirePendingSendRelay(relayUrl)
terminalFailures.forEach { $0() }
return
}
@@ -1194,6 +1635,8 @@ final class NostrRelayManager: ObservableObject {
// Stop attempting after max attempts
if relays[index].reconnectAttempts >= maxReconnectAttempts {
SecureLogger.warning("Max reconnection attempts (\(maxReconnectAttempts)) reached for \(relayUrl)", category: .session)
let terminalFailures = retirePendingSendRelay(relayUrl)
terminalFailures.forEach { $0() }
return
}
@@ -1232,6 +1675,54 @@ final class NostrRelayManager: ObservableObject {
}
}
/// A relay in terminal cooldown cannot make progress on queued sends. Drop
/// it from every target set so one unavailable default relay cannot pin
/// otherwise-delivered protected batches forever. If this was the last
/// target and no relay completed the pair, report whole-batch failure to
/// the originating transport for visible failure or retry.
private func retirePendingSendRelay(_ relayUrl: String) -> [() -> Void] {
var terminalFailures: [() -> Void] = []
messageQueueLock.lock()
for index in (0..<messageQueue.count).reversed() {
var item = messageQueue[index]
guard item.pendingRelays.remove(relayUrl) != nil else { continue }
item.inFlightConnections.removeValue(forKey: relayUrl)
if item.pendingRelays.isEmpty {
if item.priority == .privateEnvelope,
!item.hasSuccessfulDelivery,
let terminalFailure = item.terminalFailure {
terminalFailures.append(terminalFailure)
}
messageQueue.remove(at: index)
} else {
messageQueue[index] = item
}
}
messageQueueLock.unlock()
return terminalFailures
}
/// Release the in-flight marker owned by a dead socket without removing
/// the relay from the durable pending set. A stale completion from that
/// socket is ignored by its ObjectIdentifier check.
private func clearPendingSendInFlight(
relayUrl: String,
connection: NostrRelayConnectionProtocol?
) {
let expectedIdentifier = connection.map(ObjectIdentifier.init)
messageQueueLock.lock()
for index in messageQueue.indices {
var item = messageQueue[index]
guard let inFlightIdentifier = item.inFlightConnections[relayUrl],
expectedIdentifier == nil || expectedIdentifier == inFlightIdentifier else {
continue
}
item.inFlightConnections.removeValue(forKey: relayUrl)
messageQueue[index] = item
}
messageQueueLock.unlock()
}
// MARK: - Public Utility Methods
/// Manually retry connection to a specific relay
@@ -1246,6 +1737,10 @@ final class NostrRelayManager: ObservableObject {
// Disconnect if connected
if let connection = connections[normalizedRelayUrl] {
clearPendingSendInFlight(
relayUrl: normalizedRelayUrl,
connection: connection
)
connection.cancel(with: .goingAway, reason: nil)
connections.removeValue(forKey: normalizedRelayUrl)
}
@@ -1267,7 +1762,13 @@ final class NostrRelayManager: ObservableObject {
var debugPendingMessageQueueCount: Int {
messageQueueLock.lock()
defer { messageQueueLock.unlock() }
return messageQueue.count
return messageQueue.reduce(0) { $0 + $1.events.count }
}
var debugPendingMessageQueueEventIDsByBatch: [[String]] {
messageQueueLock.lock()
defer { messageQueueLock.unlock() }
return messageQueue.map { $0.events.map(\.id) }
}
func debugPendingSubscriptionCount(for relayUrl: String) -> Int {
@@ -1349,7 +1850,7 @@ private enum ParsedInbound {
case notice(String)
init?(_ message: URLSessionWebSocketTask.Message) {
guard let data = message.data,
guard let data = message.dataWithinInboundLimit,
let array = try? JSONSerialization.jsonObject(with: data) as? [Any],
array.count >= 2,
let type = array[0] as? String else {
@@ -1394,11 +1895,19 @@ private enum ParsedInbound {
}
private extension URLSessionWebSocketTask.Message {
var data: Data? {
/// Prefer rejecting oversized frames before UTF-8/Data materialization
/// where we can (string length), and always before JSON parse.
var dataWithinInboundLimit: Data? {
let maxBytes = TransportConfig.nostrMaxInboundMessageBytes
switch self {
case .string(let text): text.data(using: .utf8)
case .data(let data): data
@unknown default: nil
case .string(let text):
guard text.utf8.count <= maxBytes else { return nil }
return text.data(using: .utf8)
case .data(let data):
guard data.count <= maxBytes else { return nil }
return data
@unknown default:
return nil
}
}
}
@@ -1471,14 +1980,18 @@ struct NostrFilter: Encodable {
}
}
// For NIP-17 gift wraps
static func giftWrapsFor(pubkey: String, since: Date? = nil) -> NostrFilter {
var filter = NostrFilter()
filter.kinds = [1059] // Gift wrap kind
filter.since = since?.timeIntervalSince1970.toInt()
filter.tagFilters = ["p": [pubkey]]
filter.limit = TransportConfig.nostrRelayDefaultFetchLimit // reasonable limit
return filter
// BitChat private envelopes, plus compatibility legacy envelopes emitted
// throughout the coordinated migration and stored by older releases as
// kind 1059.
static func privateEnvelopeFiltersFor(pubkey: String, since: Date? = nil) -> [NostrFilter] {
NostrProtocol.acceptedPrivateEnvelopeKinds.map { kind in
var filter = NostrFilter()
filter.kinds = [kind]
filter.since = since?.timeIntervalSince1970.toInt()
filter.tagFilters = ["p": [pubkey]]
filter.limit = TransportConfig.nostrPrivateEnvelopeFetchLimitPerKind
return filter
}
}
// For location channels: geohash-scoped ephemeral events (kind 20000) and presence (kind 20001)
+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
<string>3B52.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
<string>1C8F.1</string>
</array>
</dict>
</array>
</dict>
</plist>
+87
View File
@@ -154,3 +154,90 @@ struct BitchatFilePacket {
)
}
}
/// Wire-compatible identity for private media exchanged by clients using the
/// current iOS entropy-bearing filenames, without extending the deployed file
/// TLV. Android clients reject unknown file tags, so eligible senders and
/// receivers derive the receipt key from fields already on the wire.
///
/// Locally-created image and voice-note filenames contain a UUID or live-voice
/// burst ID. Including the normalized direction keeps a reused filename
/// distinct across chats while allowing short and full Noise-key peer IDs to
/// converge. Android and older-iOS timestamp-only names remain ineligible and
/// retain their legacy random local IDs (transfer-compatible, no receipts).
enum PrivateMediaMessageIdentity {
private static let domain = Data("bitchat-private-media-message-v1".utf8)
private static let idPrefix = "media-"
private static let digestHexLength = 32
static func isStableID(_ candidate: String) -> Bool {
guard candidate.hasPrefix(idPrefix) else { return false }
let digest = candidate.dropFirst(idPrefix.count)
guard digest.utf8.count == digestHexLength else { return false }
return digest.utf8.allSatisfy { byte in
(UInt8(ascii: "0")...UInt8(ascii: "9")).contains(byte)
|| (UInt8(ascii: "a")...UInt8(ascii: "f")).contains(byte)
}
}
static func stableID(
senderPeerID: PeerID,
recipientPeerID: PeerID,
fileName: String?
) -> String? {
guard let fileName, !fileName.isEmpty else { return nil }
let leafName = (fileName as NSString).lastPathComponent
guard leafName == fileName else { return nil }
let path = leafName as NSString
let stem = path.deletingPathExtension
let fileExtension = path.pathExtension.lowercased()
switch true {
case stem.hasPrefix("img_"):
guard fileExtension == "jpg" || fileExtension == "jpeg" else { return nil }
case stem.hasPrefix("voice_"):
guard fileExtension == "m4a" else { return nil }
default:
return nil
}
let entropyToken = stem.split(separator: "_").last.map(String.init)
let hasUUIDEntropy = entropyToken.flatMap(UUID.init(uuidString:)) != nil
let voiceBurstID = stem.hasPrefix("voice_")
? String(stem.dropFirst("voice_".count))
: ""
let hasBurstEntropy = voiceBurstID.count == 16
&& voiceBurstID.allSatisfy(\.isHexDigit)
guard hasUUIDEntropy || hasBurstEntropy else {
return nil
}
let fields = [
Data(senderPeerID.toShort().bare.utf8),
Data(recipientPeerID.toShort().bare.utf8),
Data(leafName.utf8)
]
var input = domain
for field in fields {
guard let length = UInt32(exactly: field.count) else { return nil }
var bigEndianLength = length.bigEndian
withUnsafeBytes(of: &bigEndianLength) {
input.append(contentsOf: $0)
}
input.append(field)
}
return "\(idPrefix)\(input.sha256Hex().prefix(digestHexLength))"
}
static func stableID(
for packet: BitchatFilePacket,
senderPeerID: PeerID,
recipientPeerID: PeerID
) -> String? {
stableID(
senderPeerID: senderPeerID,
recipientPeerID: recipientPeerID,
fileName: packet.fileName
)
}
}
+25
View File
@@ -79,12 +79,35 @@ enum NoisePayloadType: UInt8 {
case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update)
// Live voice (push-to-talk)
case voiceFrame = 0x08 // One live voice-burst packet (see VoiceBurstPacket)
// Finalized private media. `0x20` is the value already deployed by the
// Android client. The complete BitchatFilePacket is encrypted inside
// Noise before the outer noiseEncrypted packet is fragmented.
case privateFile = 0x20
// Versioned peer state authenticated by the surrounding Noise session.
// This is intentionally distinct from the public announce: announce
// capabilities are discovery hints, while this payload proves possession
// of the advertised Noise static key before downgrade state is pinned.
case authenticatedPeerState = 0x21
// Verification (QR-based OOB binding)
case verifyChallenge = 0x10 // Verification challenge
case verifyResponse = 0x11 // Verification response
// Transitive verification (web of trust)
case vouch = 0x12 // Batch of vouch attestations
/// #1434 briefly used 0x09 before release. Accept it while prerelease
/// builds age out, but never emit it. Decoders canonicalize both values to
/// `.privateFile` so the compatibility alias cannot leak into app logic.
static let prereleasePrivateFileRawValue: UInt8 = 0x09
static func decoded(rawValue: UInt8) -> NoisePayloadType? {
rawValue == prereleasePrivateFileRawValue ? .privateFile : Self(rawValue: rawValue)
}
static func isPrivateFile(rawValue: UInt8?) -> Bool {
guard let rawValue else { return false }
return rawValue == privateFile.rawValue || rawValue == prereleasePrivateFileRawValue
}
var description: String {
switch self {
case .privateMessage: return "privateMessage"
@@ -93,6 +116,8 @@ enum NoisePayloadType: UInt8 {
case .groupInvite: return "groupInvite"
case .groupKeyUpdate: return "groupKeyUpdate"
case .voiceFrame: return "voiceFrame"
case .privateFile: return "privateFile"
case .authenticatedPeerState: return "authenticatedPeerState"
case .verifyChallenge: return "verifyChallenge"
case .verifyResponse: return "verifyResponse"
case .vouch: return "vouch"
@@ -0,0 +1,32 @@
//
// MeshMessageIdentity.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Foundation
/// Content-derived identity for public mesh messages.
///
/// The BLE wire carries no message ID for public broadcasts, so every device
/// recomputes the same stable ID from the signed wire fields (sender ID,
/// millisecond timestamp, content). That gives the mesh bridge a
/// cross-device-consistent radio identity with zero wire change. Bridge events
/// carry this value only as a hint for detecting a radio copy that is already
/// present: sender/timestamp/content are public, so a different Nostr signer
/// can copy them and must never be allowed to reserve the genuine event's
/// authenticated dedup slot.
enum MeshMessageIdentity {
/// Matches the wire truncation in `BLEService.sendMessage`.
static func millisecondTimestamp(_ date: Date) -> UInt64 {
UInt64(date.timeIntervalSince1970 * 1000)
}
static func stableID(senderIDHex: String, timestampMs: UInt64, content: String) -> String {
let input = senderIDHex.lowercased() + "|" + String(timestampMs) + "|" + content.trimmed
return String(Data(input.utf8).sha256Hex().prefix(32))
}
}
+83
View File
@@ -156,6 +156,89 @@ struct AnnouncementPacket {
}
}
/// State that is authoritative only because it is carried inside an
/// established Noise session. The public announce remains useful for
/// discovery, but its self-signature cannot prove possession of the copied
/// Noise public key it contains.
///
/// Wire format (v1):
/// `[version=0x01][type][length][value]...`
/// - TLV `0x01`: canonical minimal little-endian `PeerCapabilities`
/// - TLV `0x02`: 32-byte Ed25519 signing public key
///
/// Unknown TLVs are skipped for forward compatibility. Unknown versions,
/// duplicates, non-canonical capability fields, and malformed lengths are
/// rejected without changing authenticated state.
struct AuthenticatedPeerStatePacket: Equatable {
static let currentVersion: UInt8 = 1
static let signingPublicKeyLength = 32
let capabilities: PeerCapabilities
let signingPublicKey: Data
private enum TLVType: UInt8 {
case capabilities = 0x01
case signingPublicKey = 0x02
}
func encode() -> Data? {
guard signingPublicKey.count == Self.signingPublicKeyLength else { return nil }
let capabilityBytes = capabilities.encoded()
guard !capabilityBytes.isEmpty, capabilityBytes.count <= 8 else { return nil }
var data = Data([Self.currentVersion])
data.append(TLVType.capabilities.rawValue)
data.append(UInt8(capabilityBytes.count))
data.append(capabilityBytes)
data.append(TLVType.signingPublicKey.rawValue)
data.append(UInt8(signingPublicKey.count))
data.append(signingPublicKey)
return data
}
static func decode(from data: Data) -> AuthenticatedPeerStatePacket? {
guard data.first == Self.currentVersion else { return nil }
var offset = 1
var capabilities: PeerCapabilities?
var signingPublicKey: Data?
while offset < data.count {
guard offset + 2 <= data.count else { return nil }
let typeRaw = data[offset]
let length = Int(data[offset + 1])
offset += 2
guard offset + length <= data.count else { return nil }
let value = Data(data[offset..<(offset + length)])
offset += length
guard let type = TLVType(rawValue: typeRaw) else {
continue
}
switch type {
case .capabilities:
guard capabilities == nil,
!value.isEmpty,
value.count <= 8 else { return nil }
let decoded = PeerCapabilities(encoded: value)
guard decoded.encoded() == value else { return nil }
capabilities = decoded
case .signingPublicKey:
guard signingPublicKey == nil,
value.count == Self.signingPublicKeyLength else { return nil }
signingPublicKey = value
}
}
guard let capabilities, let signingPublicKey else { return nil }
return AuthenticatedPeerStatePacket(
capabilities: capabilities,
signingPublicKey: signingPublicKey
)
}
}
struct PrivateMessagePacket {
let messageID: String
let content: String
@@ -3,5 +3,11 @@ import BitFoundation
extension PeerCapabilities {
/// Capabilities this build advertises in its announce packets.
/// Each feature adds its bit here when it ships.
static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups]
static let localSupported: PeerCapabilities = [
.vouch,
.prekeys,
.groups,
.privateMedia,
.privateMediaReceipts
]
}
+38 -2
View File
@@ -16,10 +16,19 @@ struct BLEAnnounceHandlerEnvironment {
let now: () -> Date
/// Noise public key already recorded for the peer, if any (registry read).
let existingNoisePublicKey: (PeerID) -> Data?
/// Ed25519 key previously bound to this Noise identity by an authenticated
/// peer-state payload, if any (persistent identity-state read).
let authenticatedSigningPublicKey: (_ noisePublicKey: Data) -> Data?
/// Verifies the packet signature against the announced signing key.
let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
/// Direct link state for the peer (BLE-queue read).
let linkState: (PeerID) -> (hasPeripheral: Bool, hasCentral: Bool)
/// Whether the link this packet arrived on is already bound to a
/// different peer ID (ingress-registry + BLE-queue read). Directness
/// rides on the unsigned TTL, so a replayed announce can look "direct"
/// on the replayer's link; that link must not shortcut an absent peer
/// into "connected".
let linkBoundToOtherPeer: (_ packet: BitchatPacket, _ peerID: PeerID) -> Bool
/// Runs the registry mutation phase under the collections barrier.
let withRegistryBarrier: (() -> Void) -> Void
/// Upserts the verified announce into the peer registry.
@@ -124,17 +133,44 @@ final class BLEAnnounceHandler {
hasSignature: hasSignature,
signatureValid: signatureValid,
existingNoisePublicKey: existingNoisePublicKey,
announcedNoisePublicKey: announcement.noisePublicKey
announcedNoisePublicKey: announcement.noisePublicKey,
authenticatedSigningPublicKey: env.authenticatedSigningPublicKey(
announcement.noisePublicKey
),
announcedSigningPublicKey: announcement.signingPublicKey
)
if case .reject(.keyMismatch) = trustDecision {
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security)
}
if case .reject(.authenticatedSigningKeyMismatch) = trustDecision {
SecureLogger.warning(
"⚠️ Announce signing-key replacement rejected for Noise-authenticated peer \(peerID.id.prefix(8))",
category: .security
)
}
let verifiedAnnounce = trustDecision.isVerified
var isNewPeer = false
var isReconnectedPeer = false
let directLinkState = env.linkState(peerID)
let isDirectAnnounce = packet.ttl == env.messageTTL
// A "direct" announce arriving on a link that another peer already
// owns is either a rotation heal or a replay with its TTL restored;
// both are ambiguous, so only the rebind (which containment-checks
// the claimed identity) may promote it never this shortcut.
//
// Known limitation: denying the shortcut cannot prevent forged
// presence outright. A rebind that passes the containment checks
// promotes the claimed peer to connected it must, or a legitimate
// rotation on an open link would read as disconnected so a replay
// that wins the rebind (absent victim, cooldown clear) still forges
// presence. That residue is presence display only: DMs stay gated on
// canDeliverSecurely (no Noise session means retain + courier, see
// MessageRouter.sendPrivate). What this check buys: the ambiguous
// announce alone never flips presence forging requires winning the
// containment-checked rebind (never steals an identity that owns a
// live link; at most one rebind per link per cooldown window).
let linkBoundToOtherPeer = isDirectAnnounce && env.linkBoundToOtherPeer(packet, peerID)
env.withRegistryBarrier {
let hasPeripheralConnection = directLinkState.hasPeripheral
@@ -152,7 +188,7 @@ final class BLEAnnounceHandler {
let update = env.upsertVerifiedAnnounce(
peerID,
announcement,
isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
hasPeripheralConnection || hasCentralSubscription || (isDirectAnnounce && !linkBoundToOtherPeer),
now
)
isNewPeer = update.isNewPeer
@@ -56,6 +56,7 @@ enum BLEAnnounceTrustRejection: Equatable {
case missingSignature
case invalidSignature
case keyMismatch
case authenticatedSigningKeyMismatch
}
enum BLEAnnounceTrustDecision: Equatable {
@@ -72,12 +73,19 @@ enum BLEAnnounceTrustPolicy {
hasSignature: Bool,
signatureValid: Bool,
existingNoisePublicKey: Data?,
announcedNoisePublicKey: Data
announcedNoisePublicKey: Data,
authenticatedSigningPublicKey: Data? = nil,
announcedSigningPublicKey: Data? = nil
) -> BLEAnnounceTrustDecision {
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
return .reject(.keyMismatch)
}
if let authenticatedSigningPublicKey,
announcedSigningPublicKey != authenticatedSigningPublicKey {
return .reject(.authenticatedSigningKeyMismatch)
}
guard hasSignature else {
return .reject(.missingSignature)
}
+47 -11
View File
@@ -15,7 +15,10 @@ enum BLEFanoutSelector {
excludedLinks: Set<BLEIngressLinkID> = [],
peripheralPeerBindings: [String: PeerID] = [:],
centralPeerBindings: [String: PeerID] = [:],
preferredPeripheralPerPeer: [PeerID: String] = [:],
collapseDuplicatePeerLinks: Bool = true,
directedPeerHint: PeerID?,
requireDirectPeerLink: Bool = false,
packetType: UInt8,
messageID: String
) -> BLEFanoutSelection {
@@ -31,10 +34,14 @@ enum BLEFanoutSelector {
to: directedPeerHint,
links: rawAllowed,
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings
centralPeerBindings: centralPeerBindings,
preferredPeripheralPerPeer: preferredPeripheralPerPeer
) {
return directedSelection
}
if directedPeerHint != nil, requireDirectPeerLink {
return BLEFanoutSelection(peripheralIDs: [], centralIDs: [])
}
if let directedPeerHint,
hasBoundLink(
to: directedPeerHint,
@@ -46,11 +53,20 @@ enum BLEFanoutSelector {
return BLEFanoutSelection(peripheralIDs: [], centralIDs: [])
}
let allowed = collapseDuplicateLinksPerPeer(
rawAllowed,
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings
)
// Direct announces are the packet that binds a link to its peer
// (BLEService's raw bind and verified rebind). Collapsing them per
// peer starves duplicate same-peer links of the announce they need to
// become bound the duplicates then look "pre-announce" forever and
// every broadcast sprays down all of them. Announces are small and
// throttled, so they go on every live link.
let allowed = collapseDuplicatePeerLinks
? collapseDuplicateLinksPerPeer(
rawAllowed,
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings,
preferredPeripheralPerPeer: preferredPeripheralPerPeer
)
: rawAllowed
guard shouldSubset(packetType: packetType, directedPeerHint: directedPeerHint) else {
return BLEFanoutSelection(
@@ -97,7 +113,8 @@ enum BLEFanoutSelector {
to peerID: PeerID,
links: (peripheralIDs: [String], centralIDs: [String]),
peripheralPeerBindings: [String: PeerID],
centralPeerBindings: [String: PeerID]
centralPeerBindings: [String: PeerID],
preferredPeripheralPerPeer: [PeerID: String]
) -> BLEFanoutSelection? {
let directLinks = collapseDuplicateLinksPerPeer(
(
@@ -105,7 +122,8 @@ enum BLEFanoutSelector {
centralIDs: links.centralIDs.filter { centralPeerBindings[$0] == peerID }
),
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings
centralPeerBindings: centralPeerBindings,
preferredPeripheralPerPeer: preferredPeripheralPerPeer
)
guard !directLinks.peripheralIDs.isEmpty || !directLinks.centralIDs.isEmpty else {
@@ -140,7 +158,8 @@ enum BLEFanoutSelector {
private static func collapseDuplicateLinksPerPeer(
_ links: (peripheralIDs: [String], centralIDs: [String]),
peripheralPeerBindings: [String: PeerID],
centralPeerBindings: [String: PeerID]
centralPeerBindings: [String: PeerID],
preferredPeripheralPerPeer: [PeerID: String]
) -> (peripheralIDs: [String], centralIDs: [String]) {
guard !peripheralPeerBindings.isEmpty || !centralPeerBindings.isEmpty else {
return links
@@ -148,13 +167,30 @@ enum BLEFanoutSelector {
var seenPeers = Set<PeerID>()
var keptPeripheralIDs: [String] = []
// When a peer has several bound peripheral links (duplicate
// connections after a restore), collapse onto its preferred one (the
// most recently bound) instead of dictionary order an arbitrary
// pick could route a peer's single collapsed copy down a stale link.
for id in links.peripheralIDs {
if let peer = peripheralPeerBindings[id], !seenPeers.insert(peer).inserted {
continue
guard let peer = peripheralPeerBindings[id],
preferredPeripheralPerPeer[peer] == id,
seenPeers.insert(peer).inserted else { continue }
keptPeripheralIDs.append(id)
}
for id in links.peripheralIDs {
if let peer = peripheralPeerBindings[id] {
if preferredPeripheralPerPeer[peer] == id { continue }
if !seenPeers.insert(peer).inserted { continue }
}
keptPeripheralIDs.append(id)
}
// Known limitation: centrals collapse in subscription order (oldest
// first) there is no recency signal like the peripheral reverse
// map. A central-only peer with duplicate subscriptions rides the
// oldest one until the remote side (which owns those connections)
// consolidates on its next verified announce (bounded by its
// retirement cooldown).
var keptCentralIDs: [String] = []
for id in links.centralIDs {
if let peer = centralPeerBindings[id], !seenPeers.insert(peer).inserted {
+387 -74
View File
@@ -16,6 +16,8 @@ struct BLEFileTransferHandlerEnvironment {
let peersSnapshot: () -> [PeerID: BLEPeerInfo]
/// Verifies a packet's signature against a candidate signing key (registry path).
let verifyPacketSignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
/// Local signing key used to authenticate our own gossip-sync replays.
let localSigningPublicKey: () -> Data
/// Resolves a display name from a verified packet signature for peers missing from the registry.
let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String?
/// Tracks the broadcast file packet for gossip sync.
@@ -30,10 +32,85 @@ struct BLEFileTransferHandlerEnvironment {
_ fallbackExtension: String?,
_ defaultPrefix: String
) -> URL?
/// Resolves the durable receiver decision for a stable private-media ID.
let privateMediaReceiptState: (
_ messageID: String
) -> BLEPrivateMediaReceiptState
/// Atomically records a stable private-media ID after the payload save.
let commitPrivateMediaFile: (_ messageID: String, _ storedURL: URL) -> Bool
/// Rolls back a saved payload when its durable receipt commit fails.
let removeIncomingFile: (_ storedURL: URL) -> Void
/// Releases the allocator's save-to-UI ownership guard after synchronous
/// conversation insertion has completed.
let finishIncomingFileDelivery: (_ storedURL: URL) -> Void
/// Checks the authenticated sender before any private-media disk work.
let isPrivateMediaSenderBlocked: (PeerID) -> Bool
/// Updates the registry last-seen timestamp for the peer (async barrier write).
let updatePeerLastSeen: (PeerID) -> Void
/// Delivers `.messageReceived` to the UI as one main-actor hop.
let deliverMessage: (BitchatMessage) -> Void
/// Acknowledges stable private media only after its synchronous
/// conversation delivery has completed.
let acknowledgePrivateMedia: (_ messageID: String, _ peerID: PeerID) -> Void
/// Delivers `.messageReceived` as one main-actor hop while
/// `shouldDeliver` remains true before and after the synchronous sink.
/// The completion authorizes the stable-media ACK. Finalization runs after
/// every delivery attempt, including rejection, so allocator ownership
/// cannot leak indefinitely.
let deliverMessage: (
_ message: BitchatMessage,
_ shouldDeliver: @escaping () -> Bool,
_ completion: @escaping () -> Void,
_ finalization: @escaping (TransportEventDeliveryOutcome) -> Void
) -> Void
}
/// Process-lifetime reservation cache for stable private-media IDs.
///
/// The first arrival reserves its ID before quota enforcement. Concurrent
/// arrivals remain coalesced in memory, while accepted state is resolved from
/// the durable ID-to-file ledger so it survives relaunch and becomes retryable
/// if quota cleanup removed the file.
private final class PrivateMediaArrivalDeduplicator {
enum Reservation {
case reserved
case pending
case accepted(URL)
case tombstoned
case unavailable
}
private let lock = NSLock()
private var pending: Set<String> = []
func reserve(
_ messageID: String,
receiptState: () -> BLEPrivateMediaReceiptState
) -> Reservation {
lock.lock()
defer { lock.unlock() }
if pending.contains(messageID) {
return .pending
}
switch receiptState() {
case .accepted(let existingURL):
return .accepted(existingURL)
case .tombstoned:
return .tombstoned
case .unavailable:
return .unavailable
case .absent:
break
}
pending.insert(messageID)
return .reserved
}
func finish(_ messageID: String) {
lock.lock()
defer { lock.unlock() }
pending.remove(messageID)
}
}
/// Orchestrates inbound file transfers: self-echo policy, sender display-name
@@ -41,61 +118,204 @@ struct BLEFileTransferHandlerEnvironment {
/// and UI delivery.
final class BLEFileTransferHandler {
private let environment: BLEFileTransferHandlerEnvironment
private let privateMediaArrivals = PrivateMediaArrivalDeduplicator()
init(environment: BLEFileTransferHandlerEnvironment) {
self.environment = environment
}
/// Returns `false` when the packet fails sender authentication and must
/// not be relayed onward. Every other outcome returns `true`: files
/// directed to another peer are forwarded untouched, and local-only drops
/// (malformed payload, quota, save failure) don't affect multi-hop
/// delivery to nodes that may handle them fine.
/// Returns `false` when the raw packet fails sender authentication (or is
/// a live self-echo) and must not be relayed onward. Authentication runs
/// before the routing decision, so a forged directed packet cannot use a
/// node that is not its recipient as an unsigned forwarding hop.
@discardableResult
func handle(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
let env = environment
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return true }
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: env.localPeerID()) else {
return true
}
let localPeerID = env.localPeerID()
let peersSnapshot = env.peersSnapshot()
guard let senderNickname = resolveSenderNickname(
guard let senderNickname = authenticatedRawSenderNickname(
packet: packet,
from: peerID,
isBroadcast: !deliveryPlan.isPrivateMessage,
peers: peersSnapshot,
env: env
) else {
SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))", category: .security)
SecureLogger.warning("🚫 Dropping raw file transfer with missing/invalid signature from \(peerID.id.prefix(8))", category: .security)
return false
}
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: localPeerID) {
return false
}
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: localPeerID) else {
return true
}
if deliveryPlan.shouldTrackForSync {
env.trackPacketSeen(packet)
}
_ = storeIncomingPayload(
packet.payload,
from: peerID,
senderNickname: senderNickname,
timestamp: Date(timeIntervalSince1970: Double(packet.timestamp) / 1000),
isPrivate: deliveryPlan.isPrivateMessage,
usesDurableReceipts: false,
env: env
)
// Once authenticated, a local decode/quota/save failure is not proof
// that downstream nodes should be denied the valid signed packet.
return true
}
/// Accepts a file packet only after it has been authenticated and
/// decrypted by the peer's Noise session. The inner packet deliberately
/// has no redundant signature: Noise supplies sender authentication and
/// confidentiality, while this handler retains the same validation,
/// quota, persistence, and UI-delivery behavior as public files.
@discardableResult
func handlePrivatePayload(_ payload: Data, from peerID: PeerID, timestamp: Date) -> Bool {
let env = environment
let peers = env.peersSnapshot()
let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
peerID: peerID,
localPeerID: env.localPeerID(),
localNickname: env.localNickname(),
peers: peers,
allowConnectedUnverified: true
) ?? BLEPeerSenderDisplayName.anonymousNickname(for: peerID)
return storeIncomingPayload(
payload,
from: peerID,
senderNickname: senderNickname,
timestamp: timestamp,
isPrivate: true,
// Every authenticated Noise private-file keeps the stable ID/ACK
// contract introduced with capability bit 8. Bit 9 advertises
// sender-side automatic retry support; it must not downgrade
// prior iOS clients to random IDs or single-check delivery.
usesDurableReceipts: true,
env: env
)
}
private func storeIncomingPayload(
_ payload: Data,
from peerID: PeerID,
senderNickname: String,
timestamp: Date,
isPrivate: Bool,
usesDurableReceipts: Bool,
env: BLEFileTransferHandlerEnvironment
) -> Bool {
let localPeerID = env.localPeerID()
let filePacket: BitchatFilePacket
let mime: MimeType
switch BLEIncomingFileValidator.validate(payload: packet.payload) {
switch BLEIncomingFileValidator.validate(payload: payload) {
case .success(let acceptance):
filePacket = acceptance.filePacket
mime = acceptance.mime
case .failure(.malformedPayload):
SecureLogger.error("❌ Failed to decode file transfer payload", category: .session)
return true
return false
case .failure(.payloadTooLarge(let bytes)):
SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security)
return true
return false
case .failure(.unsupportedMime(let mimeType, let bytes)):
SecureLogger.warning("🚫 MIME REJECT: '\(mimeType ?? "<empty>")' not supported. Size=\(bytes)b from \(peerID.id.prefix(8))...", category: .security)
return true
return false
case .failure(.magicMismatch(let mime, let bytes, let prefixHex)):
SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(bytes)b prefix=[\(prefixHex)] from \(peerID.id.prefix(8))...", category: .security)
return false
}
if isPrivate, env.isPrivateMediaSenderBlocked(peerID) {
SecureLogger.debug(
"🚫 Dropping private media from blocked peer \(peerID.id.prefix(8))… before disk write",
category: .security
)
return true
}
let messageID = usesDurableReceipts
? PrivateMediaMessageIdentity.stableID(
for: filePacket,
senderPeerID: peerID,
recipientPeerID: localPeerID
)
: nil
if let messageID {
switch privateMediaArrivals.reserve(
messageID,
receiptState: { env.privateMediaReceiptState(messageID) }
) {
case .reserved:
break
case .pending:
// The first arrival has not reached durable storage yet.
// Coalesce this retry without ACKing so a failed first save
// remains retryable by the sender.
SecureLogger.debug(
"📁 Coalesced in-flight private media id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))",
category: .session
)
return true
case .accepted(let existingFile):
env.updatePeerLastSeen(peerID)
let message = incomingMessage(
messageID: messageID,
senderNickname: senderNickname,
timestamp: timestamp,
isPrivate: true,
peerID: peerID,
destination: existingFile,
category: storedMediaCategory(
for: existingFile,
fallback: mime.category
),
env: env
)
SecureLogger.debug(
"📁 Restored durable private media duplicate id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))… -> \(existingFile.lastPathComponent)",
category: .session
)
deliverStableMessage(
message,
messageID: messageID,
peerID: peerID,
expectedURL: existingFile,
env: env
)
return true
case .tombstoned:
// Explicit deletion is a durable terminal receiver decision.
env.updatePeerLastSeen(peerID)
env.acknowledgePrivateMedia(messageID, peerID)
SecureLogger.debug(
"📁 Dropped explicitly deleted private media id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))",
category: .session
)
return true
case .unavailable:
// Never turn an unreadable ledger into an empty ledger. The
// sender can retry after the transient storage failure clears.
SecureLogger.warning(
"📁 Withholding private media id=\(messageID.prefix(12))… while durable receipt state is unavailable",
category: .session
)
return true
}
}
defer {
if let messageID {
privateMediaArrivals.finish(messageID)
}
}
// BCH-01-002: Enforce storage quota before saving
env.enforceStorageQuota(filePacket.content.count)
@@ -106,82 +326,175 @@ final class BLEFileTransferHandler {
mime.defaultExtension,
mime.category.rawValue
) else {
return true
return false
}
if deliveryPlan.isPrivateMessage {
if let messageID,
!env.commitPrivateMediaFile(messageID, destination) {
// A payload without its durable ID mapping cannot safely suppress
// a retry after relaunch. Roll it back and withhold UI/ACK.
env.removeIncomingFile(destination)
return false
}
if isPrivate {
env.updatePeerLastSeen(peerID)
}
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
let message = BitchatMessage(
sender: senderNickname,
content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)",
timestamp: ts,
isRelay: false,
originalSender: nil,
isPrivate: deliveryPlan.isPrivateMessage,
recipientNickname: nil,
senderPeerID: peerID,
// Received messages need an explicit status: BitchatMessage
// defaults private messages to .sending, which the media views
// render as an in-flight send (empty reveal mask, disabled tap).
deliveryStatus: deliveryPlan.isPrivateMessage
? .delivered(to: env.localNickname(), at: ts)
: nil
let message = incomingMessage(
messageID: messageID,
senderNickname: senderNickname,
timestamp: timestamp,
isPrivate: isPrivate,
peerID: peerID,
destination: destination,
category: mime.category,
env: env
)
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
env.deliverMessage(message)
if let messageID {
deliverStableMessage(
message,
messageID: messageID,
peerID: peerID,
expectedURL: destination,
env: env
)
} else {
env.deliverMessage(
message,
{ true },
{},
{ outcome in
if outcome == .rejected {
// Raw media has no durable receipt that can redeliver
// it later. Do not leave a newly saved, UI-unowned file
// available for a stale fallback path to misidentify.
env.removeIncomingFile(destination)
} else {
// Plain delegates are invoked without synchronous
// insertion confirmation. Preserve the payload for
// that supported delivery path.
env.finishIncomingFileDelivery(destination)
}
}
)
}
return true
}
/// Resolves the authenticated display name for a file transfer's sender.
///
/// Directed (private) transfers are addressed to us specifically and keep
/// the lenient connected-peer path. Broadcast transfers carry an
/// attacker-controllable `senderID` exactly like public messages and public
/// voice frames registry membership alone is NOT proof of identity, so a
/// valid packet signature from the claimed sender is required before we
/// trust it. Without this, a peer that observed a public voice burst could
/// spoof a broadcast `voice_<burstID>.m4a` note under the talker's ID and
/// overwrite the signature-verified live bubble with attacker audio.
private func resolveSenderNickname(
private func deliverStableMessage(
_ message: BitchatMessage,
messageID: String,
peerID: PeerID,
expectedURL: URL,
env: BLEFileTransferHandlerEnvironment
) {
env.deliverMessage(
message,
{
guard case .accepted(let resolvedURL) =
env.privateMediaReceiptState(messageID) else {
return false
}
return resolvedURL.standardizedFileURL
== expectedURL.standardizedFileURL
},
{
env.acknowledgePrivateMedia(messageID, peerID)
},
{ _ in
env.finishIncomingFileDelivery(expectedURL)
}
)
}
private func incomingMessage(
messageID: String?,
senderNickname: String,
timestamp: Date,
isPrivate: Bool,
peerID: PeerID,
destination: URL,
category: MimeType.Category,
env: BLEFileTransferHandlerEnvironment
) -> BitchatMessage {
BitchatMessage(
id: messageID,
sender: senderNickname,
content: "\(category.messagePrefix)\(destination.lastPathComponent)",
timestamp: timestamp,
isRelay: false,
originalSender: nil,
isPrivate: isPrivate,
recipientNickname: nil,
senderPeerID: peerID,
// Received messages need an explicit status: BitchatMessage
// defaults private messages to .sending, which media views render
// as an in-flight send.
deliveryStatus: isPrivate
? .delivered(to: env.localNickname(), at: timestamp)
: nil
)
}
/// The durable URL is authoritative during reconstruction. A sender that
/// reuses a stable filename with a different MIME type must not change how
/// the already-stored payload renders.
private func storedMediaCategory(
for url: URL,
fallback: MimeType.Category
) -> MimeType.Category {
let mediaDirectory = url
.deletingLastPathComponent()
.deletingLastPathComponent()
.lastPathComponent
switch mediaDirectory {
case MimeType.Category.audio.mediaDir:
return .audio
case MimeType.Category.image.mediaDir:
return .image
case MimeType.Category.file.mediaDir:
return .file
default:
return fallback
}
}
/// Every remaining raw file transfer is signed, regardless of whether it
/// is broadcast, addressed to us, or merely passing through. Registry
/// signing keys are preferred; persisted identities cover peers that have
/// rotated or are not currently present in the registry.
private func authenticatedRawSenderNickname(
packet: BitchatPacket,
from peerID: PeerID,
isBroadcast: Bool,
peers: [PeerID: BLEPeerInfo],
env: BLEFileTransferHandlerEnvironment
) -> String? {
guard isBroadcast else {
return BLEPeerSenderDisplayName.resolveKnownPeer(
peerID: peerID,
localPeerID: env.localPeerID(),
localNickname: env.localNickname(),
peers: peers,
allowConnectedUnverified: true
) ?? env.signedSenderDisplayName(packet, peerID)
}
guard packet.signature != nil else { return nil }
// Our own broadcasts replayed back via gossip sync (ttl==0) are
// trivially authentic and cannot be verified against the peer registry
// or identity cache, so exempt self exactly as `BLEPublicMessageHandler`
// does. Verify against the signing key already in the
// (synchronously-updated) registry first, then fall back to the
// persisted-identity signature lookup for peers not yet cached there.
let isSelf = peerID == env.localPeerID()
let registrySigningKey = peers[peerID]?.signingPublicKey
let verifiedViaRegistry = !isSelf && (registrySigningKey.map { env.verifyPacketSignature(packet, $0) } ?? false)
let signedDisplayName = (isSelf || verifiedViaRegistry) ? nil : env.signedSenderDisplayName(packet, peerID)
guard isSelf || verifiedViaRegistry || signedDisplayName != nil else { return nil }
let localPeerID = env.localPeerID()
let candidateKey = peerID == localPeerID
? env.localSigningPublicKey()
: peers[peerID]?.signingPublicKey
let verifiedWithKnownKey = candidateKey.map {
env.verifyPacketSignature(packet, $0)
} ?? false
let signedDisplayName = verifiedWithKnownKey
? nil
: env.signedSenderDisplayName(packet, peerID)
guard verifiedWithKnownKey || signedDisplayName != nil else { return nil }
return BLEPeerSenderDisplayName.resolveKnownPeer(
peerID: peerID,
localPeerID: env.localPeerID(),
localPeerID: localPeerID,
localNickname: env.localNickname(),
peers: peers,
allowConnectedUnverified: false
) ?? signedDisplayName
// The packet signature authenticates the announced peer; the old
// connected-but-unsigned leniency is not involved.
allowConnectedUnverified: true
) ?? signedDisplayName ?? BLEPeerSenderDisplayName.anonymousNickname(for: peerID)
}
}
@@ -201,8 +201,11 @@ struct BLEFragmentAssemblyBuffer {
}
private static func assemblyLimit(for originalType: UInt8) -> Int {
if originalType == MessageType.fileTransfer.rawValue {
if originalType == MessageType.fileTransfer.rawValue
|| originalType == MessageType.noiseEncrypted.rawValue {
// Allow headroom for TLV metadata and binary framing overhead.
// A large noiseEncrypted packet can be an E2E-encrypted private
// file; its authenticated plaintext is validated after decrypt.
return FileTransferLimits.maxFramedFileBytes
}
+455 -15
View File
@@ -2,17 +2,271 @@ import BitLogger
import BitFoundation
import Foundation
struct BLEIncomingFileStore {
private static let quotaBytes: Int64 = 100 * 1024 * 1024
struct PanicRecoveryIntent {
let fileMarkerEstablished: Bool
let externalMarkerEstablished: Bool
private let fileManager: FileManager
var hasDurableMarker: Bool {
fileMarkerEstablished || externalMarkerEstablished
}
}
/// Small, dependency-injectable transaction surface used by ChatViewModel.
/// Production persists the same intent in two independent locations before
/// any application state is erased. Tests can inject an ephemeral operation
/// set without touching the developer's Application Support directory.
struct PanicRecoveryOperations {
let isPending: () throws -> Bool
let begin: () -> PanicRecoveryIntent
let wipeMedia: (PanicRecoveryIntent) throws -> Void
let complete: () throws -> Void
static func ephemeral(
wipeMedia: @escaping () throws -> Void = {}
) -> PanicRecoveryOperations {
PanicRecoveryOperations(
isPending: { false },
begin: {
PanicRecoveryIntent(
fileMarkerEstablished: false,
externalMarkerEstablished: false
)
},
wipeMedia: { _ in try wipeMedia() },
complete: {}
)
}
static func live(
fileStore: BLEIncomingFileStore = BLEIncomingFileStore(),
defaults: UserDefaults = .standard
) -> PanicRecoveryOperations {
let defaultsKey = "bitchat.panicResetPending"
return PanicRecoveryOperations(
isPending: {
if defaults.bool(forKey: defaultsKey) {
return true
}
return try fileStore.isPanicRecoveryPending()
},
begin: {
defaults.set(true, forKey: defaultsKey)
let externalMarkerEstablished =
defaults.synchronize()
&& defaults.bool(forKey: defaultsKey)
let fileMarkerEstablished: Bool
do {
try fileStore.markPanicRecoveryPending()
fileMarkerEstablished = true
} catch {
fileMarkerEstablished = false
SecureLogger.error(
"Failed to persist file panic-recovery marker: \(error)",
category: .security
)
}
return PanicRecoveryIntent(
fileMarkerEstablished: fileMarkerEstablished,
externalMarkerEstablished: externalMarkerEstablished
)
},
wipeMedia: { intent in
try fileStore.panicWipe(
hasDurablePendingMarker: intent.hasDurableMarker
)
},
complete: {
// Keep the independent defaults latch until the file marker
// has definitely cleared. Any failure therefore remains
// visible to the next launch.
try fileStore.completePanicRecovery()
defaults.removeObject(forKey: defaultsKey)
guard defaults.synchronize(),
!defaults.bool(forKey: defaultsKey) else {
throw BLEIncomingFileStore.PanicRecoveryError
.externalMarkerCommitFailed
}
}
)
}
}
struct BLEIncomingFileStore: @unchecked Sendable {
enum PanicRecoveryError: Error {
case externalMarkerCommitFailed
case markerWriteFailed(Error)
case markerWriteAndMediaWipeFailed(
markerError: Error,
mediaError: Error
)
}
struct PrivateMediaDeletionReservation: Sendable {
fileprivate let id: UUID
}
private final class PayloadCoordination: @unchecked Sendable {
let lock = NSLock()
var pendingDeliveryPaths: Set<String> = []
var deletionReservations: [UUID: Set<String>] = [:]
}
private static let defaultQuotaBytes: Int64 = 100 * 1024 * 1024
/// Kept outside `files/` so deleting the media tree cannot erase the
/// fail-closed startup decision before the full panic has committed.
private static let panicRecoveryPendingMarkerFileName =
".panic-recovery-pending"
/// Compatibility with a short-lived development build that used the
/// media-specific name for the same full-transaction latch.
private static let legacyPanicRecoveryPendingMarkerFileName =
".panic-media-wipe-pending"
private static let mediaSubdirectories = [
"voicenotes/incoming",
"voicenotes/outgoing",
"images/incoming",
"images/outgoing",
"files/incoming",
"files/outgoing"
]
/// Name prefix of in-flight live voice captures (progressively written by
/// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern
/// deleting one mid-stream unlinks the inode under an open `FileHandle`
/// and kills playback and the coordinator's startup sweep deletes any
/// orphans a previous session left behind.
static let liveCapturePrefix = "voice_live_"
/// Exposed so callers that write progressively into the store's
/// directories (live voice captures) share the same file manager.
let fileManager: FileManager
private let baseDirectory: URL?
private let dateProvider: () -> Date
private let panicMarkerWriter: (Data, URL) throws -> Void
private let quotaBytes: Int64
private let privateMediaReceipts: BLEPrivateMediaReceiptStore
private let payloadCoordination: PayloadCoordination
init(fileManager: FileManager = .default, baseDirectory: URL? = nil, dateProvider: @escaping () -> Date = Date.init) {
init(
fileManager: FileManager = .default,
baseDirectory: URL? = nil,
dateProvider: @escaping () -> Date = Date.init,
panicMarkerWriter: @escaping (Data, URL) throws -> Void = {
try $0.write(to: $1, options: .atomic)
},
quotaBytes: Int64 = Self.defaultQuotaBytes
) {
self.fileManager = fileManager
self.baseDirectory = baseDirectory
self.dateProvider = dateProvider
self.panicMarkerWriter = panicMarkerWriter
self.quotaBytes = max(0, quotaBytes)
self.privateMediaReceipts = BLEPrivateMediaReceiptStore(
fileManager: fileManager,
baseDirectory: baseDirectory,
now: dateProvider
)
self.payloadCoordination = PayloadCoordination()
}
/// Panic-wipe every managed incoming and outgoing media artifact before
/// returning. Recreating the directory tree keeps later capture/receive
/// paths usable without allowing a detached cleanup task to race them.
///
/// Marker persistence and deletion are deliberately separate error
/// domains: even when both durable marker channels fail, deletion is
/// still attempted before this method reports the marker failure.
func panicWipe(
hasDurablePendingMarker: Bool = false
) throws {
// The receipt index caches tombstones as well as accepted payloads,
// while payload coordination retains save/delete reservations. Always
// invalidate both on return, including partial-failure paths, so no
// pre-panic receiver decision survives after identity reset.
defer {
privateMediaReceipts.resetForPanic()
payloadCoordination.lock.lock()
payloadCoordination.pendingDeliveryPaths.removeAll(
keepingCapacity: false
)
payloadCoordination.deletionReservations.removeAll(
keepingCapacity: false
)
payloadCoordination.lock.unlock()
}
let markerError: Error?
do {
try markPanicRecoveryPending()
markerError = nil
} catch {
markerError = error
SecureLogger.error(
"Could not persist file panic-recovery marker; attempting media deletion anyway: \(error)",
category: .security
)
}
do {
let filesDirectory = try rootDirectory()
.appendingPathComponent("files", isDirectory: true)
if fileManager.fileExists(atPath: filesDirectory.path) {
try fileManager.removeItem(at: filesDirectory)
}
for subdirectory in Self.mediaSubdirectories {
try fileManager.createDirectory(
at: filesDirectory.appendingPathComponent(
subdirectory,
isDirectory: true
),
withIntermediateDirectories: true,
attributes: nil
)
}
} catch {
if let markerError {
throw PanicRecoveryError.markerWriteAndMediaWipeFailed(
markerError: markerError,
mediaError: error
)
}
throw error
}
if let markerError, !hasDurablePendingMarker {
throw PanicRecoveryError.markerWriteFailed(markerError)
}
}
func markPanicRecoveryPending() throws {
let markerURL = try panicRecoveryPendingMarkerURL()
try fileManager.createDirectory(
at: markerURL.deletingLastPathComponent(),
withIntermediateDirectories: true,
attributes: nil
)
try panicMarkerWriter(Data([1]), markerURL)
}
func isPanicRecoveryPending() throws -> Bool {
try panicRecoveryMarkerURLs().contains {
fileManager.fileExists(atPath: $0.path)
}
}
func completePanicRecovery() throws {
for markerURL in try panicRecoveryMarkerURLs()
where fileManager.fileExists(atPath: markerURL.path) {
try fileManager.removeItem(at: markerURL)
}
}
/// Resolves (and creates) an incoming-media directory for callers that
/// write progressively instead of via `save` (live voice captures).
func incomingDirectory(subdirectory: String) throws -> URL {
let directory = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
return directory
}
func save(
@@ -22,6 +276,9 @@ struct BLEIncomingFileStore {
fallbackExtension: String?,
defaultPrefix: String
) -> URL? {
payloadCoordination.lock.lock()
defer { payloadCoordination.lock.unlock() }
do {
let base = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
try fileManager.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil)
@@ -30,8 +287,26 @@ struct BLEIncomingFileStore {
defaultName: "\(defaultPrefix)_\(Self.timestampString(from: dateProvider()))",
fallbackExtension: fallbackExtension
)
let destination = uniqueFileURL(in: base, fileName: sanitized)
let reservedPaths = privateMediaReceipts.reservedPayloadPaths()
let deletionPaths = payloadCoordination
.deletionReservations.values.reduce(into: Set<String>()) {
$0.formUnion($1)
}
let allocationReservations = deletionPaths.union(
payloadCoordination.pendingDeliveryPaths
)
let destination = uniqueFileURL(
in: base,
fileName: sanitized,
reservedPaths: (reservedPaths ?? []).union(
allocationReservations
),
forceRandomizedName: reservedPaths == nil
)
try data.write(to: destination, options: .atomic)
payloadCoordination.pendingDeliveryPaths.insert(
destination.standardizedFileURL.path
)
return destination
} catch {
SecureLogger.error("❌ Failed to persist incoming media: \(error)", category: .session)
@@ -39,7 +314,111 @@ struct BLEIncomingFileStore {
}
}
func privateMediaReceiptState(
messageID: String
) -> BLEPrivateMediaReceiptState {
privateMediaReceipts.state(for: messageID)
}
func commitPrivateMediaFile(
messageID: String,
storedURL: URL
) -> Bool {
privateMediaReceipts.commitAccepted(
messageID: messageID,
storedURL: storedURL
)
}
/// Reserves every receipt/UI path before the asynchronous deletion
/// barrier. Allocation and reservation share one lock, so either an
/// in-flight raw arrival is observed and deletion fails closed, or the
/// arrival is forced onto a different filename.
func reservePrivateMediaDeletion(
messageIDs: [String],
payloadRelativePaths: [String: String]
) -> PrivateMediaDeletionReservation? {
payloadCoordination.lock.lock()
defer { payloadCoordination.lock.unlock() }
guard let paths = privateMediaReceipts
.prospectiveDeletionPayloadPaths(
messageIDs: messageIDs,
payloadRelativePaths: payloadRelativePaths
),
paths.isDisjoint(
with: payloadCoordination.pendingDeliveryPaths
) else {
return nil
}
let reservation = PrivateMediaDeletionReservation(id: UUID())
payloadCoordination.deletionReservations[reservation.id] = paths
return reservation
}
func commitPrivateMediaDeletion(
reservation: PrivateMediaDeletionReservation,
messageIDs: [String],
payloadRelativePaths: [String: String],
protectedPayloadRelativePaths: Set<String>
) -> Bool {
payloadCoordination.lock.lock()
defer {
payloadCoordination.deletionReservations.removeValue(
forKey: reservation.id
)
payloadCoordination.lock.unlock()
}
guard payloadCoordination.deletionReservations[reservation.id] != nil
else {
return false
}
return privateMediaReceipts.recordDeleted(
messageIDs: messageIDs,
payloadRelativePaths: payloadRelativePaths,
protectedPayloadRelativePaths: protectedPayloadRelativePaths
)
}
/// Releases the short window between disk save and synchronous
/// conversation insertion. Before this callback, a deletion transaction
/// may not infer ownership from a stale bubble that names the same path.
func finishIncomingFileDelivery(at storedURL: URL) {
payloadCoordination.lock.lock()
defer { payloadCoordination.lock.unlock() }
payloadCoordination.pendingDeliveryPaths.remove(
storedURL.standardizedFileURL.path
)
}
/// Best-effort rollback for a payload whose durable receipt commit failed.
func removeIncomingFile(at storedURL: URL) {
payloadCoordination.lock.lock()
defer { payloadCoordination.lock.unlock() }
payloadCoordination.pendingDeliveryPaths.remove(
storedURL.standardizedFileURL.path
)
guard isURLInsideFilesDirectory(storedURL) else { return }
do {
try fileManager.removeItem(at: storedURL)
} catch {
SecureLogger.warning(
"⚠️ Failed to roll back uncommitted incoming media: \(error)",
category: .session
)
}
}
/// Frees least-recently-modified incoming files until `reservingBytes`
/// fits under the quota. Files named `voice_live_*` (in-flight live
/// captures) are never evicted regardless of who triggers enforcement
/// a finalized transfer can arrive at quota while a burst is still
/// streaming but they still count toward usage.
func enforceQuota(reservingBytes: Int) {
payloadCoordination.lock.lock()
defer { payloadCoordination.lock.unlock() }
do {
let base = try filesDirectory()
let incomingDirs = [
@@ -65,13 +444,26 @@ struct BLEIncomingFileStore {
}
let currentUsage = allFiles.reduce(0) { $0 + $1.size }
let targetUsage = Self.quotaBytes - Int64(reservingBytes)
let targetUsage = quotaBytes - Int64(reservingBytes)
guard currentUsage > targetUsage else { return }
let needToFree = currentUsage - targetUsage
let activeDeletionPaths = payloadCoordination
.deletionReservations.values.reduce(into: Set<String>()) {
$0.formUnion($1)
}
let protectedPaths = activeDeletionPaths.union(
payloadCoordination.pendingDeliveryPaths
)
var freedSpace: Int64 = 0
for file in allFiles.sorted(by: { $0.modified < $1.modified }) {
guard freedSpace < needToFree else { break }
guard !file.url.lastPathComponent.hasPrefix(Self.liveCapturePrefix) else { continue }
guard !protectedPaths.contains(
file.url.standardizedFileURL.path
) else {
continue
}
do {
try fileManager.removeItem(at: file.url)
freedSpace += file.size
@@ -90,15 +482,46 @@ struct BLEIncomingFileStore {
}
private func filesDirectory() throws -> URL {
let root = try baseDirectory ?? fileManager.url(
let filesDir = try rootDirectory().appendingPathComponent("files", isDirectory: true)
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
return filesDir
}
private func rootDirectory() throws -> URL {
try baseDirectory ?? fileManager.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let filesDir = root.appendingPathComponent("files", isDirectory: true)
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
return filesDir
}
private func panicRecoveryPendingMarkerURL() throws -> URL {
try rootDirectory().appendingPathComponent(
Self.panicRecoveryPendingMarkerFileName,
isDirectory: false
)
}
private func panicRecoveryMarkerURLs() throws -> [URL] {
let root = try rootDirectory()
return [
root.appendingPathComponent(
Self.panicRecoveryPendingMarkerFileName,
isDirectory: false
),
root.appendingPathComponent(
Self.legacyPanicRecoveryPendingMarkerFileName,
isDirectory: false
)
]
}
private func isURLInsideFilesDirectory(_ url: URL) -> Bool {
guard let filesDirectory = try? filesDirectory().standardizedFileURL else {
return false
}
return url.standardizedFileURL.path.hasPrefix(filesDirectory.path + "/")
}
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
@@ -128,11 +551,20 @@ struct BLEIncomingFileStore {
return candidate.isEmpty ? defaultName : candidate
}
private func uniqueFileURL(in directory: URL, fileName: String) -> URL {
private func uniqueFileURL(
in directory: URL,
fileName: String,
reservedPaths: Set<String>,
forceRandomizedName: Bool
) -> URL {
let directoryPath = directory.standardizedFileURL.path
func isInsideDirectory(_ url: URL) -> Bool {
url.standardizedFileURL.path.hasPrefix(directoryPath + "/")
}
func isAvailable(_ url: URL) -> Bool {
!reservedPaths.contains(url.standardizedFileURL.path)
&& !fileManager.fileExists(atPath: url.path)
}
var candidate = directory.appendingPathComponent(fileName)
guard isInsideDirectory(candidate) else {
@@ -140,19 +572,27 @@ struct BLEIncomingFileStore {
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
}
if !fileManager.fileExists(atPath: candidate.path) {
let baseName = (fileName as NSString).deletingPathExtension
let ext = (fileName as NSString).pathExtension
if forceRandomizedName {
let suffix = UUID().uuidString
let randomizedName = ext.isEmpty
? "\(baseName)_\(suffix)"
: "\(baseName)_\(suffix).\(ext)"
return directory.appendingPathComponent(randomizedName)
}
if isAvailable(candidate) {
return candidate
}
let baseName = (fileName as NSString).deletingPathExtension
let ext = (fileName as NSString).pathExtension
for counter in 1..<100 {
let newName = ext.isEmpty ? "\(baseName) (\(counter))" : "\(baseName) (\(counter)).\(ext)"
candidate = directory.appendingPathComponent(newName)
guard isInsideDirectory(candidate) else {
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
}
if !fileManager.fileExists(atPath: candidate.path) {
if isAvailable(candidate) {
return candidate
}
}
+25 -3
View File
@@ -164,7 +164,11 @@ final class BLELinkStateStore {
guard let peerID else { return [] }
var links: Set<BLEIngressLinkID> = []
if let peripheralUUID = peerToPeripheralUUID[peerID] {
// Scan all states rather than the 1:1 reverse map: after a state
// restoration the same device can hold several live peripheral links
// bound to one peer (it reappears under a fresh UUID while the
// restored connection lives on).
for (peripheralUUID, state) in peripherals where state.peerID == peerID {
links.insert(.peripheral(peripheralUUID))
}
for (centralUUID, mappedPeerID) in centralToPeerID where mappedPeerID == peerID {
@@ -173,6 +177,13 @@ final class BLELinkStateStore {
return links
}
/// The peer's most recently bound peripheral link, per peer. Used to keep
/// duplicate-link fanout collapse deterministic (see BLEFanoutSelector).
var preferredPeripheralBindings: [PeerID: String] {
assertOwned()
return peerToPeripheralUUID
}
func peerID(forPeripheralID peripheralID: String) -> PeerID? {
assertOwned()
return peripherals[peripheralID]?.peerID
@@ -221,8 +232,19 @@ final class BLELinkStateStore {
func removePeripheral(_ peripheralID: String) -> PeerID? {
assertOwned()
let peerID = peripherals.removeValue(forKey: peripheralID)?.peerID
if let peerID {
peerToPeripheralUUID.removeValue(forKey: peerID)
// Only clear (or repair) the reverse map when it points at the removed
// link: with duplicate links to one peer, removing a stale duplicate
// must not strand the peer's surviving bound link.
if let peerID, peerToPeripheralUUID[peerID] == peripheralID {
// Prefer a writable survivor: repairing onto a link that is
// mid-service-rediscovery would strand directed sends until the
// characteristic comes back.
let survivors = peripherals.filter { $0.value.peerID == peerID && $0.value.isConnected }
if let survivorUUID = survivors.first(where: { $0.value.characteristic != nil })?.key ?? survivors.first?.key {
peerToPeripheralUUID[peerID] = survivorUUID
} else {
peerToPeripheralUUID.removeValue(forKey: peerID)
}
}
return peerID
}
@@ -1,7 +1,13 @@
import BitFoundation
import BitLogger
import CryptoKit
import Foundation
struct BLENoiseDecryptionResult {
let plaintext: Data
let sessionGeneration: UUID
}
/// Narrow environment for `BLENoisePacketHandler`.
///
/// All queue hops (collections barrier writes, main-actor UI notification)
@@ -20,6 +26,8 @@ struct BLENoisePacketHandlerEnvironment {
let processHandshakeMessage: (_ peerID: PeerID, _ message: Data) throws -> Data?
/// Whether any Noise session (established or pending) exists for the peer (crypto).
let hasNoiseSession: (PeerID) -> Bool
/// Whether an inbound ordinary XX responder is waiting for message 3.
let isAwaitingResponderHandshakeCompletion: (PeerID) -> Bool
/// Initiates a fresh Noise handshake with the peer (crypto + send).
let initiateHandshake: (PeerID) -> Void
/// Broadcasts a packet on the mesh (caller is already on the message queue).
@@ -27,9 +35,16 @@ struct BLENoisePacketHandlerEnvironment {
/// Updates the registry last-seen timestamp for the peer (async barrier write).
let updatePeerLastSeen: (PeerID) -> Void
/// Decrypts an encrypted payload from the peer (crypto).
let decrypt: (_ payload: Data, _ peerID: PeerID) throws -> Data
let decrypt: (_ payload: Data, _ peerID: PeerID) throws -> BLENoiseDecryptionResult
/// Clears the peer's Noise session after an unrecoverable decrypt failure (crypto).
let clearSession: (PeerID) -> Void
/// Consumes session-authenticated protocol state inside the transport. It
/// must never escape to UI or Nostr payload dispatch.
let handleAuthenticatedPeerState: (
_ peerID: PeerID,
_ payload: Data,
_ sessionGeneration: UUID
) -> Void
/// Delivers `.noisePayloadReceived` to the UI as one main-actor hop.
let deliverNoisePayload: (
_ peerID: PeerID,
@@ -43,13 +58,38 @@ struct BLENoisePacketHandlerEnvironment {
/// processing (with response), encrypted payload decryption and dispatch,
/// and session recovery on decrypt failure.
final class BLENoisePacketHandler {
private struct DeferredCiphertext {
let packet: BitchatPacket
let receivedAt: Date
}
/// Early post-handshake packets are normally tiny control messages or
/// queued DMs. Keep the recovery surface deliberately small so an
/// unauthenticated half-handshake cannot create an unbounded memory queue.
private static let maxDeferredPacketsPerPeer = 4
private static let maxDeferredPacketsGlobal = 32
/// One legacy sender can immediately follow message 3 with the largest
/// valid private-file ciphertext and has no application-level retry. Keep
/// room for that packet plus a small control-message budget.
private static let maxDeferredBytes =
NoiseSecurityConstants.maxPrivateFileCiphertextSize + 256 * 1024
private static let deferredLifetime =
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
private let environment: BLENoisePacketHandlerEnvironment
private let deferredLock = NSLock()
private var deferredCiphertexts: [PeerID: [DeferredCiphertext]] = [:]
private var deferredCiphertextBytes = 0
init(environment: BLENoisePacketHandlerEnvironment) {
self.environment = environment
}
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
/// Returns true when the handshake message was processed successfully.
/// Callers use this to distinguish an authenticated reconnect completion
/// from a rejected ordinary responder while rollback state is restored.
@discardableResult
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
let env = environment
// Use NoiseEncryptionService for handshake processing
if PeerID(hexData: packet.recipientID) == env.localPeerID() {
@@ -70,19 +110,60 @@ final class BLENoisePacketHandler {
env.broadcastPacket(responsePacket)
}
// Session establishment will trigger onPeerAuthenticated callback
// which will send any pending messages at the right time
// The serialized authentication callback installs transport
// state before it drains any bounded early ciphertext.
return true
} catch let managedFailure as NoiseManagedHandshakeFailure {
SecureLogger.error(
"Failed to process handshake; manager owns recovery: \(managedFailure.underlying)"
)
return false
} catch NoiseSessionError.peerIdentityMismatch {
// The responder was already discarded by the session manager.
// Do not let a spoofed claimed ID trigger a fresh outbound
// handshake or recreate state for the attacker-selected ID.
SecureLogger.warning(
"Rejected Noise handshake whose static key does not match \(peerID.id.prefix(8))",
category: .security
)
return false
} catch {
SecureLogger.error("Failed to process handshake: \(error)")
// Try initiating a new handshake
if !env.hasNoiseSession(peerID) {
env.initiateHandshake(peerID)
}
return false
}
}
return false
}
func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
handleEncrypted(packet, from: peerID, isDeferredRetry: false)
}
/// Called by the transport's serialized authentication callback after it
/// has installed state for the promoted or restored session generation.
func handleSessionAuthenticated(_ peerID: PeerID) {
drainDeferredCiphertextsIfReady(for: peerID)
}
/// Synchronously discards ciphertext retained for a pre-panic Noise
/// generation. The handler survives the service's identity replacement,
/// so keeping this queue would replay old bytes after post-panic auth.
func resetForPanic() {
deferredLock.lock()
deferredCiphertexts.removeAll(keepingCapacity: false)
deferredCiphertextBytes = 0
deferredLock.unlock()
}
private func handleEncrypted(
_ packet: BitchatPacket,
from peerID: PeerID,
isDeferredRetry: Bool
) {
let env = environment
guard let recipientID = PeerID(hexData: packet.recipientID) else {
SecureLogger.warning("⚠️ Encrypted message has no recipient ID", category: .session)
@@ -98,35 +179,231 @@ final class BLENoisePacketHandler {
env.updatePeerLastSeen(peerID)
do {
let decrypted = try env.decrypt(packet.payload, peerID)
let decryption = try env.decrypt(packet.payload, peerID)
let decrypted = decryption.plaintext
guard decrypted.count > 0 else { return }
// First byte indicates the payload type
let payloadType = decrypted[0]
let payloadData = decrypted.dropFirst()
guard let noisePayloadType = NoisePayloadType(rawValue: payloadType) else {
guard let noisePayloadType = NoisePayloadType.decoded(rawValue: payloadType) else {
SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)")
return
}
SecureLogger.debug("🔐 Decrypted noise payload type \(noisePayloadType.description) from \(peerID.id.prefix(8))", category: .session)
if noisePayloadType == .authenticatedPeerState {
env.handleAuthenticatedPeerState(
peerID,
Data(payloadData),
decryption.sessionGeneration
)
return
}
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
env.deliverNoisePayload(peerID, noisePayloadType, Data(payloadData), ts)
} catch NoiseEncryptionError.transportGenerationNotReady {
if isDeferredRetry {
SecureLogger.warning(
"Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… because its authenticated transport generation changed again",
category: .session
)
return
}
// The manager promoted or restored keys before BLE's serialized
// callback installed generation-bound transport state. The
// manager rejected this before decrypting, so replay is safe.
deferCiphertext(packet, from: peerID)
} catch NoiseEncryptionError.sessionNotEstablished {
if isDeferredRetry {
SecureLogger.warning(
"Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… because the authenticated session is unavailable",
category: .session
)
return
}
// We received an encrypted message before establishing a session with this peer.
// Trigger a handshake so future messages can be decrypted.
// An initiator may already have sent message 3 followed by this
// ciphertext, with BLE delivering the ciphertext first.
if env.isAwaitingResponderHandshakeCompletion(peerID) {
deferCiphertext(packet, from: peerID)
return
}
// Otherwise trigger a handshake so future messages can decrypt.
SecureLogger.debug("🔑 Encrypted message from \(peerID.id.prefix(8))… without session; initiating handshake")
if !env.hasNoiseSession(peerID) {
env.initiateHandshake(peerID)
}
} catch {
if isDeferredRetry {
// An early packet cannot tear down the authenticated session
// merely because its single bounded retry still fails.
SecureLogger.warning(
"Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… after retry failed: \(error)",
category: .session
)
return
}
// A responder may retain an older transport as receive-only
// rollback state while ordinary XX waits for message 3. New-key
// ciphertext can fail against those retained receive keys first.
if env.isAwaitingResponderHandshakeCompletion(peerID) {
if isDeferrableEarlyHandshakeFailure(error) {
deferCiphertext(packet, from: peerID)
} else {
SecureLogger.warning(
"Dropping invalid Noise ciphertext from \(peerID.id.prefix(8))… while responder handshake is completing: \(error)",
category: .session
)
}
return
}
if isDropOnlyCiphertextFailure(error) {
// The packet is attacker-controlled and did not prove a
// transport-state failure. Never let malformed, replayed,
// forged, oversized, or rate-limited bytes evict working keys.
SecureLogger.warning(
"Dropping rejected Noise ciphertext from \(peerID.id.prefix(8))… without clearing its session: \(error)",
category: .security
)
return
}
// Decryption failed - clear the corrupted session and re-initiate handshake
// This handles cases where session state got out of sync (nonce mismatch, etc.)
// Only local/session lifecycle failures reach this path.
SecureLogger.error("❌ Failed to decrypt message from \(peerID.id.prefix(8))…: \(error) - clearing session and re-initiating handshake")
env.clearSession(peerID)
env.initiateHandshake(peerID)
}
}
private func isDeferrableEarlyHandshakeFailure(_ error: Error) -> Bool {
if let noiseError = error as? NoiseError {
switch noiseError {
case .authenticationFailure, .replayDetected:
return true
default:
return false
}
}
if let cryptoError = error as? CryptoKitError,
case .authenticationFailure = cryptoError {
return true
}
return false
}
private func isDropOnlyCiphertextFailure(_ error: Error) -> Bool {
if let securityError = error as? NoiseSecurityError {
switch securityError {
case .messageTooLarge, .rateLimitExceeded, .invalidPeerID:
return true
case .sessionExpired, .sessionExhausted:
return false
}
}
if let noiseError = error as? NoiseError {
switch noiseError {
case .invalidCiphertext, .authenticationFailure, .replayDetected:
return true
case .uninitializedCipher, .handshakeComplete,
.handshakeNotComplete, .missingLocalStaticKey,
.missingKeys, .invalidMessage, .invalidPublicKey,
.nonceExceeded:
return false
}
}
return error is CryptoKitError
}
private func deferCiphertext(_ packet: BitchatPacket, from peerID: PeerID) {
guard NoiseSecurityValidator.validatePrivateFileCiphertextSize(
packet.payload
) else {
SecureLogger.warning(
"Dropping oversized early Noise ciphertext from \(peerID.id.prefix(8))",
category: .security
)
return
}
let now = environment.now()
deferredLock.lock()
defer { deferredLock.unlock() }
purgeExpiredCiphertextsLocked(now: now)
let peerCount = deferredCiphertexts[peerID]?.count ?? 0
let globalCount = deferredCiphertexts.values.reduce(0) {
$0 + $1.count
}
guard peerCount < Self.maxDeferredPacketsPerPeer,
globalCount < Self.maxDeferredPacketsGlobal,
deferredCiphertextBytes + packet.payload.count
<= Self.maxDeferredBytes else {
SecureLogger.warning(
"Dropping early Noise ciphertext from \(peerID.id.prefix(8))… because the handshake buffer is full",
category: .security
)
return
}
deferredCiphertexts[peerID, default: []].append(
DeferredCiphertext(packet: packet, receivedAt: now)
)
deferredCiphertextBytes += packet.payload.count
SecureLogger.debug(
"Deferring early Noise ciphertext from \(peerID.id.prefix(8))… until responder handshake completion",
category: .session
)
}
private func drainDeferredCiphertextsIfReady(for peerID: PeerID) {
let env = environment
guard !env.isAwaitingResponderHandshakeCompletion(peerID),
env.hasNoiseSession(peerID) else {
return
}
let now = env.now()
deferredLock.lock()
purgeExpiredCiphertextsLocked(now: now)
let deferred = deferredCiphertexts.removeValue(forKey: peerID) ?? []
deferredCiphertextBytes -= deferred.reduce(0) {
$0 + $1.packet.payload.count
}
deferredLock.unlock()
guard !deferred.isEmpty else { return }
SecureLogger.debug(
"Retrying \(deferred.count) early Noise ciphertext packet(s) from \(peerID.id.prefix(8))… after handshake completion",
category: .session
)
for item in deferred {
handleEncrypted(item.packet, from: peerID, isDeferredRetry: true)
}
}
private func purgeExpiredCiphertextsLocked(now: Date) {
for peerID in Array(deferredCiphertexts.keys) {
guard let items = deferredCiphertexts[peerID] else { continue }
let retained = items.filter {
now.timeIntervalSince($0.receivedAt) <= Self.deferredLifetime
}
guard retained.count != items.count else { continue }
deferredCiphertextBytes -= items.reduce(0) {
$0 + $1.packet.payload.count
}
deferredCiphertextBytes += retained.reduce(0) {
$0 + $1.packet.payload.count
}
if retained.isEmpty {
deferredCiphertexts.removeValue(forKey: peerID)
} else {
deferredCiphertexts[peerID] = retained
}
}
}
}
@@ -17,6 +17,16 @@ enum BLENoisePayloadFactory {
typedPayload(.delivered, payload: Data(messageID.utf8))
}
static func privateFile(_ filePacket: BitchatFilePacket) -> Data? {
guard let payload = filePacket.encode() else { return nil }
return typedPayload(.privateFile, payload: payload)
}
static func authenticatedPeerState(_ state: AuthenticatedPeerStatePacket) -> Data? {
guard let payload = state.encode() else { return nil }
return typedPayload(.authenticatedPeerState, payload: payload)
}
static func typedPayload(_ type: NoisePayloadType, payload: Data) -> Data {
var typed = Data([type.rawValue])
typed.append(payload)
@@ -0,0 +1,40 @@
import Foundation
/// Bounds ordinary Noise revalidation to one attempt per physical-link epoch.
/// A live epoch may retry after the cooldown so a lost handshake cannot leave
/// the link permanently unauthenticated.
struct BLENoiseReconnectPolicy {
static let minimumRetryInterval: TimeInterval = 60
private var lastAttemptAt: [BLEIngressLinkID: Date] = [:]
mutating func shouldRevalidate(
on link: BLEIngressLinkID,
hasEstablishedSession: Bool,
isNoiseAuthenticatedLink: Bool,
hasAuthenticatedPeerLink: Bool,
now: Date
) -> Bool {
guard hasEstablishedSession,
!isNoiseAuthenticatedLink,
!hasAuthenticatedPeerLink else {
return false
}
if let previous = lastAttemptAt[link],
now.timeIntervalSince(previous) < Self.minimumRetryInterval {
return false
}
lastAttemptAt[link] = now
return true
}
/// Link identifiers can be stable across CoreBluetooth reconnects, so a
/// disconnect explicitly starts a new epoch and permits one fresh attempt.
mutating func endLinkEpoch(_ link: BLEIngressLinkID) {
lastAttemptAt.removeValue(forKey: link)
}
mutating func removeAll() {
lastAttemptAt.removeAll()
}
}
@@ -6,9 +6,16 @@ struct BLEPendingPrivateMessage: Equatable {
let messageID: String
}
struct BLEPendingTypedPayload: Equatable {
let payload: Data
/// Present for app-initiated media so handshake queuing preserves the
/// fragment scheduler's progress/cancellation identity.
let transferId: String?
}
struct BLENoiseSessionQueues {
private var privateMessagesByPeerID: [PeerID: [BLEPendingPrivateMessage]] = [:]
private var typedPayloadsByPeerID: [PeerID: [Data]] = [:]
private var typedPayloadsByPeerID: [PeerID: [BLEPendingTypedPayload]] = [:]
var isEmpty: Bool {
privateMessagesByPeerID.isEmpty && typedPayloadsByPeerID.isEmpty
@@ -34,13 +41,35 @@ struct BLENoiseSessionQueues {
privateMessagesByPeerID[peerID, default: []].insert(contentsOf: messages, at: 0)
}
mutating func appendTypedPayload(_ payload: Data, for peerID: PeerID) {
typedPayloadsByPeerID[peerID, default: []].append(payload)
mutating func appendTypedPayload(_ payload: Data, transferId: String? = nil, for peerID: PeerID) {
typedPayloadsByPeerID[peerID, default: []].append(
BLEPendingTypedPayload(payload: payload, transferId: transferId)
)
}
mutating func takeTypedPayloads(for peerID: PeerID) -> [Data] {
mutating func takeTypedPayloads(for peerID: PeerID) -> [BLEPendingTypedPayload] {
let payloads = typedPayloadsByPeerID[peerID] ?? []
typedPayloadsByPeerID.removeValue(forKey: peerID)
return payloads
}
func containsTypedPayload(transferId: String) -> Bool {
typedPayloadsByPeerID.values.contains { payloads in
payloads.contains { $0.transferId == transferId }
}
}
@discardableResult
mutating func removeTypedPayload(transferId: String) -> Bool {
for peerID in Array(typedPayloadsByPeerID.keys) {
guard var payloads = typedPayloadsByPeerID[peerID],
let index = payloads.firstIndex(where: { $0.transferId == transferId }) else {
continue
}
payloads.remove(at: index)
typedPayloadsByPeerID[peerID] = payloads.isEmpty ? nil : payloads
return true
}
return false
}
}
@@ -17,6 +17,9 @@ struct BLEOutboundFragmentPlan {
}
enum BLEOutboundFragmentPlanner {
/// Current Android receivers reject fragment sets above 256. Private
/// media v1 treats that deployed ceiling as a cross-platform contract.
static let privateMediaV1MaxFragments = 256
private static let minimumChunkSize = 64
private static let fragmentIDLength = 8
@@ -71,6 +74,10 @@ enum BLEOutboundFragmentPlanner {
)
}
static func isPrivateMediaV1Compatible(_ plan: BLEOutboundFragmentPlan) -> Bool {
plan.totalFragments <= privateMediaV1MaxFragments
}
private static func sizingPolicy(
for packet: BitchatPacket,
requestedMaxChunk: Int?,
@@ -7,10 +7,54 @@ struct BLEOutboundFragmentTransferRequest {
let maxChunk: Int?
let directedPeer: PeerID?
let transferId: String?
let requireDirectPeerLink: Bool
let requireNoiseAuthenticatedPeerLink: Bool
init(
packet: BitchatPacket,
pad: Bool,
maxChunk: Int?,
directedPeer: PeerID?,
transferId: String?,
requireDirectPeerLink: Bool = false,
requireNoiseAuthenticatedPeerLink: Bool = false
) {
self.packet = packet
self.pad = pad
self.maxChunk = maxChunk
self.directedPeer = directedPeer
self.transferId = transferId
self.requireDirectPeerLink = requireDirectPeerLink
self.requireNoiseAuthenticatedPeerLink = requireNoiseAuthenticatedPeerLink
}
var resolvedTransferId: String? {
if let transferId { return transferId }
guard packet.type == MessageType.fileTransfer.rawValue else { return nil }
return transferId ?? packet.payload.sha256Hex()
return packet.payload.sha256Hex()
}
/// Content identity independent of the caller-chosen transfer ID: the
/// same file resent through another path (gossip-sync replay, retry)
/// arrives with a different explicit transferId but identical payload.
var contentKey: String? {
guard packet.type == MessageType.fileTransfer.rawValue else { return nil }
return packet.payload.sha256Hex()
}
}
/// Transactional admission for strict fragment trains. Durable callers may
/// commit only when every fragment was accepted; the first rejection stops
/// the train and reports failure so the original remains retryable.
enum BLEStrictFragmentAdmission {
static func admitAll<Fragment>(
_ fragments: [Fragment],
accepting: (Fragment) -> Bool
) -> Bool {
for fragment in fragments where !accepting(fragment) {
return false
}
return true
}
}
@@ -23,6 +67,16 @@ struct BLEOutboundFragmentTransferScheduler {
enum SubmitResult {
case start(request: BLEOutboundFragmentTransferRequest, reservedTransferId: String?)
case queued(request: BLEOutboundFragmentTransferRequest, transferId: String?, position: QueuePosition)
/// Strict direct-link requests are transactional: returning false to
/// their durable owner must mean no process-local copy remains that
/// can transmit later. They are therefore start-or-reject, never
/// admitted to `pendingTransfers`.
case rejectedStrict(request: BLEOutboundFragmentTransferRequest, transferId: String?)
/// The same file is already being (or waiting to be) fragmented out
/// to an audience covering this request; sending it again would just
/// double the airtime (field-verified: one 41KB voice file went out
/// as two complete fragment streams).
case droppedDuplicate(request: BLEOutboundFragmentTransferRequest, activeTransferId: String?)
}
enum CancelResult {
@@ -38,14 +92,34 @@ struct BLEOutboundFragmentTransferScheduler {
}
private struct ActiveTransferState {
let totalFragments: Int
var totalFragments: Int
var sentFragments: Int
var workItems: [DispatchWorkItem]
var contentKey: String?
var directedPeer: PeerID?
}
private var activeTransfers: [String: ActiveTransferState] = [:]
private var pendingTransfers: [BLEOutboundFragmentTransferRequest] = []
/// A transfer of the same content whose audience covers `directedPeer`:
/// a broadcast covers every peer; a directed transfer covers only its
/// recipient. A directed resend to a peer NOT covered by what's in
/// flight (different recipient of a private file) is never a duplicate.
private func coveringDuplicate(contentKey: String, directedPeer: PeerID?) -> String? {
for (transferId, state) in activeTransfers where state.contentKey == contentKey {
if state.directedPeer == nil || state.directedPeer == directedPeer {
return transferId
}
}
for request in pendingTransfers where request.contentKey == contentKey {
if request.directedPeer == nil || request.directedPeer == directedPeer {
return request.resolvedTransferId
}
}
return nil
}
var activeCount: Int {
activeTransfers.count
}
@@ -69,17 +143,39 @@ struct BLEOutboundFragmentTransferScheduler {
return .start(request: request, reservedTransferId: nil)
}
// Only requests without an explicit transferId are dropped as
// duplicates: those are resend paths (gossip-sync replay, directed
// spool) with no UI waiting on them. An app-initiated send carries a
// transferId whose progress events the UI tracks, so it always runs.
if request.transferId == nil,
let contentKey = request.contentKey,
let coveringId = coveringDuplicate(contentKey: contentKey, directedPeer: request.directedPeer) {
return .droppedDuplicate(request: request, activeTransferId: coveringId)
}
guard activeTransfers.count < maxConcurrentTransfers else {
if request.requireDirectPeerLink {
return .rejectedStrict(request: request, transferId: transferId)
}
pendingTransfers.append(request)
return .queued(request: request, transferId: transferId, position: .back)
}
guard activeTransfers[transferId] == nil else {
if request.requireDirectPeerLink {
return .rejectedStrict(request: request, transferId: transferId)
}
pendingTransfers.insert(request, at: 0)
return .queued(request: request, transferId: transferId, position: .front)
}
activeTransfers[transferId] = ActiveTransferState(totalFragments: 0, sentFragments: 0, workItems: [])
activeTransfers[transferId] = ActiveTransferState(
totalFragments: 0,
sentFragments: 0,
workItems: [],
contentKey: request.contentKey,
directedPeer: request.directedPeer
)
return .start(request: request, reservedTransferId: transferId)
}
@@ -88,12 +184,11 @@ struct BLEOutboundFragmentTransferScheduler {
totalFragments: Int,
workItems: [DispatchWorkItem]
) -> Bool {
guard activeTransfers[transferId] != nil else { return false }
activeTransfers[transferId] = ActiveTransferState(
totalFragments: totalFragments,
sentFragments: 0,
workItems: workItems
)
guard var state = activeTransfers[transferId] else { return false }
state.totalFragments = totalFragments
state.sentFragments = 0
state.workItems = workItems
activeTransfers[transferId] = state
return true
}
@@ -149,13 +244,25 @@ struct BLEOutboundFragmentTransferScheduler {
while availableSlots > 0, !pendingTransfers.isEmpty {
let request = pendingTransfers.removeFirst()
availableSlots -= 1
guard let transferId = request.resolvedTransferId else {
availableSlots -= 1
results.append(.start(request: request, reservedTransferId: nil))
continue
}
// A queued duplicate of content that started while it waited
// must not resend the whole file once the slot frees up (same
// explicit-transferId exemption as submit).
if request.transferId == nil,
let contentKey = request.contentKey,
let coveringId = coveringDuplicate(contentKey: contentKey, directedPeer: request.directedPeer) {
results.append(.droppedDuplicate(request: request, activeTransferId: coveringId))
continue
}
availableSlots -= 1
guard activeTransfers.count < maxConcurrentTransfers else {
pendingTransfers.insert(request, at: 0)
results.append(.queued(request: request, transferId: transferId, position: .front))
@@ -168,7 +275,13 @@ struct BLEOutboundFragmentTransferScheduler {
continue
}
activeTransfers[transferId] = ActiveTransferState(totalFragments: 0, sentFragments: 0, workItems: [])
activeTransfers[transferId] = ActiveTransferState(
totalFragments: 0,
sentFragments: 0,
workItems: [],
contentKey: request.contentKey,
directedPeer: request.directedPeer
)
results.append(.start(request: request, reservedTransferId: transferId))
}
@@ -20,22 +20,15 @@ enum BLEOutboundLinkPlanner {
excludedLinks: Set<BLEIngressLinkID>,
peripheralPeerBindings: [String: PeerID] = [:],
centralPeerBindings: [String: PeerID] = [:],
directedOnlyPeer: PeerID?
preferredPeripheralPerPeer: [PeerID: String] = [:],
directAnnounceTTL: UInt8 = TransportConfig.messageTTLDefault,
directedOnlyPeer: PeerID?,
requireDirectPeerLink: Bool = false
) -> BLEOutboundLinkPlan {
if let minLimit = minimumLinkLimit(
peripheralWriteLimits: peripheralWriteLimits,
centralNotifyLimits: centralNotifyLimits
), packet.type != MessageType.fragment.rawValue,
dataCount > minLimit {
return BLEOutboundLinkPlan(
directedPeerHint: directedPeerHint(for: packet, explicitPeer: directedOnlyPeer),
fragmentChunkSize: BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: minLimit),
selectedLinks: BLEFanoutSelection(peripheralIDs: [], centralIDs: []),
shouldSpoolDirectedPacket: false
)
}
let directedPeerHint = directedPeerHint(for: packet, explicitPeer: directedOnlyPeer)
// Direct announces bypass the per-peer duplicate-link collapse so
// every live link gets bound (see BLEFanoutSelector.selectLinks).
let isDirectAnnounce = packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL
let selectedLinks = BLEFanoutSelector.selectLinks(
peripheralIDs: peripheralIDs,
centralIDs: centralIDs,
@@ -43,11 +36,37 @@ enum BLEOutboundLinkPlanner {
excludedLinks: excludedLinks,
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings,
preferredPeripheralPerPeer: preferredPeripheralPerPeer,
collapseDuplicatePeerLinks: !isDirectAnnounce,
directedPeerHint: directedPeerHint,
requireDirectPeerLink: requireDirectPeerLink,
packetType: packet.type,
messageID: BLEOutboundPacketPolicy.messageID(for: packet)
)
// Fragment only for links that this packet can actually use. Looking
// at every connected link before directed-peer selection lets an
// unrelated peer's MTU make an oversized directed send look routable,
// even though every resulting fragment will select zero target links.
let selectedPeripheralLimits = zip(peripheralIDs, peripheralWriteLimits).compactMap { id, limit in
selectedLinks.peripheralIDs.contains(id) ? limit : nil
}
let selectedCentralLimits = zip(centralIDs, centralNotifyLimits).compactMap { id, limit in
selectedLinks.centralIDs.contains(id) ? limit : nil
}
if let minLimit = minimumLinkLimit(
peripheralWriteLimits: selectedPeripheralLimits,
centralNotifyLimits: selectedCentralLimits
), packet.type != MessageType.fragment.rawValue,
dataCount > minLimit {
return BLEOutboundLinkPlan(
directedPeerHint: directedPeerHint,
fragmentChunkSize: BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: minLimit),
selectedLinks: selectedLinks,
shouldSpoolDirectedPacket: false
)
}
return BLEOutboundLinkPlan(
directedPeerHint: directedPeerHint,
fragmentChunkSize: nil,
@@ -44,4 +44,16 @@ struct BLEOutboundNotificationBuffer<Target> {
guard !pending.isEmpty else { return }
notifications.insert(contentsOf: pending, at: 0)
}
/// Removes a disconnected target from target-specific retries. Broadcast
/// entries (`targets == nil`) remain valid for the surviving subscriber
/// set; an entry with no targets left is discarded entirely.
mutating func removeTarget(where matches: (Target) -> Bool) {
notifications = notifications.compactMap { notification in
guard let targets = notification.targets else { return notification }
let remaining = targets.filter { !matches($0) }
guard !remaining.isEmpty else { return nil }
return BLEPendingNotification(data: notification.data, targets: remaining)
}
}
}
@@ -46,8 +46,25 @@ struct BLEOutboundWriteBuffer {
priority: BLEOutboundWritePriority,
capBytes: Int
) -> EnqueueResult {
enqueueReportingAcceptance(
data: data,
for: peripheralID,
priority: priority,
capBytes: capBytes
).result
}
/// Enqueues while also reporting whether the newly offered write survived
/// priority trimming. `EnqueueResult.enqueued` alone cannot express that:
/// a full queue may immediately trim the new lowest-priority item.
mutating func enqueueReportingAcceptance(
data: Data,
for peripheralID: String,
priority: BLEOutboundWritePriority,
capBytes: Int
) -> (result: EnqueueResult, accepted: Bool) {
guard data.count <= capBytes else {
return .oversized(bytes: data.count)
return (.oversized(bytes: data.count), false)
}
var queue = writesByPeripheralID[peripheralID] ?? []
@@ -65,7 +82,8 @@ struct BLEOutboundWriteBuffer {
}
writesByPeripheralID[peripheralID] = queue.isEmpty ? nil : queue
return .enqueued(trimmedBytes: trimmedBytes, remainingBytes: total)
let accepted = insertIndex < queue.count
return (.enqueued(trimmedBytes: trimmedBytes, remainingBytes: total), accepted)
}
mutating func takeAll(for peripheralID: String) -> [BLEPendingWrite] {
@@ -74,6 +92,13 @@ struct BLEOutboundWriteBuffer {
return items
}
/// Drops link-specific ciphertext when that physical link is gone. Keeping
/// the dictionary entry would let rotating peripheral UUIDs accumulate a
/// fresh per-link byte cap indefinitely.
mutating func discardAll(for peripheralID: String) {
writesByPeripheralID[peripheralID] = nil
}
mutating func prepend(_ items: [BLEPendingWrite], for peripheralID: String) {
guard !items.isEmpty else { return }
var existing = writesByPeripheralID[peripheralID] ?? []
+28 -2
View File
@@ -10,6 +10,9 @@ struct BLEPeerInfo: Equatable {
var isVerifiedNickname: Bool
var lastSeen: Date
var capabilities: PeerCapabilities = []
/// Distinguishes an old client that omitted the capabilities TLV from a
/// modern client that explicitly advertised a set without a given bit.
var capabilitiesWereExplicitlyAdvertised: Bool = false
/// Rendezvous cell from the peer's announce when it advertises `.bridge`.
var bridgeGeohash: String?
}
@@ -114,6 +117,10 @@ struct BLEPeerRegistry {
peers[peerID.toShort()]?.capabilities ?? []
}
func capabilitiesWereExplicitlyAdvertised(for peerID: PeerID) -> Bool {
peers[peerID.toShort()]?.capabilitiesWereExplicitlyAdvertised == true
}
/// Peers whose last verified announce advertised the given capability.
func peers(advertising capability: PeerCapabilities) -> [PeerID] {
peers.values.filter { $0.capabilities.contains(capability) }.map(\.peerID)
@@ -158,12 +165,30 @@ struct BLEPeerRegistry {
peers[peerID] = info
}
/// Flips an already-known peer to connected. Returns false when the peer
/// is unknown or already connected (nothing changed).
@discardableResult
mutating func markConnected(_ peerID: PeerID) -> Bool {
guard var info = peers[peerID], !info.isConnected else { return false }
info.isConnected = true
peers[peerID] = info
return true
}
mutating func updateLastSeen(_ peerID: PeerID, at date: Date) {
guard var peer = peers[peerID] else { return }
peer.lastSeen = date
peers[peerID] = peer
}
/// Replaces the announcement signing key only after the surrounding Noise
/// session proved possession of this peer's static key.
mutating func bindAuthenticatedSigningPublicKey(_ key: Data, for peerID: PeerID) {
guard var peer = peers[peerID.toShort()] else { return }
peer.signingPublicKey = key
peers[peer.peerID] = peer
}
mutating func upsertVerifiedAnnounce(
peerID: PeerID,
nickname: String,
@@ -171,7 +196,7 @@ struct BLEPeerRegistry {
signingPublicKey: Data?,
isConnected: Bool,
now: Date,
capabilities: PeerCapabilities = [],
capabilities: PeerCapabilities? = nil,
bridgeGeohash: String? = nil
) -> BLEPeerAnnounceUpdate {
let existing = peers[peerID]
@@ -189,7 +214,8 @@ struct BLEPeerRegistry {
signingPublicKey: signingPublicKey,
isVerifiedNickname: true,
lastSeen: now,
capabilities: capabilities,
capabilities: capabilities ?? [],
capabilitiesWereExplicitlyAdvertised: capabilities != nil,
bridgeGeohash: bridgeGeohash
)
File diff suppressed because it is too large Load Diff
@@ -122,10 +122,18 @@ final class BLEPublicMessageHandler {
SecureLogger.debug("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)) chars=\(content.count) bytes=\(packet.payload.count)", category: .session)
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
var resolvedSelfMessageID: String? = nil
let messageID: String?
if peerID == env.localPeerID() {
resolvedSelfMessageID = env.takeSelfBroadcastMessageID(packet)
messageID = env.takeSelfBroadcastMessageID(packet)
} else {
// The wire carries no message ID; derive the stable one every
// device agrees on so bridged copies dedup against the radio copy.
messageID = MeshMessageIdentity.stableID(
senderIDHex: peerID.id,
timestampMs: packet.timestamp,
content: content
)
}
env.deliverPublicMessage(peerID, senderNickname, content, ts, resolvedSelfMessageID)
env.deliverPublicMessage(peerID, senderNickname, content, ts, messageID)
}
}
@@ -0,0 +1,73 @@
import BitFoundation
import Foundation
/// Decides which central-role connections (peripheral links we own) are
/// redundant duplicates of a peer's live link.
///
/// One connection per role per peer is the normal dual-role topology (each
/// device is both central and peripheral). After a BLE state-restoration
/// relaunch, though, the same phone can reappear under a fresh peripheral
/// UUID while the restored connection lives on leaving several live
/// central-role connections to one peer, each carrying every packet
/// (field-verified: 2-3x airtime on all traffic). Only same-role duplicates
/// are retired; the peer's central-role subscription on our peripheral
/// manager is its own connection to manage, and it runs the same policy.
enum BLERedundantLinkPolicy {
struct PeripheralLink: Equatable {
let uuid: String
let peerID: PeerID?
let isConnected: Bool
/// Whether the link has a discovered characteristic (is writable).
/// A link mid-service-rediscovery (didModifyServices cleared it)
/// must never be kept over a writable duplicate.
let hasCharacteristic: Bool
init(uuid: String, peerID: PeerID?, isConnected: Bool, hasCharacteristic: Bool) {
self.uuid = uuid
self.peerID = peerID
self.isConnected = isConnected
self.hasCharacteristic = hasCharacteristic
}
}
/// The link to keep when a peer has several connected bound peripheral
/// links, or nil when there is nothing to consolidate. Prefers the
/// ingress link of the verified direct announce that triggered the check
/// (the strongest liveness proof available), falling back to the peer's
/// most recently bound link but only among writable links while any
/// exist: keeping a characteristic-less link and cancelling the writable
/// duplicate would strand outbound traffic on the central link until
/// rediscovery finishes. When neither anchor is a viable candidate,
/// consolidation waits for a later announce rather than guessing.
static func keptPeripheralUUID(
ingressPeripheralUUID: String?,
mostRecentlyBoundUUID: String?,
links: [PeripheralLink],
peerID: PeerID
) -> String? {
let bound = links.filter { $0.peerID == peerID && $0.isConnected }
guard bound.count > 1 else { return nil }
let writable = bound.filter(\.hasCharacteristic)
let candidates = writable.isEmpty ? bound : writable
if let ingressPeripheralUUID, candidates.contains(where: { $0.uuid == ingressPeripheralUUID }) {
return ingressPeripheralUUID
}
if let mostRecentlyBoundUUID, candidates.contains(where: { $0.uuid == mostRecentlyBoundUUID }) {
return mostRecentlyBoundUUID
}
return nil
}
/// Connected peripheral links bound to the peer other than the kept one.
static func peripheralUUIDsToRetire(
links: [PeripheralLink],
peerID: PeerID,
keeping keptUUID: String
) -> [String] {
links
.filter { $0.peerID == peerID && $0.isConnected && $0.uuid != keptUUID }
.map(\.uuid)
}
}
File diff suppressed because it is too large Load Diff
+7
View File
@@ -200,6 +200,10 @@ final class CommandProcessor {
LocationNotesManager.postDrop(content: content, nickname: nickname, geohash: geohash) else {
return .error(message: "no geo relays reachable — note not left")
}
// Leaving a note is an explicit notes act: it unlocks the passive
// nearby-notes counter (tap-to-reveal) so the sender sees their own
// drop counted on the timeline.
NearbyNotesCounter.shared.reveal()
return .success(message: "📍 note left here — it fades in 24h")
}
@@ -365,6 +369,9 @@ final class CommandProcessor {
)
identityManager.updateSocialIdentity(blockedIdentity)
}
// Scrub their carried public messages now, while the peerID is
// resolvable, so they can't resurface as archived echoes.
meshService?.purgeArchivedPublicMessages(from: peerID)
return .success(message: "blocked \(nickname). you will no longer receive messages from them")
}
// Mesh lookup failed; try geohash (Nostr) participant by display name
+291 -41
View File
@@ -10,6 +10,9 @@ import BitFoundation
import BitLogger
import Combine
import Foundation
#if os(iOS)
import UIKit
#endif
/// Trust level of a courier deposit, decided by the caller's policy.
/// Favorites get the larger quota and are never evicted to make room for
@@ -120,13 +123,45 @@ final class CourierStore {
private let queue = DispatchQueue(label: "chat.bitchat.courier.store")
private let fileURL: URL?
private let now: () -> Date
private let readData: (URL) throws -> Data
/// A protected file can be present but unreadable during an iOS
/// background restoration before first unlock. Keep that distinct from
/// an absent file: mutations may proceed in memory, but must not replace
/// the unreadable durable snapshot until it can be merged.
private var diskLoadDeferred = false
#if os(iOS)
private var protectedDataObserver: NSObjectProtocol?
#endif
/// - Parameter fileURL: Overrides the on-disk location (tests). Ignored
/// when `persistsToDisk` is false.
init(persistsToDisk: Bool = true, fileURL: URL? = nil, now: @escaping () -> Date = Date.init) {
init(
persistsToDisk: Bool = true,
fileURL: URL? = nil,
now: @escaping () -> Date = Date.init,
readData: @escaping (URL) throws -> Data = { try Data(contentsOf: $0) }
) {
self.now = now
self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
self.readData = readData
loadFromDisk()
#if os(iOS)
protectedDataObserver = NotificationCenter.default.addObserver(
forName: UIApplication.protectedDataDidBecomeAvailableNotification,
object: nil,
queue: nil
) { [weak self] _ in
self?.retryDeferredPersistence()
}
#endif
}
deinit {
#if os(iOS)
if let protectedDataObserver {
NotificationCenter.default.removeObserver(protectedDataObserver)
}
#endif
}
// MARK: - Depositing (courier side)
@@ -154,10 +189,16 @@ final class CourierStore {
return queue.sync {
pruneExpiredLocked(at: date)
// Identical ciphertext is the same envelope; accept idempotently,
// keeping the larger spray budget (bounded by maxCopies either way).
// Identical ciphertext is the same envelope. Before any spray,
// a carry-only copy may legitimately arrive ahead of the original
// higher-budget copy, so keep the larger initial budget. Once a
// branch has sprayed, however, replaying the depositor's original
// packet must never replenish spent copies: that would defeat
// spray-and-wait and let `sprayedTo` grow without bound.
if let existing = envelopes.firstIndex(where: { $0.ciphertext == envelope.ciphertext }) {
envelopes[existing].copies = max(envelopes[existing].copies, envelope.copies)
if envelopes[existing].sprayedTo.isEmpty {
envelopes[existing].copies = max(envelopes[existing].copies, envelope.copies)
}
persistLocked()
return true
}
@@ -207,20 +248,52 @@ final class CourierStore {
// MARK: - Handover (on encountering a peer)
/// Remove and return all envelopes addressed to the given peer, matching
/// the rotating recipient tag across adjacent days. Envelopes are removed
/// optimistically: handover happens over a live link, and the depositor's
/// outbox still retains the original for direct delivery.
/// the rotating recipient tag across adjacent days. This compatibility
/// helper accepts every offer; transport callers should use
/// `handoverEnvelopes(for:accepting:)` so failed sends remain durable.
func takeEnvelopes(for noiseStaticKey: Data) -> [CourierEnvelope] {
var handedOver: [CourierEnvelope] = []
handoverEnvelopes(for: noiseStaticKey) { envelope in
handedOver.append(envelope)
return true
}
return handedOver
}
/// Attempts direct handover without retiring the durable carried copy
/// until the transport accepts it onto the intended peer's physical link.
/// A failed encode, stale binding, or backpressure rejection leaves the
/// envelope unchanged for the next authenticated encounter.
@discardableResult
func handoverEnvelopes(
for noiseStaticKey: Data,
accepting: (CourierEnvelope) -> Bool
) -> Int {
let date = now()
let candidates = CourierEnvelope.candidateTags(noiseStaticKey: noiseStaticKey, around: date)
return queue.sync {
let offered = queue.sync {
pruneExpiredLocked(at: date)
let matched = envelopes.filter { candidates.contains($0.recipientTag) }
guard !matched.isEmpty else { return [] }
envelopes.removeAll { stored in matched.contains(stored) }
persistLocked()
return matched.map(\.envelope)
return envelopes
.filter { candidates.contains($0.recipientTag) }
.map(\.envelope)
}
var acceptedCount = 0
for envelope in offered where accepting(envelope) {
// Do not hold the store queue while the acceptance closure enters
// BLE/collections queues. Commit in a second short critical
// section, rechecking that another handover did not win first.
let committed = queue.sync {
guard let index = envelopes.firstIndex(where: { $0.ciphertext == envelope.ciphertext }) else {
return false
}
envelopes.remove(at: index)
persistLocked()
return true
}
if committed { acceptedCount += 1 }
}
return acceptedCount
}
/// Envelopes addressed to a recipient we heard from via a *relayed*
@@ -247,27 +320,33 @@ final class CourierStore {
}
}
/// Envelopes to park on relays as bridge courier drops. Non-destructive
/// like remote handover the relay copy is speculative, so the carried
/// copy stays until direct handover or expiry. The per-envelope cooldown
/// keeps relay churn from republishing the same mail; publishing relays
/// dedup identical events by ID anyway.
/// Envelopes eligible to park on relays as bridge courier drops. Merely
/// offering one does not start its cooldown: the caller commits that only
/// after a relay explicitly accepts the event via NIP-01 `OK`.
func envelopesForBridgePublish(cooldown: TimeInterval) -> [CourierEnvelope] {
let date = now()
return queue.sync {
pruneExpiredLocked(at: date)
var matched: [CourierEnvelope] = []
for index in envelopes.indices {
if let last = envelopes[index].lastBridgePublishAt,
return envelopes.compactMap { stored in
if let last = stored.lastBridgePublishAt,
date.timeIntervalSince(last) < cooldown {
continue
return nil
}
envelopes[index].lastBridgePublishAt = date
// The relay copy carries no spray budget.
matched.append(envelopes[index].envelope.withCopies(1))
return stored.envelope.withCopies(1)
}
if !matched.isEmpty { persistLocked() }
return matched
}
}
/// Starts the bridge-publish cooldown only for a relay-confirmed copy.
func markBridgePublished(_ envelope: CourierEnvelope) {
let date = now()
queue.sync {
guard let index = envelopes.firstIndex(where: { $0.ciphertext == envelope.ciphertext }) else {
return
}
envelopes[index].lastBridgePublishAt = date
persistLocked()
}
}
@@ -278,25 +357,60 @@ final class CourierStore {
/// deposited, envelopes addressed to them (those ride the handover path),
/// carry-only envelopes, and couriers already sprayed.
func takeSprayCopies(for courierNoiseKey: Data) -> [CourierEnvelope] {
var sprayed: [CourierEnvelope] = []
transferSprayCopies(to: courierNoiseKey) { envelope in
sprayed.append(envelope)
return true
}
return sprayed
}
/// Offers binary-spray copies one at a time and commits the reduced local
/// budget plus `sprayedTo` marker only after the directed transport accepts
/// that copy. A false result is a rollback: retrying the same courier sees
/// the original budget and eligibility.
@discardableResult
func transferSprayCopies(
to courierNoiseKey: Data,
accepting: (CourierEnvelope) -> Bool
) -> Int {
let date = now()
let courierTags = CourierEnvelope.candidateTags(noiseStaticKey: courierNoiseKey, around: date)
return queue.sync {
let offered = queue.sync {
pruneExpiredLocked(at: date)
var sprayed: [CourierEnvelope] = []
for index in envelopes.indices {
let stored = envelopes[index]
return envelopes.compactMap { stored -> CourierEnvelope? in
guard stored.copies > 1,
stored.depositorNoiseKey != courierNoiseKey,
!stored.sprayedTo.contains(courierNoiseKey),
!courierTags.contains(stored.recipientTag) else { continue }
let given = stored.copies / 2
envelopes[index].copies = stored.copies - given
envelopes[index].sprayedTo.insert(courierNoiseKey)
sprayed.append(stored.envelope.withCopies(given))
!courierTags.contains(stored.recipientTag) else { return nil }
return stored.envelope.withCopies(stored.copies / 2)
}
if !sprayed.isEmpty { persistLocked() }
return sprayed
}
var acceptedCount = 0
for copy in offered where accepting(copy) {
// As with direct handover, BLE acceptance runs outside the store
// queue. Revalidate and commit the exact budget that left this
// device; a competing successful transfer makes this a no-op.
let committed = queue.sync {
guard let index = envelopes.firstIndex(where: { $0.ciphertext == copy.ciphertext }) else {
return false
}
let stored = envelopes[index]
guard stored.copies > copy.copies,
stored.depositorNoiseKey != courierNoiseKey,
!stored.sprayedTo.contains(courierNoiseKey),
!courierTags.contains(stored.recipientTag) else {
return false
}
envelopes[index].copies = stored.copies - copy.copies
envelopes[index].sprayedTo.insert(courierNoiseKey)
persistLocked()
return true
}
if committed { acceptedCount += 1 }
}
return acceptedCount
}
// MARK: - Maintenance
@@ -305,6 +419,7 @@ final class CourierStore {
func wipe() {
queue.sync {
envelopes.removeAll()
diskLoadDeferred = false
if let fileURL {
try? FileManager.default.removeItem(at: fileURL)
}
@@ -312,6 +427,16 @@ final class CourierStore {
}
}
/// Retries a protected-data read and merges any envelopes accepted while
/// the file was unavailable. Internal so persistence tests can drive the
/// same transition as iOS's protected-data notification.
func retryDeferredPersistence() {
queue.sync {
guard diskLoadDeferred, resolveDeferredLoadLocked() else { return }
persistLocked()
}
}
// MARK: - Internals (call only on `queue`)
private func pruneExpiredLocked(at date: Date) {
@@ -332,6 +457,12 @@ final class CourierStore {
private func persistLocked() {
publishCountLocked()
guard let fileURL else { return }
// Never turn a transient protected-data read failure into an
// authoritative empty/new file. Once readable, resolve first by
// merging the durable and in-memory snapshots.
if diskLoadDeferred, !resolveDeferredLoadLocked() {
return
}
do {
if envelopes.isEmpty {
try? FileManager.default.removeItem(at: fileURL)
@@ -344,7 +475,7 @@ final class CourierStore {
let data = try JSONEncoder().encode(envelopes)
var options: Data.WritingOptions = [.atomic]
#if os(iOS)
options.insert(.completeFileProtection)
options.insert(.completeFileProtectionUntilFirstUserAuthentication)
#endif
try data.write(to: fileURL, options: options)
} catch {
@@ -355,16 +486,135 @@ final class CourierStore {
private func loadFromDisk() {
guard let fileURL else { return }
queue.sync {
guard let data = try? Data(contentsOf: fileURL),
let stored = try? JSONDecoder().decode([StoredEnvelope].self, from: data) else {
guard FileManager.default.fileExists(atPath: fileURL.path) else {
diskLoadDeferred = false
return
}
envelopes = stored
do {
let data = try readData(fileURL)
do {
envelopes = try JSONDecoder().decode([StoredEnvelope].self, from: data)
diskLoadDeferred = false
pruneExpiredLocked(at: now())
publishCountLocked()
Self.migrateFileProtectionIfNeeded(at: fileURL)
} catch {
// The bytes were readable, so this is corruption/schema
// failure rather than protected-data unavailability.
diskLoadDeferred = false
SecureLogger.error("Failed to decode courier store: \(error)", category: .session)
}
} catch {
diskLoadDeferred = true
SecureLogger.warning("Courier store unavailable; deferring load until protected data is available: \(error)", category: .session)
}
}
}
/// Must be called on `queue`.
private func resolveDeferredLoadLocked() -> Bool {
guard diskLoadDeferred, let fileURL else { return true }
guard FileManager.default.fileExists(atPath: fileURL.path) else {
diskLoadDeferred = false
return true
}
do {
let data = try readData(fileURL)
let durable = try JSONDecoder().decode([StoredEnvelope].self, from: data)
envelopes = Self.merge(durable: durable, inMemory: envelopes)
diskLoadDeferred = false
pruneExpiredLocked(at: now())
publishCountLocked()
Self.migrateFileProtectionIfNeeded(at: fileURL)
return true
} catch let error as DecodingError {
// Readability returned but the snapshot is corrupt. Do not pin
// persistence forever; retain the valid in-memory snapshot.
diskLoadDeferred = false
SecureLogger.error("Failed to decode deferred courier store: \(error)", category: .session)
return true
} catch {
SecureLogger.warning("Courier store still unavailable: \(error)", category: .session)
return false
}
}
private static func merge(durable: [StoredEnvelope], inMemory: [StoredEnvelope]) -> [StoredEnvelope] {
var merged = durable
for candidate in inMemory {
if let index = merged.firstIndex(where: { $0.ciphertext == candidate.ciphertext }) {
// Union progress before deciding the budget. Once either copy
// has sprayed, the lower remaining budget is authoritative;
// an older durable/original snapshot must not replenish it.
let durableHasProgress = !merged[index].sprayedTo.isEmpty
let memoryHasProgress = !candidate.sprayedTo.isEmpty
let combinedSprayedTo = merged[index].sprayedTo.union(candidate.sprayedTo)
switch (durableHasProgress, memoryHasProgress) {
case (false, false):
merged[index].copies = max(merged[index].copies, candidate.copies)
case (true, false):
break // durable progress owns its remaining budget
case (false, true):
merged[index].copies = candidate.copies
case (true, true):
// Concurrent progress can only spend budget; choosing the
// lower branch prevents a merge from minting copies.
merged[index].copies = min(merged[index].copies, candidate.copies)
}
merged[index].sprayedTo = combinedSprayedTo
if candidate.tier == .favorite { merged[index].tier = .favorite }
merged[index].lastRemoteHandoverAt = [merged[index].lastRemoteHandoverAt, candidate.lastRemoteHandoverAt]
.compactMap { $0 }
.max()
merged[index].lastBridgePublishAt = [merged[index].lastBridgePublishAt, candidate.lastBridgePublishAt]
.compactMap { $0 }
.max()
} else {
merged.append(candidate)
}
}
// A long locked wake can accept new mail alongside a full durable
// store. Re-apply deposit quotas: existing per-depositor mail keeps
// its slot, while total-cap eviction sheds oldest verified mail first.
merged.sort { $0.storedAt < $1.storedAt }
var perDepositorCounts: [Data: Int] = [:]
merged = merged.filter { envelope in
let limit = envelope.tier == .favorite
? Limits.maxPerFavoriteDepositor
: Limits.maxPerVerifiedDepositor
let count = perDepositorCounts[envelope.depositorNoiseKey, default: 0]
guard count < limit else { return false }
perDepositorCounts[envelope.depositorNoiseKey] = count + 1
return true
}
while merged.filter({ $0.tier == .verified }).count > Limits.maxVerifiedEnvelopes {
guard let victim = merged.firstIndex(where: { $0.tier == .verified }) else { break }
merged.remove(at: victim)
}
while merged.count > Limits.maxEnvelopes {
if let victim = merged.firstIndex(where: { $0.tier == .verified }) {
merged.remove(at: victim)
} else {
merged.removeFirst()
}
}
return merged
}
private static func migrateFileProtectionIfNeeded(at fileURL: URL) {
#if os(iOS)
do {
try FileManager.default.setAttributes(
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
ofItemAtPath: fileURL.path
)
} catch {
SecureLogger.warning("Failed to migrate courier store file protection: \(error)", category: .session)
}
#endif
}
private static func defaultFileURL() -> URL? {
guard let base = try? FileManager.default.url(
for: .applicationSupportDirectory,
+729 -39
View File
@@ -11,6 +11,9 @@ import BitLogger
import CryptoKit
import Foundation
import Security
#if os(iOS)
import UIKit
#endif
/// Disk persistence for the MessageRouter outbox, so private messages queued
/// for an offline peer survive an app kill instead of silently evaporating.
@@ -60,76 +63,550 @@ final class MessageOutboxStore {
private static let keychainService = "chat.bitchat.outbox"
private static let keychainKey = "outbox-encryption-key"
typealias Snapshot = [PeerID: [QueuedMessage]]
private enum DiskState {
case unknown
case loaded
case deferred
}
private enum DiskReadResult {
case missing
case loaded(Snapshot)
case deferred(Error?)
case corrupt(Error)
}
private enum EncryptionKeyReadResult {
case available(SymmetricKey)
case missing
case invalid
case unavailable(Error?)
}
private struct RecoveredSnapshot {
let snapshot: Snapshot
let generation: UInt64
let unseenDurable: Snapshot
}
private let fileURL: URL?
private let keychain: KeychainManagerProtocol
private let readData: (URL) throws -> Data
private let writeData: (Data, URL, Data.WritingOptions) throws -> Void
private let beforeRecoveryNotification: () -> Void
private let lock = NSLock()
private var diskState: DiskState = .unknown
private var cachedSnapshot: Snapshot = [:]
/// Mutations made after a protected-data load failed. They are merged
/// with the durable snapshot once it becomes readable, never written on
/// top of an unreadable file.
private var pendingSnapshot: Snapshot?
/// True after a write failed after the durable baseline was already
/// loaded. That full-router snapshot includes removals and must replace,
/// rather than union with, the older disk contents on retry.
private var pendingSnapshotIsAuthoritative = false
/// Delivery/read acknowledgments received before a deferred cold-load
/// reveals the durable queue. Applied to every merge before persistence.
private var pendingRemovalMessageIDs = Set<String>()
private var recoveryHandler: (@MainActor (Snapshot) -> Void)?
/// Recovery loaded durable state that MessageRouter has not merged yet.
/// While true, router saves must union with `cachedSnapshot` instead of
/// replacing unseen durable messages.
private var recoveryDeliveryPending = false
/// Recovery read durable state and classified the unseen subset, but the
/// merged snapshot could not yet be persisted. Preserve that classification
/// across retries instead of treating the cached union as router-known.
private var unseenRecoveryPendingPersistence = false
/// MessageRouter's latest authoritative in-memory snapshot while a
/// recovery classification is awaiting persistence or delivery. This is
/// deliberately separate from `pendingSnapshot`, which may contain the
/// union of router-known and unseen durable work after a failed write.
private var recoveryRouterSnapshot: Snapshot = [:]
/// The durable messages absent from MessageRouter's locked-wake snapshot
/// when recovery completed. Only this subset is unioned into authoritative
/// router saves before the recovery callback is claimed.
private var unseenRecoveredSnapshot: Snapshot = [:]
/// Covers the narrow launch race where protected data becomes available
/// after `load()` returned but before MessageRouter installs its handler.
private var unreportedRecoveredSnapshot: RecoveredSnapshot?
/// Invalidates recovery callbacks already queued onto the main actor when
/// panic wipe begins.
private var lifecycleGeneration: UInt64 = 0
#if os(iOS)
private var protectedDataObserver: NSObjectProtocol?
#endif
init(keychain: KeychainManagerProtocol, fileURL: URL? = nil) {
init(
keychain: KeychainManagerProtocol,
fileURL: URL? = nil,
readData: @escaping (URL) throws -> Data = { try Data(contentsOf: $0) },
writeData: @escaping (Data, URL, Data.WritingOptions) throws -> Void = {
try $0.write(to: $1, options: $2)
},
beforeRecoveryNotification: @escaping () -> Void = {}
) {
self.keychain = keychain
self.fileURL = fileURL ?? Self.defaultFileURL()
self.readData = readData
self.writeData = writeData
self.beforeRecoveryNotification = beforeRecoveryNotification
#if os(iOS)
protectedDataObserver = NotificationCenter.default.addObserver(
forName: UIApplication.protectedDataDidBecomeAvailableNotification,
object: nil,
queue: nil
) { [weak self] _ in
self?.retryDeferredLoad()
}
#endif
}
deinit {
#if os(iOS)
if let protectedDataObserver {
NotificationCenter.default.removeObserver(protectedDataObserver)
}
#endif
}
// MARK: - API (call from the router's actor; IO is small and atomic)
func load() -> [PeerID: [QueuedMessage]] {
guard let fileURL,
let sealed = try? Data(contentsOf: fileURL),
let key = encryptionKey(createIfMissing: false),
let box = try? ChaChaPoly.SealedBox(combined: sealed),
let plaintext = try? ChaChaPoly.open(box, using: key),
let decoded = try? JSONDecoder().decode([String: [QueuedMessage]].self, from: plaintext) else {
return [:]
func load() -> Snapshot {
lock.lock()
defer { lock.unlock() }
if case .loaded = diskState {
return cachedSnapshot
}
var outbox: [PeerID: [QueuedMessage]] = [:]
for (peerID, queue) in decoded where !queue.isEmpty {
outbox[PeerID(str: peerID)] = queue
// Once a deferred recovery has classified the durable baseline,
// `cachedSnapshot` is the only safe synchronous view. Re-reading via
// the generic load path would confuse its durable+router union with a
// fully router-known authoritative snapshot.
if unseenRecoveryPendingPersistence || recoveryDeliveryPending {
return cachedSnapshot
}
let wasDeferred = diskState == .deferred
switch readSnapshotLocked() {
case .loaded(let durable):
cachedSnapshot = applyingPendingRemovalsLocked(pendingSnapshotIsAuthoritative
? (pendingSnapshot ?? [:])
: Self.merge(durable, pendingSnapshot ?? [:]))
diskState = .loaded
if pendingSnapshot != nil || !pendingRemovalMessageIDs.isEmpty {
if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
} else {
pendingSnapshot = cachedSnapshot
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
}
// The recovery callback is driven by `retryDeferredLoad`; `load`
// itself returns the recovered value synchronously to its caller.
return cachedSnapshot
case .missing:
cachedSnapshot = applyingPendingRemovalsLocked(pendingSnapshot ?? [:])
diskState = .loaded
if pendingSnapshot != nil || !pendingRemovalMessageIDs.isEmpty {
if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
} else {
pendingSnapshot = cachedSnapshot
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
}
return cachedSnapshot
case .deferred(let error):
diskState = .deferred
if !wasDeferred {
SecureLogger.warning("Outbox unavailable; deferring load until protected data is available: \(String(describing: error))", category: .session)
}
return pendingSnapshot ?? [:]
case .corrupt(let error):
diskState = .loaded
cachedSnapshot = applyingPendingRemovalsLocked(pendingSnapshot ?? [:])
SecureLogger.error("Failed to decode encrypted outbox: \(error)", category: .session)
if pendingSnapshot != nil || !pendingRemovalMessageIDs.isEmpty {
if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
} else {
pendingSnapshot = cachedSnapshot
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
}
return cachedSnapshot
}
return outbox
}
func save(_ outbox: [PeerID: [QueuedMessage]]) {
guard let fileURL else { return }
let flattened = outbox.filter { !$0.value.isEmpty }
guard !flattened.isEmpty else {
try? FileManager.default.removeItem(at: fileURL)
return
func save(_ outbox: Snapshot) {
var recovered: RecoveredSnapshot?
lock.lock()
let recoveryWasPending = recoveryDeliveryPending
let unseenClassificationWasPending = unseenRecoveryPendingPersistence
let recoveryStateWasPending = recoveryWasPending || unseenClassificationWasPending
let flattened = applyingPendingRemovalsLocked(outbox.filter { !$0.value.isEmpty })
if recoveryStateWasPending {
// `save` is the router's complete current view. Keep it separate
// from the durable union retained for disk retry.
recoveryRouterSnapshot = flattened
}
guard let key = encryptionKey(createIfMissing: true) else {
SecureLogger.error("Outbox not persisted: no encryption key available", category: .session)
return
}
do {
let keyed = Dictionary(uniqueKeysWithValues: flattened.map { ($0.key.id, $0.value) })
let plaintext = try JSONEncoder().encode(keyed)
let sealed = try ChaChaPoly.seal(plaintext, using: key).combined
try FileManager.default.createDirectory(
at: fileURL.deletingLastPathComponent(),
withIntermediateDirectories: true
switch diskState {
case .loaded:
cachedSnapshot = applyingPendingRemovalsLocked(
recoveryStateWasPending
? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot)
: flattened
)
var options: Data.WritingOptions = [.atomic]
#if os(iOS)
options.insert(.completeFileProtection)
#endif
try sealed.write(to: fileURL, options: options)
} catch {
SecureLogger.error("Failed to persist outbox: \(error)", category: .session)
if !persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
// Retain the latest complete router snapshot. Because its
// durable baseline was already loaded, it replaces the older
// disk file after protected data returns (preserving removals).
pendingSnapshot = cachedSnapshot
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
case .unknown, .deferred:
let wasDeferred = diskState == .deferred
// A non-authoritative deferred snapshot means the initial cold
// load never completed. An authoritative deferred snapshot with
// no recovery state is merely an ordinary post-load write retry.
let isRecoveryAttempt = recoveryStateWasPending ||
(wasDeferred && !pendingSnapshotIsAuthoritative)
switch readSnapshotLocked() {
case .loaded(let durable):
// `save` before a successful `load` is not authoritative over
// an unreadable snapshot. Union by message ID so neither the
// durable queue nor work accepted during the locked wake is
// lost.
let newlyUnseen = recoveryStateWasPending
? unseenRecoveredSnapshot
: (isRecoveryAttempt
? applyingPendingRemovalsLocked(Self.excludingKnownMessages(from: durable, known: flattened))
: [:])
if isRecoveryAttempt && !recoveryStateWasPending {
unseenRecoveredSnapshot = newlyUnseen
recoveryRouterSnapshot = flattened
unseenRecoveryPendingPersistence = true
}
let preservesUnseenRecovery = recoveryStateWasPending || unseenRecoveryPendingPersistence
let merged = preservesUnseenRecovery
? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot)
: (pendingSnapshotIsAuthoritative ? flattened : Self.merge(durable, flattened))
cachedSnapshot = applyingPendingRemovalsLocked(merged)
diskState = .loaded
if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
if isRecoveryAttempt && !recoveryWasPending {
recovered = RecoveredSnapshot(
snapshot: cachedSnapshot,
generation: lifecycleGeneration,
unseenDurable: newlyUnseen
)
}
} else {
pendingSnapshot = cachedSnapshot
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
case .missing:
if isRecoveryAttempt && !recoveryStateWasPending {
unseenRecoveredSnapshot = [:]
recoveryRouterSnapshot = flattened
unseenRecoveryPendingPersistence = true
}
let preservesUnseenRecovery = recoveryStateWasPending || unseenRecoveryPendingPersistence
let merged = applyingPendingRemovalsLocked(
preservesUnseenRecovery
? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot)
: flattened
)
cachedSnapshot = merged
diskState = .loaded
if persistSnapshotAndClearRemovalsLocked(merged) {
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
if isRecoveryAttempt && !recoveryWasPending {
recovered = RecoveredSnapshot(
snapshot: merged,
generation: lifecycleGeneration,
unseenDurable: unseenRecoveredSnapshot
)
}
} else {
pendingSnapshot = merged
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
case .deferred:
// `save` receives MessageRouter's complete current in-memory
// snapshot. Replace prior locked-wake state so delivery acks
// and expiry removals become tombstones for that state; only
// the still-unknown durable snapshot is unioned on recovery.
pendingSnapshot = applyingPendingRemovalsLocked(
recoveryStateWasPending
? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot)
: flattened
)
diskState = .deferred
case .corrupt(let error):
SecureLogger.error("Failed to decode encrypted outbox: \(error)", category: .session)
if isRecoveryAttempt && !recoveryStateWasPending {
unseenRecoveredSnapshot = [:]
recoveryRouterSnapshot = flattened
unseenRecoveryPendingPersistence = true
}
let preservesUnseenRecovery = recoveryStateWasPending || unseenRecoveryPendingPersistence
let merged = applyingPendingRemovalsLocked(
preservesUnseenRecovery
? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot)
: flattened
)
cachedSnapshot = merged
diskState = .loaded
if persistSnapshotAndClearRemovalsLocked(merged) {
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
if isRecoveryAttempt && !recoveryWasPending {
recovered = RecoveredSnapshot(
snapshot: merged,
generation: lifecycleGeneration,
unseenDurable: unseenRecoveredSnapshot
)
}
} else {
pendingSnapshot = merged
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
}
}
if let recovered {
unseenRecoveryPendingPersistence = false
recoveryDeliveryPending = true
unseenRecoveredSnapshot = recovered.unseenDurable
}
lock.unlock()
if let recovered {
beforeRecoveryNotification()
notifyRecovered(recovered.snapshot, generation: recovered.generation)
}
}
/// Installs the router-side merge hook used when a cold, locked launch
/// initially received an empty snapshot and protected data later becomes
/// readable.
func setRecoveryHandler(_ handler: @escaping @MainActor (Snapshot) -> Void) {
lock.lock()
recoveryHandler = handler
let unreported = unreportedRecoveredSnapshot
unreportedRecoveredSnapshot = nil
lock.unlock()
if let unreported {
Task { @MainActor [weak self] in
guard let latest = self?.claimPendingRecovery(generation: unreported.generation) else { return }
handler(latest)
}
}
}
/// Records an ack even when a locked cold-load has not revealed the
/// matching durable message yet. The next `save`/recovery applies this
/// tombstone before writing or notifying MessageRouter.
func recordRemoval(messageID: String) {
lock.lock()
pendingRemovalMessageIDs.insert(messageID)
cachedSnapshot = Self.removing([messageID], from: cachedSnapshot)
unseenRecoveredSnapshot = Self.removing([messageID], from: unseenRecoveredSnapshot)
recoveryRouterSnapshot = Self.removing([messageID], from: recoveryRouterSnapshot)
if let pendingSnapshot {
self.pendingSnapshot = Self.removing([messageID], from: pendingSnapshot)
}
lock.unlock()
}
/// Retries a deferred protected-data load. The returned snapshot includes
/// both durable messages and any messages queued during the locked wake.
@discardableResult
func retryDeferredLoad() -> Snapshot? {
var recovered: RecoveredSnapshot?
lock.lock()
guard diskState == .deferred else {
lock.unlock()
return nil
}
let recoveryWasPending = recoveryDeliveryPending
let unseenClassificationWasPending = unseenRecoveryPendingPersistence
let recoveryStateWasPending = recoveryWasPending || unseenClassificationWasPending
// `pendingSnapshotIsAuthoritative` alone means a normal write failed
// after the router had already loaded its baseline. It must retry, but
// must not masquerade as cold-load recovery or schedule a callback.
let isRecoveryAttempt = recoveryStateWasPending || !pendingSnapshotIsAuthoritative
switch readSnapshotLocked() {
case .loaded(let durable):
let known = recoveryStateWasPending ? recoveryRouterSnapshot : (pendingSnapshot ?? [:])
let newlyUnseen = recoveryStateWasPending
? unseenRecoveredSnapshot
: (isRecoveryAttempt
? applyingPendingRemovalsLocked(Self.excludingKnownMessages(from: durable, known: known))
: [:])
if isRecoveryAttempt && !recoveryStateWasPending {
unseenRecoveredSnapshot = newlyUnseen
recoveryRouterSnapshot = known
unseenRecoveryPendingPersistence = true
}
let preservesUnseenRecovery = recoveryStateWasPending || unseenRecoveryPendingPersistence
let merged = applyingPendingRemovalsLocked(preservesUnseenRecovery
? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot)
: (pendingSnapshotIsAuthoritative ? known : Self.merge(durable, known)))
cachedSnapshot = merged
diskState = .loaded
if (pendingSnapshot == nil && pendingRemovalMessageIDs.isEmpty) ||
persistSnapshotAndClearRemovalsLocked(merged) {
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
if isRecoveryAttempt && !recoveryWasPending {
recovered = RecoveredSnapshot(
snapshot: merged,
generation: lifecycleGeneration,
unseenDurable: newlyUnseen
)
}
} else {
pendingSnapshot = merged
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
case .missing:
let known = recoveryStateWasPending ? recoveryRouterSnapshot : (pendingSnapshot ?? [:])
if isRecoveryAttempt && !recoveryStateWasPending {
unseenRecoveredSnapshot = [:]
recoveryRouterSnapshot = known
unseenRecoveryPendingPersistence = true
}
let preservesUnseenRecovery = recoveryStateWasPending || unseenRecoveryPendingPersistence
let merged = applyingPendingRemovalsLocked(preservesUnseenRecovery
? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot)
: known)
cachedSnapshot = merged
diskState = .loaded
if (pendingSnapshot == nil && pendingRemovalMessageIDs.isEmpty) ||
persistSnapshotAndClearRemovalsLocked(merged) {
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
if isRecoveryAttempt && !recoveryWasPending {
recovered = RecoveredSnapshot(
snapshot: merged,
generation: lifecycleGeneration,
unseenDurable: unseenRecoveredSnapshot
)
}
} else {
pendingSnapshot = merged
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
case .deferred:
break
case .corrupt(let error):
SecureLogger.error("Failed to decode encrypted outbox after protected-data recovery: \(error)", category: .session)
let known = recoveryStateWasPending ? recoveryRouterSnapshot : (pendingSnapshot ?? [:])
if isRecoveryAttempt && !recoveryStateWasPending {
unseenRecoveredSnapshot = [:]
recoveryRouterSnapshot = known
unseenRecoveryPendingPersistence = true
}
let preservesUnseenRecovery = recoveryStateWasPending || unseenRecoveryPendingPersistence
let merged = applyingPendingRemovalsLocked(preservesUnseenRecovery
? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot)
: known)
cachedSnapshot = merged
diskState = .loaded
if (pendingSnapshot == nil && pendingRemovalMessageIDs.isEmpty) ||
persistSnapshotAndClearRemovalsLocked(merged) {
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
if isRecoveryAttempt && !recoveryWasPending {
recovered = RecoveredSnapshot(
snapshot: merged,
generation: lifecycleGeneration,
unseenDurable: unseenRecoveredSnapshot
)
}
} else {
pendingSnapshot = merged
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
}
if let recovered {
unseenRecoveryPendingPersistence = false
recoveryDeliveryPending = true
unseenRecoveredSnapshot = recovered.unseenDurable
}
lock.unlock()
if let recovered {
beforeRecoveryNotification()
notifyRecovered(recovered.snapshot, generation: recovered.generation)
}
return recovered?.snapshot
}
/// Panic wipe: drop the queued mail and the key that could ever read it.
func wipe() {
lock.lock()
diskState = .loaded
cachedSnapshot = [:]
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
pendingRemovalMessageIDs.removeAll()
recoveryDeliveryPending = false
unseenRecoveryPendingPersistence = false
unseenRecoveredSnapshot = [:]
recoveryRouterSnapshot = [:]
unreportedRecoveredSnapshot = nil
lifecycleGeneration &+= 1
if let fileURL {
try? FileManager.default.removeItem(at: fileURL)
}
keychain.delete(key: Self.keychainKey, service: Self.keychainService)
lock.unlock()
}
// MARK: - Internals
private func encryptionKey(createIfMissing: Bool) -> SymmetricKey? {
if let data = keychain.load(key: Self.keychainKey, service: Self.keychainService), data.count == 32 {
return SymmetricKey(data: data)
switch readEncryptionKey() {
case .available(let key):
return key
case .missing:
break
case .invalid:
guard createIfMissing else { return nil }
keychain.delete(key: Self.keychainKey, service: Self.keychainService)
case .unavailable:
return nil
}
guard createIfMissing else { return nil }
let key = SymmetricKey(size: .bits256)
let data = key.withUnsafeBytes { Data($0) }
// After-first-unlock so queued mail can flush from background BLE wakes.
@@ -139,9 +616,222 @@ final class MessageOutboxStore {
service: Self.keychainService,
accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
)
// The protocol's generic save predates result-bearing writes. Verify
// the item before sealing a file: otherwise a locked/full Keychain
// could drop the key while we successfully write unrecoverable mail.
guard case .success(let stored) = keychain.loadWithResult(
key: Self.keychainKey,
service: Self.keychainService
), stored == data else {
keychain.delete(key: Self.keychainKey, service: Self.keychainService)
SecureLogger.error("Outbox encryption key was not retained by Keychain", category: .session)
return nil
}
return key
}
private func readEncryptionKey() -> EncryptionKeyReadResult {
switch keychain.loadWithResult(key: Self.keychainKey, service: Self.keychainService) {
case .success(let data):
guard data.count == 32 else { return .invalid }
return .available(SymmetricKey(data: data))
case .itemNotFound:
return .missing
case .deviceLocked, .authenticationFailed:
return .unavailable(nil)
case .accessDenied:
return .unavailable(NSError(domain: NSOSStatusErrorDomain, code: Int(errSecNotAvailable)))
case .otherError(let status):
return .unavailable(NSError(domain: NSOSStatusErrorDomain, code: Int(status)))
}
}
/// Must be called with `lock` held.
private func readSnapshotLocked() -> DiskReadResult {
guard let fileURL,
FileManager.default.fileExists(atPath: fileURL.path) else {
return .missing
}
let sealed: Data
do {
sealed = try readData(fileURL)
} catch {
return .deferred(error)
}
// A locked Keychain is transient; a genuine item-not-found next to an
// existing sealed file is permanent (notably after restoring onto a
// new device, because the key is ThisDeviceOnly). Remove that
// unrecoverable ciphertext before allowing a replacement key/file.
let key: SymmetricKey
switch readEncryptionKey() {
case .available(let availableKey):
key = availableKey
case .missing:
return discardOrphanedSnapshotLocked(reason: "encryption key is missing")
case .invalid:
keychain.delete(key: Self.keychainKey, service: Self.keychainService)
return discardOrphanedSnapshotLocked(reason: "encryption key has an invalid length")
case .unavailable(let error):
return .deferred(error)
}
do {
let box = try ChaChaPoly.SealedBox(combined: sealed)
let plaintext = try ChaChaPoly.open(box, using: key)
let decoded = try JSONDecoder().decode([String: [QueuedMessage]].self, from: plaintext)
var outbox: Snapshot = [:]
for (peerID, queue) in decoded where !queue.isEmpty {
outbox[PeerID(str: peerID)] = queue
}
Self.migrateFileProtectionIfNeeded(at: fileURL)
return .loaded(outbox)
} catch {
return .corrupt(error)
}
}
/// Must be called with `lock` held. A ciphertext whose ThisDeviceOnly key
/// is definitively absent can never become readable; removing it is safer
/// than deferring forever or overwriting it while pretending it loaded.
private func discardOrphanedSnapshotLocked(reason: String) -> DiskReadResult {
guard let fileURL else { return .missing }
do {
try FileManager.default.removeItem(at: fileURL)
SecureLogger.warning("Removed unrecoverable encrypted outbox because its \(reason)", category: .session)
return .missing
} catch {
SecureLogger.error("Could not remove unrecoverable encrypted outbox: \(error)", category: .session)
return .deferred(error)
}
}
/// Must be called with `lock` held.
@discardableResult
private func persistSnapshotLocked(_ snapshot: Snapshot) -> Bool {
guard let fileURL else { return true }
do {
if snapshot.isEmpty {
if FileManager.default.fileExists(atPath: fileURL.path) {
try FileManager.default.removeItem(at: fileURL)
}
return true
}
guard let key = encryptionKey(createIfMissing: true) else {
SecureLogger.error("Outbox not persisted: no encryption key available", category: .session)
return false
}
let keyed = Dictionary(uniqueKeysWithValues: snapshot.map { ($0.key.id, $0.value) })
let plaintext = try JSONEncoder().encode(keyed)
let sealed = try ChaChaPoly.seal(plaintext, using: key).combined
try FileManager.default.createDirectory(
at: fileURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
var options: Data.WritingOptions = [.atomic]
#if os(iOS)
options.insert(.completeFileProtectionUntilFirstUserAuthentication)
#endif
try writeData(sealed, fileURL, options)
return true
} catch {
SecureLogger.error("Failed to persist outbox: \(error)", category: .session)
return false
}
}
/// Must be called with `lock` held.
private func persistSnapshotAndClearRemovalsLocked(_ snapshot: Snapshot) -> Bool {
guard persistSnapshotLocked(snapshot) else { return false }
pendingRemovalMessageIDs.removeAll()
return true
}
/// Must be called with `lock` held.
private func applyingPendingRemovalsLocked(_ snapshot: Snapshot) -> Snapshot {
Self.removing(pendingRemovalMessageIDs, from: snapshot)
}
private static func removing(_ messageIDs: Set<String>, from snapshot: Snapshot) -> Snapshot {
guard !messageIDs.isEmpty else { return snapshot }
var filtered: Snapshot = [:]
for (peerID, queue) in snapshot {
let remaining = queue.filter { !messageIDs.contains($0.messageID) }
if !remaining.isEmpty { filtered[peerID] = remaining }
}
return filtered
}
private static func excludingKnownMessages(from durable: Snapshot, known: Snapshot) -> Snapshot {
let knownIDs = Set(known.values.flatMap { $0.map(\.messageID) })
return removing(knownIDs, from: durable)
}
private static func merge(_ durable: Snapshot, _ pending: Snapshot) -> Snapshot {
var merged = durable
for (peerID, pendingQueue) in pending {
var queue = merged[peerID] ?? []
for var candidate in pendingQueue {
if let index = queue.firstIndex(where: { $0.messageID == candidate.messageID }) {
candidate.sendAttempts = max(candidate.sendAttempts, queue[index].sendAttempts)
candidate.depositedCourierKeys.formUnion(queue[index].depositedCourierKeys)
queue[index] = candidate
} else {
queue.append(candidate)
}
}
queue.sort { $0.timestamp < $1.timestamp }
if !queue.isEmpty { merged[peerID] = queue }
}
return merged.filter { !$0.value.isEmpty }
}
private func notifyRecovered(_ snapshot: Snapshot, generation: UInt64) {
lock.lock()
guard lifecycleGeneration == generation else {
lock.unlock()
return
}
let handler = recoveryHandler
if handler == nil {
unreportedRecoveredSnapshot = RecoveredSnapshot(
snapshot: snapshot,
generation: generation,
unseenDurable: unseenRecoveredSnapshot
)
}
lock.unlock()
guard let handler else { return }
Task { @MainActor [weak self] in
guard let latest = self?.claimPendingRecovery(generation: generation) else { return }
handler(latest)
}
}
private func claimPendingRecovery(generation: UInt64) -> Snapshot? {
lock.lock()
defer { lock.unlock() }
guard lifecycleGeneration == generation, recoveryDeliveryPending else { return nil }
let latest = cachedSnapshot
recoveryDeliveryPending = false
unseenRecoveryPendingPersistence = false
unseenRecoveredSnapshot = [:]
recoveryRouterSnapshot = [:]
unreportedRecoveredSnapshot = nil
return latest
}
private static func migrateFileProtectionIfNeeded(at fileURL: URL) {
#if os(iOS)
do {
try FileManager.default.setAttributes(
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
ofItemAtPath: fileURL.path
)
} catch {
SecureLogger.warning("Failed to migrate outbox file protection: \(error)", category: .session)
}
#endif
}
private static func defaultFileURL() -> URL? {
guard let base = try? FileManager.default.url(
for: .applicationSupportDirectory,
@@ -9,7 +9,11 @@
import BitFoundation
import BitLogger
import Foundation
import Security
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
/// Courier delivery over the internet bridge: sealed courier envelopes are
/// parked on relays as kind-1401 "drops" tagged with their rotating
@@ -49,6 +53,10 @@ final class BridgeCourierService: ObservableObject {
/// Encoded envelope cap for a drop (16 KiB ciphertext + TLV slack).
static let maxDropEnvelopeBytes = 20 * 1024
static let maxTrackedIDs = 512
/// Coalescing window for dedup-record writes: a backlog re-fetch
/// mutates the seen set once per event, and each snapshot save is a
/// full JSON encode + atomic write on the main actor.
static let dedupPersistCoalesceSeconds: TimeInterval = 1.0
}
static let shared = BridgeCourierService()
@@ -57,8 +65,11 @@ final class BridgeCourierService: ObservableObject {
var bridgeEnabled: (@MainActor () -> Bool)?
var relaysConnected: (@MainActor () -> Bool)?
/// Publishes a signed drop event to the default (DM) relays.
var publishEvent: (@MainActor (NostrEvent) -> Void)?
/// Publishes a signed drop event directly to connected default (DM)
/// relays. Completion is true only after at least one relay explicitly
/// accepts the event via NIP-01 `OK`; this must never mean "queued in RAM"
/// or merely "written to a socket".
var publishEvent: (@MainActor (NostrEvent, @escaping @MainActor (Bool) -> Void) -> Void)?
/// (Re)opens the drop subscription for the given hex tags.
var openSubscription: (@MainActor ([String]) -> Void)?
var closeSubscription: (@MainActor () -> Void)?
@@ -68,12 +79,21 @@ final class BridgeCourierService: ObservableObject {
var localVerifiedPeers: (@MainActor () -> [(peerID: PeerID, noiseKey: Data)])?
/// Seals content into a carry-only envelope for a recipient key.
var sealEnvelope: (@MainActor (String, String, Data) -> CourierEnvelope?)?
/// Opens a drop addressed to us (tag verified inside).
var openEnvelope: (@MainActor (CourierEnvelope) -> Void)?
/// Opens a drop addressed to us. True means the inner envelope was
/// delivered, deduplicated, or intentionally rejected after decryption;
/// false leaves the relay event retryable after a transient crypto/key
/// failure.
var openEnvelope: (@MainActor (CourierEnvelope) -> Bool)?
/// Hands a drop to a matching local peer as a directed courier packet.
var deliverToPeer: (@MainActor (CourierEnvelope, PeerID) -> Void)?
/// Returns true only when the transport accepted the packet onto a live
/// physical link or its link-specific backpressure queue. Stale
/// reachability and process-local directed spooling return false so the
/// drop event stays retryable.
var deliverToPeer: (@MainActor (CourierEnvelope, PeerID) -> Bool)?
/// Held envelopes eligible for (re)publish, honoring the cooldown.
var heldEnvelopes: (@MainActor (TimeInterval) -> [CourierEnvelope])?
/// Commits a held envelope's cooldown after confirmed relay acceptance.
var markHeldEnvelopePublished: (@MainActor (CourierEnvelope) -> Void)?
/// Timer injection for tests; nil arms a real `Task`.
var scheduleTimer: (@MainActor (TimeInterval, @escaping @MainActor () -> Void) -> Void)?
@@ -81,39 +101,156 @@ final class BridgeCourierService: ObservableObject {
private(set) var myTagsHex: Set<String> = []
private(set) var watchedPeerTags: [(peerID: PeerID, tagsHex: Set<String>)] = []
private(set) var pendingDrops: [(envelope: CourierEnvelope, dedupKey: String?)] = []
/// Message IDs already published as drops (sender-side dedup).
private var publishedDropKeys: BoundedIDSet
/// Drop event IDs already handled (multi-relay dedup).
private var seenDropEventIDs: BoundedIDSet
private(set) var pendingDrops: [(
envelope: CourierEnvelope,
dedupKey: String?,
operationID: UUID?
)] = []
/// Message IDs already published as drops (sender-side dedup) and drop
/// event IDs already handled (multi-relay dedup). Both persist across
/// relaunches: relays hold drops for the full 24h NIP-40 window and the
/// persisted outbox keeps re-depositing, so in-memory-only dedup meant
/// every relaunch republished the same message as a fresh drop and every
/// gateway relaunch re-delivered the whole backlog (field-verified
/// amplification storm). Entries age out with the 24h drop window.
private var publishedDropKeys: ExpiringIDSet
private var seenDropEventIDs: ExpiringIDSet
private var subscriptionOpen = false
private var lastSubscribedTags: Set<String> = []
private var refreshTimerArmed = false
private var announceRefreshTimerArmed = false
private var lastAnnounceRefresh = Date.distantPast
private struct ActiveDropOperation {
let id: UUID
let completion: @MainActor (Bool) -> Void
}
/// Sender operations queued locally or awaiting relay confirmation.
/// The per-attempt ID prevents a stale pre-wipe callback from completing
/// a newer attempt for the same message.
private var activeDropOperations: [String: ActiveDropOperation] = [:]
/// Held-envelope publishes have no sender message ID, but still need an
/// in-flight identity: repeated refreshes inside the relay-OK wait window
/// must not mint duplicate relay events for the same opaque envelope.
private var heldDropOperations: [Data: UUID] = [:]
/// Deterministically invalid envelopes are suppressed for this process,
/// but never persisted as if a relay accepted them. Bound and age them so
/// rotating oversize IDs cannot grow process memory forever.
private var rejectedDropKeys = ExpiringIDSet(
capacity: Limits.maxTrackedIDs,
lifetime: CourierEnvelope.maxLifetimeSeconds
)
private let now: () -> Date
private let dedupStore: BridgeDropDedupStore
init(now: @escaping () -> Date = Date.init) {
private var dedupPersistScheduled = false
init(now: @escaping () -> Date = Date.init, dedupStore: BridgeDropDedupStore? = nil) {
self.now = now
self.publishedDropKeys = BoundedIDSet(capacity: Limits.maxTrackedIDs)
self.seenDropEventIDs = BoundedIDSet(capacity: Limits.maxTrackedIDs)
self.dedupStore = dedupStore ?? BridgeDropDedupStore(persistsToDisk: !TestEnvironment.isRunningTests)
let snapshot = self.dedupStore.load()
let date = now()
self.publishedDropKeys = ExpiringIDSet(
capacity: Limits.maxTrackedIDs,
lifetime: CourierEnvelope.maxLifetimeSeconds,
entries: snapshot.publishedDropKeys,
now: date
)
self.seenDropEventIDs = ExpiringIDSet(
capacity: Limits.maxTrackedIDs,
lifetime: CourierEnvelope.maxLifetimeSeconds,
entries: snapshot.seenDropEventIDs,
now: date
)
// A coalesced dedup write scheduled just before a background kill
// would be lost; flush when the app backgrounds or terminates.
#if os(iOS)
let flushNotifications = [UIApplication.didEnterBackgroundNotification, UIApplication.willTerminateNotification]
#else
let flushNotifications = [NSApplication.willTerminateNotification]
#endif
for name in flushNotifications {
NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
MainActor.assumeIsolated { self?.flushDedupSnapshot() }
}
}
}
/// Schedules a coalesced write of the dedup record (see
/// `Limits.dedupPersistCoalesceSeconds`); lifecycle notifications flush
/// any scheduled write before a background kill could drop it.
private func persistDedup() {
guard !dedupPersistScheduled else { return }
dedupPersistScheduled = true
Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: UInt64(Limits.dedupPersistCoalesceSeconds * 1_000_000_000))
guard let self else { return }
self.dedupPersistScheduled = false
self.flushDedupSnapshot()
}
}
/// Writes the dedup record now. `publishedDropKeys` contains only drops
/// a relay explicitly accepted; queued and in-flight keys
/// live in `activeDropOperations` and are intentionally process-local.
func flushDedupSnapshot() {
dedupStore.save(BridgeDropDedupStore.Snapshot(
publishedDropKeys: publishedDropKeys.entries,
seenDropEventIDs: seenDropEventIDs.entries
))
}
/// Panic wipe: forget queued drops and the persisted dedup record.
func wipe() {
cancelActivePublishes()
rejectedDropKeys = ExpiringIDSet(
capacity: Limits.maxTrackedIDs,
lifetime: CourierEnvelope.maxLifetimeSeconds
)
publishedDropKeys = ExpiringIDSet(capacity: Limits.maxTrackedIDs, lifetime: CourierEnvelope.maxLifetimeSeconds)
seenDropEventIDs = ExpiringIDSet(capacity: Limits.maxTrackedIDs, lifetime: CourierEnvelope.maxLifetimeSeconds)
dedupStore.wipe()
}
// MARK: - Sender role
/// Parallel-deposit a sealed copy of an outbound private message as a
/// relay drop. Called by the message router alongside physical courier
/// deposits; idempotent per message ID. Returns true when a fresh drop
/// was sealed (published now or queued for the next relay connection)
/// the router marks the message "carried" so the sender sees progress.
@discardableResult
func depositDrop(content: String, messageID: String, recipientNoiseKey: Data) -> Bool {
guard bridgeEnabled?() ?? false else { return false }
guard !publishedDropKeys.contains(messageID) else { return false }
guard let envelope = sealEnvelope?(content, messageID, recipientNoiseKey) else { return false }
publishedDropKeys.insert(messageID)
publishDrop(envelope)
return true
/// deposits; idempotent per message ID. Completion becomes true only
/// after a real relay acceptance arrives, which is when the router may
/// show "carried".
func depositDrop(
content: String,
messageID: String,
recipientNoiseKey: Data,
completion: @escaping @MainActor (Bool) -> Void = { _ in }
) {
guard bridgeEnabled?() ?? false else {
completion(false)
return
}
guard !publishedDropKeys.contains(messageID, now: now()),
activeDropOperations[messageID] == nil,
!rejectedDropKeys.contains(messageID, now: now()) else {
completion(false)
return
}
guard let envelope = sealEnvelope?(content, messageID, recipientNoiseKey) else {
completion(false)
return
}
// An envelope that can't encode within the drop size caps fails the
// same way on every attempt (size is a function of the content, not
// of the sealing); suppress it in-memory so the retry sweep does not
// churn, but never persist it as a published drop.
guard let encoded = envelope.encode(), encoded.count <= Limits.maxDropEnvelopeBytes else {
rejectedDropKeys.insert(messageID, now: now())
completion(false)
return
}
let operationID = UUID()
activeDropOperations[messageID] = ActiveDropOperation(id: operationID, completion: completion)
publishDrop(envelope, messageID: messageID, operationID: operationID)
}
/// Publishes held envelopes (mail we carry for others) as drops,
@@ -121,22 +258,61 @@ final class BridgeCourierService: ObservableObject {
func publishHeldEnvelopes() {
guard bridgeEnabled?() ?? false, relaysConnected?() ?? false else { return }
for envelope in heldEnvelopes?(Limits.heldEnvelopePublishCooldown) ?? [] {
publishDrop(envelope)
let key = envelope.ciphertext
guard heldDropOperations[key] == nil else { continue }
let operationID = UUID()
heldDropOperations[key] = operationID
publishDrop(envelope) { [weak self] succeeded in
guard let self, self.heldDropOperations[key] == operationID else { return }
self.heldDropOperations.removeValue(forKey: key)
if succeeded {
self.markHeldEnvelopePublished?(envelope)
}
}
}
}
private func publishDrop(_ envelope: CourierEnvelope) {
/// Publishes a drop, or queues it when relays are down. `messageID` is the
/// sender-side dedup key (nil for held/relayed envelopes we don't track);
/// it rides the pending queue so an evicted or failed drop can release its
/// in-flight slot. Completion reports actual NIP-01 relay acceptance.
private func publishDrop(
_ envelope: CourierEnvelope,
messageID: String? = nil,
operationID: UUID? = nil,
untrackedCompletion: (@MainActor (Bool) -> Void)? = nil
) {
guard let encoded = envelope.encode(),
encoded.count <= Limits.maxDropEnvelopeBytes,
!envelope.isExpired else { return }
!envelope.isExpired else {
finishPublish(
messageID: messageID,
operationID: operationID,
succeeded: false,
untrackedCompletion: untrackedCompletion
)
return
}
guard relaysConnected?() ?? false else {
pendingDrops.append((envelope, nil))
if pendingDrops.count > Limits.maxPendingDrops {
pendingDrops.removeFirst(pendingDrops.count - Limits.maxPendingDrops)
// Held mail remains in CourierStore and has no sender operation to
// recover after an in-memory queue loss. Leave its cooldown unset
// and let the next connected refresh offer it again.
guard messageID != nil else {
untrackedCompletion?(false)
return
}
pendingDrops.append((envelope, messageID, operationID))
while pendingDrops.count > Limits.maxPendingDrops {
let evicted = pendingDrops.removeFirst()
finishPublish(
messageID: evicted.dedupKey,
operationID: evicted.operationID,
succeeded: false
)
}
return
}
guard let identity = Self.makeThrowawayIdentity(),
guard let identity = try? NostrIdentity.generate(),
let event = try? NostrProtocol.createCourierDropEvent(
envelope: encoded,
recipientTagHex: envelope.recipientTag.hexEncodedString(),
@@ -144,10 +320,63 @@ final class BridgeCourierService: ObservableObject {
senderIdentity: identity
) else {
SecureLogger.error("📦🌉 Failed to compose courier drop", category: .encryption)
finishPublish(
messageID: messageID,
operationID: operationID,
succeeded: false,
untrackedCompletion: untrackedCompletion
)
return
}
publishEvent?(event)
SecureLogger.debug("📦🌉 Published courier drop for tag \(envelope.recipientTag.hexEncodedString().prefix(8))", category: .session)
guard let publishEvent else {
SecureLogger.error("📦🌉 Courier drop publisher is not configured", category: .session)
finishPublish(
messageID: messageID,
operationID: operationID,
succeeded: false,
untrackedCompletion: untrackedCompletion
)
return
}
publishEvent(event) { [weak self] succeeded in
guard let self else { return }
guard self.finishPublish(
messageID: messageID,
operationID: operationID,
succeeded: succeeded,
untrackedCompletion: untrackedCompletion
) else { return }
if succeeded {
SecureLogger.debug("📦🌉 Published courier drop for tag \(envelope.recipientTag.hexEncodedString().prefix(8))", category: .session)
} else {
SecureLogger.warning("📦🌉 No relay accepted courier drop", category: .session)
}
}
}
@discardableResult
private func finishPublish(
messageID: String?,
operationID: UUID?,
succeeded: Bool,
untrackedCompletion: (@MainActor (Bool) -> Void)? = nil
) -> Bool {
guard let messageID else {
untrackedCompletion?(succeeded)
return true
}
// Missing/mismatched means this callback was duplicated, invalidated
// by panic wipe, or belongs to an older attempt for the same key.
guard let operationID,
let operation = activeDropOperations[messageID],
operation.id == operationID else { return false }
activeDropOperations.removeValue(forKey: messageID)
if succeeded {
publishedDropKeys.insert(messageID, now: now())
persistDedup()
}
operation.completion(succeeded)
return true
}
/// Drops queued while relays were unreachable publish on reconnect.
@@ -156,7 +385,11 @@ final class BridgeCourierService: ObservableObject {
let queued = pendingDrops
pendingDrops.removeAll()
for item in queued {
publishDrop(item.envelope)
publishDrop(
item.envelope,
messageID: item.dedupKey,
operationID: item.operationID
)
}
}
@@ -167,7 +400,15 @@ final class BridgeCourierService: ObservableObject {
/// (tags rotate daily); idempotent.
func refresh() {
armRefreshTimerIfNeeded()
guard bridgeEnabled?() ?? false, relaysConnected?() ?? false else {
guard bridgeEnabled?() ?? false else {
cancelActivePublishes()
if subscriptionOpen {
closeSubscription?()
subscriptionOpen = false
}
return
}
guard relaysConnected?() ?? false else {
if subscriptionOpen {
closeSubscription?()
subscriptionOpen = false
@@ -209,11 +450,37 @@ final class BridgeCourierService: ObservableObject {
/// Announce-driven refresh, debounced a newly verified peer should be
/// watched promptly, but announce storms must not thrash subscriptions.
/// Calls inside the window coalesce into one trailing refresh so peers
/// learned after the leading edge are not omitted until the 30-minute
/// periodic timer.
func refreshAfterVerifiedAnnounce() {
guard bridgeEnabled?() ?? false else { return }
guard now().timeIntervalSince(lastAnnounceRefresh) >= Limits.announceRefreshDebounceSeconds else { return }
lastAnnounceRefresh = now()
refresh()
let date = now()
let elapsed = date.timeIntervalSince(lastAnnounceRefresh)
if elapsed >= Limits.announceRefreshDebounceSeconds {
lastAnnounceRefresh = date
refresh()
return
}
guard !announceRefreshTimerArmed else { return }
announceRefreshTimerArmed = true
let delay = max(0, Limits.announceRefreshDebounceSeconds - elapsed)
let fire: @MainActor () -> Void = { [weak self] in
guard let self else { return }
self.announceRefreshTimerArmed = false
guard self.bridgeEnabled?() ?? false else { return }
self.lastAnnounceRefresh = self.now()
self.refresh()
}
if let scheduleTimer {
scheduleTimer(delay, fire)
} else {
Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
fire()
}
}
}
private func armRefreshTimerIfNeeded() {
@@ -241,7 +508,10 @@ final class BridgeCourierService: ObservableObject {
func handleDropEvent(_ event: NostrEvent) {
guard bridgeEnabled?() ?? false else { return }
guard event.kind == NostrProtocol.EventKind.courierDrop.rawValue else { return }
guard seenDropEventIDs.insert(event.id) else { return }
// A resubscribe can still deliver an event from the old watch set.
// Do not durably consume it until it actually belongs to us/current
// local peer and is opened or accepted for physical delivery.
guard !seenDropEventIDs.contains(event.id, now: now()) else { return }
guard let data = Data(base64Encoded: event.content),
data.count <= Limits.maxDropEnvelopeBytes,
let envelope = CourierEnvelope.decode(data),
@@ -256,29 +526,40 @@ final class BridgeCourierService: ObservableObject {
if myTagsHex.contains(tagHex) {
SecureLogger.info("📦🌉 Courier drop for us arrived via bridge", category: .session)
openEnvelope?(envelope)
if openEnvelope?(envelope) == true {
seenDropEventIDs.insert(event.id, now: now())
persistDedup()
}
return
}
if let match = watchedPeerTags.first(where: { $0.tagsHex.contains(tagHex) }) {
SecureLogger.info("📦🌉 Courier drop fetched for local peer \(match.peerID.id.prefix(8))", category: .session)
deliverToPeer?(envelope, match.peerID)
if deliverToPeer?(envelope, match.peerID) == true {
seenDropEventIDs.insert(event.id, now: now())
persistDedup()
}
}
}
// MARK: - Helpers
/// A fresh random Nostr identity for signing one drop.
/// Cancels queued and in-flight sender operations after bridge disable or
/// panic wipe. Invalidate first, then resolve false so callback re-entry
/// cannot be mistaken for an active operation; late relay callbacks no-op.
private func cancelActivePublishes() {
let invalidated = activeDropOperations.values.map(\.completion)
pendingDrops.removeAll()
activeDropOperations.removeAll()
// Untracked held publishes are invalidated too. Their late relay
// callbacks compare operation IDs and no-op after this reset.
heldDropOperations.removeAll()
invalidated.forEach { $0(false) }
}
/// A fresh random Nostr identity for signing one drop. Delegates to the
/// canonical generator (Schnorr key that can't fail validity) instead of
/// hand-rolling SecRandom + retry.
static func makeThrowawayIdentity() -> NostrIdentity? {
for _ in 0..<10 {
var bytes = Data(count: 32)
let status = bytes.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
}
guard status == errSecSuccess else { return nil }
if let identity = try? NostrIdentity(privateKeyData: bytes) {
return identity
}
}
return nil
try? NostrIdentity.generate()
}
}
@@ -0,0 +1,137 @@
//
// BridgeDropDedupStore.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import Foundation
/// ID set with per-entry timestamps: entries expire after `lifetime` and the
/// oldest are evicted past `capacity`. The bridge's dedup caches use this
/// instead of `BoundedIDSet` because their contents persist across relaunches
/// and must age out with the 24h drop window they guard.
struct ExpiringIDSet {
private(set) var entries: [String: Date]
let capacity: Int
let lifetime: TimeInterval
init(capacity: Int, lifetime: TimeInterval, entries: [String: Date] = [:], now: Date = Date()) {
self.capacity = capacity
self.lifetime = lifetime
self.entries = entries
prune(now: now)
}
func contains(_ id: String, now: Date) -> Bool {
guard let recorded = entries[id] else { return false }
return now.timeIntervalSince(recorded) <= lifetime
}
/// Returns false when the ID was already present (and unexpired).
@discardableResult
mutating func insert(_ id: String, now: Date) -> Bool {
guard !contains(id, now: now) else { return false }
entries[id] = now
prune(now: now)
return true
}
/// Releases a previously inserted ID so it can be re-added later (e.g. a
/// queued drop evicted before it ever published must become retryable).
mutating func remove(_ id: String) {
entries.removeValue(forKey: id)
}
private mutating func prune(now: Date) {
if entries.contains(where: { now.timeIntervalSince($0.value) > lifetime }) {
entries = entries.filter { now.timeIntervalSince($0.value) <= lifetime }
}
let overflow = entries.count - capacity
guard overflow > 0 else { return }
for (id, _) in entries.sorted(by: { $0.value < $1.value }).prefix(overflow) {
entries.removeValue(forKey: id)
}
}
}
/// Disk persistence for the bridge courier's drop-dedup record. Relays hold
/// drops for the full 24h NIP-40 window and redeliver them on every launch,
/// and the 120s outbox sweep re-deposits anything undelivered so with
/// in-memory-only dedup every relaunch republished the same message as a
/// fresh drop (fresh throwaway seal, undeduplicatable downstream) and every
/// gateway relaunch re-delivered the whole backlog. Field-verified: ~20
/// copies of one DM delivered in 40ms fed the storm behind a permanent
/// device freeze. Persisting both sides caps this at one drop per message
/// ID per 24h regardless of relaunch count.
///
/// Contents are opaque IDs (message UUIDs, relay event IDs) no plaintext,
/// no peer identities so until-first-unlock protection matches
/// `NostrProcessedEventStore`, and the file must load during a
/// locked-background restoration relaunch. Wiped on panic with the rest of
/// the courier state.
final class BridgeDropDedupStore {
struct Snapshot: Codable {
var publishedDropKeys: [String: Date]
var seenDropEventIDs: [String: Date]
}
private let fileURL: URL?
/// - Parameter fileURL: Overrides the on-disk location (tests). Ignored
/// when `persistsToDisk` is false.
init(persistsToDisk: Bool = true, fileURL: URL? = nil) {
self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
}
func load() -> Snapshot {
guard let fileURL,
let data = try? Data(contentsOf: fileURL),
let snapshot = try? JSONDecoder().decode(Snapshot.self, from: data) else {
return Snapshot(publishedDropKeys: [:], seenDropEventIDs: [:])
}
return snapshot
}
func save(_ snapshot: Snapshot) {
guard let fileURL else { return }
guard !(snapshot.publishedDropKeys.isEmpty && snapshot.seenDropEventIDs.isEmpty) else {
try? FileManager.default.removeItem(at: fileURL)
return
}
do {
try FileManager.default.createDirectory(
at: fileURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
let data = try JSONEncoder().encode(snapshot)
var options: Data.WritingOptions = [.atomic]
#if os(iOS)
options.insert(.completeFileProtectionUntilFirstUserAuthentication)
#endif
try data.write(to: fileURL, options: options)
} catch {
SecureLogger.error("Failed to persist bridge drop dedup record: \(error)", category: .session)
}
}
/// Panic wipe: forget which drops we published or handled.
func wipe() {
guard let fileURL else { return }
try? FileManager.default.removeItem(at: fileURL)
}
private static func defaultFileURL() -> URL? {
guard let base = try? FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
) else { return nil }
return base
.appendingPathComponent("courier", isDirectory: true)
.appendingPathComponent("bridge-drop-dedup.json")
}
}
+257 -40
View File
@@ -47,9 +47,15 @@ import Foundation
/// already holds the radio copy (`isMessageSeenLocally` on the event's
/// mesh message ID) remote islands' traffic is the only thing worth
/// airtime.
/// Receivers additionally dedup by timeline message ID: a bridged copy
/// carries the original mesh message ID in its `m` tag, so the store's
/// insert-by-ID absorbs radio/bridge duplicates in either arrival order.
/// Receivers key bridge rows by the signed Nostr event ID. The event's `m` tag
/// is only a radio-copy hint: its mesh sender/timestamp fields are public and
/// cannot authenticate the event signer, so letting them own the timeline ID
/// would allow a different signer to front-run the genuine event's dedup slot.
/// When the radio copy is already present the hint avoids duplicate rendering
/// and downlink airtime. If the bridge copy wins the race, a later
/// authenticated radio copy replaces every bridge row that claimed the same
/// hint; the untrusted hint can therefore merge a duplicate but can never
/// suppress the radio-authenticated origin.
///
/// All dependencies are closure-injected (repo convention) so the policy
/// layer is unit-testable without relays, radios, or CoreLocation.
@@ -69,11 +75,25 @@ final class BridgeService: ObservableObject {
static let maxEventAgeSeconds: TimeInterval = 15 * 60
/// Bounded loop-prevention ID caches (oldest evicted).
static let maxTrackedEventIDs = 512
/// Keep radio-replacement aliases for every bridge row that can still
/// be visible in the bounded mesh timeline. Retiring an alias earlier
/// would let valid high-volume ingress delete otherwise visible
/// history merely to preserve the radio-wins invariant.
static let maxTrackedRadioAliases = TransportConfig.meshTimelineCap
/// Presence heartbeat cadence while the bridge is active.
static let presenceIntervalSeconds: TimeInterval = 4 * 60
/// A rendezvous participant counts toward "via bridge" for this long
/// after their last event.
static let participantFreshnessSeconds: TimeInterval = 10 * 60
/// Relay ingress is adversarial: bound both accepted work and the
/// people-sheet state even when an attacker rotates signing keys.
static let inboundEventsPerMinute = 600
static let inboundEventsPerMinutePerSigner = 120
/// Cheap pre-crypto gate for both valid and invalid ingress. Without
/// it, invalid carrier events could force unbounded Schnorr work while
/// never reaching the accepted-event limiter below.
static let signatureVerificationAttemptsPerMinute = 720
static let maxParticipants = 128
/// Content cap, matching the public-message pipeline's own limit.
static let maxContentBytes = 16_000
/// Geohash-cell precision of the rendezvous (neighborhood, ~1.2 km).
@@ -89,7 +109,11 @@ final class BridgeService: ObservableObject {
/// A validated rendezvous message ready for the timeline.
struct InboundBridgeMessage {
let messageID: String
/// Unauthenticated mesh coordinates can only be used to notice that a
/// verified radio copy is already present; they never own bridge dedup.
let radioMessageIDHint: String?
let senderNickname: String
let participantNickname: String?
let senderPubkey: String
let content: String
let timestamp: Date
@@ -114,10 +138,9 @@ final class BridgeService: ObservableObject {
/// The user toggle. While true this device publishes its own public mesh
/// messages to the rendezvous and subscribes to it when online.
@Published private(set) var isEnabled: Bool
/// Distinct remote rendezvous participants seen within the freshness
/// window. Approximate by design: local participants are subtracted by
/// matching their events' mesh message IDs against the local timeline,
/// which cannot attribute silent (presence-only) local peers.
/// Distinct rendezvous participants seen within the freshness window.
/// Radio-copy hints never alter signer locality: their public coordinates
/// can suppress a duplicate row but cannot authenticate a Nostr signer.
@Published private(set) var bridgedPeerCount: Int = 0
/// The people behind the count, newest activity first.
@Published private(set) var bridgedParticipants: [BridgedParticipant] = []
@@ -157,6 +180,13 @@ final class BridgeService: ObservableObject {
var broadcastToMesh: (@MainActor (Data) -> Void)?
/// Delivers a validated inbound bridge message to the mesh timeline.
var injectInbound: (@MainActor (InboundBridgeMessage) -> Void)?
/// Removes a previously injected bridge row when an authenticated radio
/// copy arrives later. The UI hook also discards a not-yet-flushed row.
var removeInjectedInbound: (@MainActor (String) -> Void)?
/// Exact liveness check for a bridge row in either the pending UI pipeline
/// or bounded conversation store. Alias pruning may discard proof only
/// after the corresponding row is already gone.
var isInjectedInboundPresent: (@MainActor (String) -> Bool)?
/// True when the mesh timeline already holds this message ID (the radio
/// copy) used to skip pointless downlink airtime.
var isMessageSeenLocally: (@MainActor (String) -> Bool)?
@@ -183,32 +213,56 @@ final class BridgeService: ObservableObject {
private var rebroadcastEventIDs: BoundedIDSet
/// Timeline message IDs already injected (either arrival path).
private var injectedMessageIDs: BoundedIDSet
/// Signed relay/carrier events already accepted. Kept separate from the
/// loop caches so a mesh arrival can still mark loop suppression even when
/// the relay copy won the race.
private var receivedEventIDs: BoundedIDSet
/// Authenticated radio IDs observed this session. These close the
/// bridge-first race even before the UI pipeline flushes its radio row.
private var observedRadioMessageIDs: BoundedIDSet
/// event ID -> untrusted radio hint. Event IDs own bridge
/// dedup; this bounded reverse index is used only to replace bridge rows
/// after the genuine radio packet has authenticated successfully.
private var injectedRadioAliases: [String: String] = [:]
private var injectedRadioAliasOrder: [String] = []
/// Cells the rendezvous subscription covers (own + neighbor ring).
private(set) var subscribedCells: Set<String> = []
private(set) var queuedUplinks: [QueuedUplink] = []
private var uplinkDepositTimes: [PeerID: [Date]] = [:]
private var downlinkSendTimes: [Date] = []
private var inboundEventTimes: [Date] = []
private var inboundEventTimesBySigner: [String: [Date]] = [:]
private var signatureVerificationTokens = Double(Limits.signatureVerificationAttemptsPerMinute)
private var signatureVerificationLastRefillAt: Date?
private var pendingDownlinks: [(event: NostrEvent, cell: String)] = []
private var downlinkDrainScheduled = false
private var presenceTimerArmed = false
private var lastPresenceAt = Date.distantPast
/// pubkey -> (lastSeen, attributed-to-local-island, last known nickname).
private var participants: [String: (lastSeen: Date, isLocal: Bool, nickname: String?)] = [:]
/// pubkey -> (lastSeen, last known nickname).
private var participants: [String: (lastSeen: Date, nickname: String?)] = [:]
private let defaults: UserDefaults
private let now: () -> Date
private let verifyEventSignature: (NostrEvent) -> Bool
private static let enabledKey = "bridge.userEnabled"
init(defaults: UserDefaults = .standard, now: @escaping () -> Date = Date.init) {
init(
defaults: UserDefaults = .standard,
now: @escaping () -> Date = Date.init,
verifyEventSignature: @escaping (NostrEvent) -> Bool = { $0.isValidSignature() }
) {
self.defaults = defaults
self.now = now
self.verifyEventSignature = verifyEventSignature
self.isEnabled = defaults.bool(forKey: Self.enabledKey)
self.meshBroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
self.publishedEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
self.rebroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
self.injectedMessageIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
self.receivedEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
self.observedRadioMessageIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
}
// MARK: - Toggle & lifecycle
@@ -221,6 +275,8 @@ final class BridgeService: ObservableObject {
queuedUplinks.removeAll()
pendingDownlinks.removeAll()
uplinkDepositTimes.removeAll()
inboundEventTimes.removeAll()
inboundEventTimesBySigner.removeAll()
participants.removeAll()
bridgedPeerCount = 0
bridgedParticipants = []
@@ -292,22 +348,27 @@ final class BridgeService: ObservableObject {
/// Composes and ships the bridged copy of an outgoing public mesh
/// message. Call after the radio send; no-op when the bridge is off,
/// no cell is known, or the message was flagged nearby-only upstream.
func bridgeOutgoing(content: String, messageID: String) {
/// `senderPeerID`/`timestamp` are the origin coordinates of the radio
/// send they (with the content) derive the cross-device-stable mesh
/// message ID that receivers dedup on.
func bridgeOutgoing(content: String, senderPeerID: PeerID, timestamp: Date) {
guard isEnabled, !nearbyOnly, let cell = activeCell ?? currentCell() else { return }
guard content.utf8.count <= Limits.maxContentBytes else { return }
let timestampMs = MeshMessageIdentity.millisecondTimestamp(timestamp)
guard let identity = try? deriveIdentity?(cell),
let event = try? NostrProtocol.createBridgeMeshEvent(
content: content,
cell: cell,
senderIdentity: identity,
nickname: myNickname?(),
meshMessageID: messageID
meshSenderID: senderPeerID.id,
meshTimestampMs: timestampMs
) else {
SecureLogger.error("🌉 Bridge: failed to compose rendezvous event", category: .session)
return
}
publishedEventIDs.insert(event.id)
injectedMessageIDs.insert(messageID) // our own timeline already has it
injectedMessageIDs.insert(event.id) // our own timeline already has it
if relaysConnected?() ?? false {
publishToRelays?(event, cell)
} else if let carrier = NostrCarrierPacket(direction: .toBridge, geohash: cell, event: event),
@@ -376,7 +437,6 @@ final class BridgeService: ObservableObject {
subscribedCells.contains(cell) else {
return
}
guard let kind = classify(event, cell: cell) else { return }
// Events we published come back from our own subscription; they are
// presence-neutral (we never count ourselves) and never re-injected
// or rebroadcast. Two layers: the published-ID cache (this session)
@@ -389,18 +449,23 @@ final class BridgeService: ObservableObject {
publishedEventIDs.insert(event.id) // never downlink it either
return
}
guard event.isValidSignature() else { return }
guard allowSignatureVerificationAttempt(), verifyEventSignature(event) else { return }
guard receivedEventIDs.insert(event.id), allowInboundEvent(from: event.pubkey) else { return }
guard let kind = classify(event, cell: cell) else { return }
switch kind {
case .presence:
recordParticipant(event.pubkey, isLocal: false, nickname: nil)
recordParticipant(event.pubkey, nickname: nil)
case .message(let message):
let isLocalRadioCopy = isMessageSeenLocally?(message.messageID) ?? false
let isLocalRadioCopy = message.radioMessageIDHint.map(radioCopyAlreadyPresent) ?? false
if isLocalRadioCopy {
SecureLogger.debug("🌉 Bridge: radio copy of \(message.messageID.prefix(8))… already present; sender counted as local", category: .session)
// The public m-tag proves only that identical radio content is
// already present, not that this Nostr signer authored it.
// Skip the duplicate row without changing signer locality.
SecureLogger.debug("🌉 Bridge: authenticated radio copy already present; bridge alias skipped", category: .session)
} else if inject(message) {
recordParticipant(event.pubkey, nickname: message.participantNickname)
}
recordParticipant(event.pubkey, isLocal: isLocalRadioCopy, nickname: message.senderNickname)
inject(message)
// Serving duty: carry remote islands' messages onto the radio for
// mesh-only peers. Local-origin events are skipped the island
// already heard them (loop rule 3). The drain is jitter-delayed:
@@ -419,6 +484,33 @@ final class BridgeService: ObservableObject {
}
}
/// Called only after the BLE public-message signature has authenticated.
/// A bridge hint is not trusted enough to drop this radio row. Instead,
/// the radio row wins and any earlier bridge aliases are removed, which
/// gives both arrival orders one timeline row without restoring the
/// origin-spoof vulnerability.
func handleAuthenticatedRadioMessage(messageID: String) {
guard !messageID.isEmpty else { return }
observedRadioMessageIDs.insert(messageID)
let matching = injectedRadioAliases.filter { $0.value == messageID }
guard !matching.isEmpty else { return }
let eventIDs = Set(matching.keys)
for eventID in matching.keys {
removeInjectedInbound?(eventID)
injectedRadioAliases.removeValue(forKey: eventID)
}
injectedRadioAliasOrder.removeAll { eventIDs.contains($0) }
// A relay event can already be waiting inside the multi-gateway jitter
// window. Remove it now so it cannot consume BLE airtime after the
// authenticated radio packet proved this island already has a copy.
pendingDownlinks.removeAll { item in
guard case .message(let message)? = classify(item.event, cell: item.cell) else { return false }
return message.radioMessageIDHint == messageID
}
}
// MARK: - Mesh carrier ingress (both roles)
/// Entry point for received `nostrCarrier` packets with bridge
@@ -455,7 +547,7 @@ final class BridgeService: ObservableObject {
SecureLogger.debug("🌉 Bridge: rate-limited deposit from \(depositor.id.prefix(8))", category: .session)
return
}
guard event.isValidSignature() else {
guard allowSignatureVerificationAttempt(), verifyEventSignature(event) else {
SecureLogger.debug("🌉 Bridge: rejected deposit from \(depositor.id.prefix(8))… (bad signature)", category: .security)
return
}
@@ -511,6 +603,52 @@ final class BridgeService: ObservableObject {
return true
}
/// Bounds relay/carrier work independently of the downlink airtime budget.
/// A valid signature proves control of one key, not that the sender is
/// entitled to unbounded main-actor state or CPU.
private func allowInboundEvent(from signer: String) -> Bool {
let date = now()
let cutoff = date.addingTimeInterval(-60)
inboundEventTimes.removeAll { $0 < cutoff }
guard inboundEventTimes.count < Limits.inboundEventsPerMinute else { return false }
var signerTimes = inboundEventTimesBySigner[signer, default: []]
signerTimes.removeAll { $0 < cutoff }
guard signerTimes.count < Limits.inboundEventsPerMinutePerSigner else {
inboundEventTimesBySigner[signer] = signerTimes
return false
}
inboundEventTimes.append(date)
signerTimes.append(date)
inboundEventTimesBySigner[signer] = signerTimes
if inboundEventTimesBySigner.count > Limits.maxTrackedEventIDs {
inboundEventTimesBySigner = inboundEventTimesBySigner.filter { entry in
entry.value.contains { $0 >= cutoff }
}
}
return true
}
/// Bounds expensive signature checks before signer identity is trusted.
/// Kept independent from accepted-event accounting so invalid events do
/// not create signer state or poison any dedup cache.
private func allowSignatureVerificationAttempt() -> Bool {
let date = now()
if let lastRefillAt = signatureVerificationLastRefillAt {
let elapsed = max(0, date.timeIntervalSince(lastRefillAt))
let refillPerSecond = Double(Limits.signatureVerificationAttemptsPerMinute) / 60
signatureVerificationTokens = min(
Double(Limits.signatureVerificationAttemptsPerMinute),
signatureVerificationTokens + elapsed * refillPerSecond
)
}
signatureVerificationLastRefillAt = date
guard signatureVerificationTokens >= 1 else { return false }
signatureVerificationTokens -= 1
return true
}
// MARK: - Downlink (gateway role: internet -> mesh)
private func drainPendingDownlinks() {
@@ -521,9 +659,15 @@ final class BridgeService: ObservableObject {
let (event, cell) = pendingDownlinks.removeFirst()
guard isFresh(event) else { continue }
// Suppression recheck at send time: another gateway may have
// broadcast this event during our jitter holdoff.
// broadcast this event, or the authenticated radio copy may have
// arrived, during our jitter holdoff.
guard !meshBroadcastEventIDs.contains(event.id),
!rebroadcastEventIDs.contains(event.id) else { continue }
if case .message(let message)? = classify(event, cell: cell),
let radioMessageID = message.radioMessageIDHint,
radioCopyAlreadyPresent(radioMessageID) {
continue
}
guard let carrier = NostrCarrierPacket(direction: .fromBridge, geohash: cell, event: event),
let payload = carrier.encode() else { continue }
broadcastToMesh?(payload)
@@ -572,36 +716,90 @@ final class BridgeService: ObservableObject {
guard let event = structurallyValidEvent(from: carrier),
!publishedEventIDs.contains(event.id),
!isOwnRendezvousEvent(event, cell: carrier.geohash),
event.isValidSignature() else {
allowSignatureVerificationAttempt(),
verifyEventSignature(event) else {
return
}
// Mark after verification (a forged copy must not poison the cache),
// and use the marking as multi-path dedup.
guard meshBroadcastEventIDs.insert(event.id) else { return }
// even when the relay copy won the injection race: pending gateway
// downlinks consult this cache and must stand down.
let firstMeshArrival = meshBroadcastEventIDs.insert(event.id)
guard firstMeshArrival,
receivedEventIDs.insert(event.id),
allowInboundEvent(from: event.pubkey) else { return }
guard case .message(let message)? = classify(event, cell: carrier.geohash) else {
return
}
recordParticipant(event.pubkey, isLocal: false, nickname: message.senderNickname)
inject(message)
if inject(message) {
recordParticipant(event.pubkey, nickname: message.participantNickname)
}
}
// MARK: - Injection & participants
private func inject(_ message: InboundBridgeMessage) {
guard injectedMessageIDs.insert(message.messageID) else { return }
guard !(isMessageSeenLocally?(message.messageID) ?? false) else { return }
@discardableResult
private func inject(_ message: InboundBridgeMessage) -> Bool {
guard injectedMessageIDs.insert(message.messageID) else { return false }
guard !(message.radioMessageIDHint.map(radioCopyAlreadyPresent) ?? false) else {
return false
}
SecureLogger.info("🌉 Bridge: injected bridged message \(message.messageID.prefix(8))… from \(message.senderNickname)", category: .session)
injectInbound?(message)
if let radioMessageID = message.radioMessageIDHint {
recordRadioAlias(
eventID: message.messageID,
radioMessageID: radioMessageID
)
}
return true
}
private func recordParticipant(_ pubkey: String, isLocal: Bool, nickname: String?) {
private func radioCopyAlreadyPresent(_ messageID: String) -> Bool {
observedRadioMessageIDs.contains(messageID) || (isMessageSeenLocally?(messageID) ?? false)
}
private func recordRadioAlias(
eventID: String,
radioMessageID: String
) {
guard injectedRadioAliases[eventID] == nil else { return }
injectedRadioAliases[eventID] = radioMessageID
injectedRadioAliasOrder.append(eventID)
guard injectedRadioAliasOrder.count > Limits.maxTrackedRadioAliases,
let isInjectedInboundPresent else { return }
// Arrival order and signed event timestamps are independent. The
// conversation cap trims by timestamp, so blindly evicting the oldest
// alias could discard the proof for a still-visible row and then
// actively delete that history. Prune only aliases whose exact row is
// already absent; temporary overflow is safer than losing radio-wins
// reconciliation for any visible bridge message.
var overflow = injectedRadioAliasOrder.count - Limits.maxTrackedRadioAliases
var retained: [String] = []
retained.reserveCapacity(injectedRadioAliasOrder.count)
for candidate in injectedRadioAliasOrder {
if overflow > 0, !isInjectedInboundPresent(candidate) {
injectedRadioAliases.removeValue(forKey: candidate)
overflow -= 1
} else {
retained.append(candidate)
}
}
injectedRadioAliasOrder = retained
}
private func recordParticipant(_ pubkey: String, nickname: String?) {
let cutoff = now().addingTimeInterval(-Limits.participantFreshnessSeconds)
participants = participants.filter { $0.value.lastSeen >= cutoff }
if participants[pubkey] == nil, participants.count >= Limits.maxParticipants,
let oldest = participants.min(by: { $0.value.lastSeen < $1.value.lastSeen })?.key {
participants.removeValue(forKey: oldest)
}
let previous = participants[pubkey]
// Local attribution is sticky: one radio-confirmed message marks the
// pubkey as an islander for as long as they stay fresh. Presence
// events carry no nickname, so a known name is never forgotten.
// Presence events carry no nickname, so a known name is never
// forgotten. Radio hints deliberately do not mutate this record.
participants[pubkey] = (
lastSeen: now(),
isLocal: (previous?.isLocal ?? false) || isLocal,
nickname: nickname?.trimmedOrNilIfEmpty ?? previous?.nickname
)
recomputeBridgedCount()
@@ -616,7 +814,7 @@ final class BridgeService: ObservableObject {
private func recomputeBridgedCount() {
let cutoff = now().addingTimeInterval(-Limits.participantFreshnessSeconds)
let visible = participants
.filter { $0.value.lastSeen >= cutoff && !$0.value.isLocal }
.filter { $0.value.lastSeen >= cutoff }
.map { BridgedParticipant(pubkey: $0.key, nickname: $0.value.nickname, lastSeen: $0.value.lastSeen) }
.sorted { $0.lastSeen > $1.lastSeen }
if visible.count != bridgedPeerCount {
@@ -648,11 +846,30 @@ final class BridgeService: ObservableObject {
let content = event.content
guard !content.trimmed.isEmpty, content.utf8.count <= Limits.maxContentBytes else { return nil }
let nickname = event.tags.first(where: { $0.count >= 2 && $0[0] == "n" })?[1]
let meshID = event.tags.first(where: { $0.count >= 2 && $0[0] == "m" })?[1]
let messageID = (meshID?.trimmedOrNilIfEmpty) ?? event.id
// The `m` tag is `[stable ID, sender ID, wire timestamp ms]` for
// radio/bridge duplicate detection. Those coordinates are public
// and not cryptographically bound to this Nostr signer, so they
// are never allowed to own bridge dedup. A copied tag can at most
// make the attacker's own event look like a radio duplicate; it
// cannot reserve the genuine signed event's timeline ID.
let m = event.tags.first(where: { $0.count >= 2 && $0[0] == "m" })
let radioMessageIDHint: String?
if let m, m.count >= 4, m[2].count == 16, m[2].allSatisfy(\.isHexDigit),
let timestampMs = UInt64(m[3]) {
radioMessageIDHint = MeshMessageIdentity.stableID(
senderIDHex: m[2],
timestampMs: timestampMs,
content: content
)
} else {
radioMessageIDHint = nil
}
let baseNickname = nickname?.trimmedOrNilIfEmpty ?? "anon"
return .message(InboundBridgeMessage(
messageID: messageID,
senderNickname: nickname?.trimmedOrNilIfEmpty ?? "anon#\(event.pubkey.prefix(4))",
messageID: event.id,
radioMessageIDHint: radioMessageIDHint,
senderNickname: baseNickname + "#" + String(event.pubkey.suffix(4)),
participantNickname: nickname?.trimmedOrNilIfEmpty,
senderPubkey: event.pubkey,
content: content,
timestamp: Date(timeIntervalSince1970: TimeInterval(event.created_at))
@@ -15,12 +15,6 @@ struct GeohashChatPreview: Equatable, Sendable {
let senderName: String
let content: String
let timestamp: Date
init(senderName: String, content: String, timestamp: Date) {
self.senderName = senderName
self.content = content
self.timestamp = timestamp
}
}
/// The liveliest nearby conversation, resolved against the user's regional
+55 -7
View File
@@ -45,6 +45,9 @@ final class GeohashPresenceService: ObservableObject {
private var subscriptions = Set<AnyCancellable>()
private var heartbeatTimer: GeohashPresenceTimerProtocol?
private var pendingBroadcastTasks: [UUID: Task<Void, Never>] = [:]
private var heartbeatGeneration: UInt64 = 0
private var started = false
private let availableChannelsProvider: () -> [GeohashChannel]
private let locationChanges: AnyPublisher<[GeohashChannel], Never>
private let torReadyPublisher: AnyPublisher<Void, Never>
@@ -147,10 +150,25 @@ final class GeohashPresenceService: ObservableObject {
/// Start the service (safe to call multiple times)
func start() {
guard !started else { return }
started = true
heartbeatGeneration &+= 1
SecureLogger.info("Presence: service starting...", category: .session)
scheduleNextHeartbeat()
}
/// Stops the timer and every decorrelation task synchronously at the panic
/// boundary. Generation checks also protect against custom sleepers that
/// ignore task cancellation and return later.
func stopForPanic() {
started = false
heartbeatGeneration &+= 1
heartbeatTimer?.invalidate()
heartbeatTimer = nil
pendingBroadcastTasks.values.forEach { $0.cancel() }
pendingBroadcastTasks.removeAll(keepingCapacity: false)
}
private func setupObservers() {
// Monitor location channel changes
locationChanges
@@ -169,20 +187,26 @@ final class GeohashPresenceService: ObservableObject {
}
func handleLocationChange() {
guard started else { return }
// When location changes, we trigger an immediate (but slightly delayed) heartbeat
// to announce presence in the new zone, then reset the loop.
SecureLogger.debug("Presence: location changed, scheduling update", category: .session)
heartbeatTimer?.invalidate()
// Small delay to allow location state to settle
let generation = heartbeatGeneration
heartbeatTimer = scheduleTimer(5.0) { [weak self] in
Task { @MainActor [weak self] in
self?.performHeartbeat()
guard let self,
self.started,
self.heartbeatGeneration == generation else { return }
self.performHeartbeat()
}
}
}
func handleConnectivityChange() {
guard started else { return }
SecureLogger.debug("Presence: connectivity restored, triggering heartbeat", category: .session)
// If we were waiting for network, do it now
if heartbeatTimer == nil || !heartbeatTimer!.isValid {
@@ -191,18 +215,29 @@ final class GeohashPresenceService: ObservableObject {
}
func scheduleNextHeartbeat() {
guard started else { return }
heartbeatTimer?.invalidate()
let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval)
let generation = heartbeatGeneration
heartbeatTimer = scheduleTimer(interval) { [weak self] in
Task { @MainActor [weak self] in
self?.performHeartbeat()
guard let self,
self.started,
self.heartbeatGeneration == generation else { return }
self.performHeartbeat()
}
}
}
func performHeartbeat() {
guard started else { return }
let generation = heartbeatGeneration
// Always schedule next loop first ensures continuity even if this one fails/skips
defer { scheduleNextHeartbeat() }
defer {
if started, heartbeatGeneration == generation {
scheduleNextHeartbeat()
}
}
// 1. Check preconditions
guard torIsReady() else {
@@ -228,14 +263,27 @@ final class GeohashPresenceService: ObservableObject {
}
// Launch independent task for each channel's delay
Task { @MainActor in
let taskID = UUID()
let sleeper = self.sleeper
let delay = TimeInterval.random(
in: burstMinDelay...burstMaxDelay
)
let nanoseconds = UInt64(delay * 1_000_000_000)
let task = Task { @MainActor [weak self] in
// Random delay for decorrelation
let delay = TimeInterval.random(in: self.burstMinDelay...self.burstMaxDelay)
let nanoseconds = UInt64(delay * 1_000_000_000)
await self.sleeper(nanoseconds)
await sleeper(nanoseconds)
guard let self else { return }
guard !Task.isCancelled,
self.started,
self.heartbeatGeneration == generation else {
self.pendingBroadcastTasks.removeValue(forKey: taskID)
return
}
self.pendingBroadcastTasks.removeValue(forKey: taskID)
self.broadcastPresence(for: channel.geohash)
}
pendingBroadcastTasks[taskID] = task
}
}
+113 -16
View File
@@ -11,6 +11,13 @@ import BitFoundation
import Foundation
import Security
enum KeychainInstallLifecycleAction: Equatable {
case markerPresent
case bootstrapMarker
case clearStaleKeys
case retryLater
}
final class KeychainManager: KeychainManagerProtocol {
/// Default keychain for components that construct their own rather than
/// having one injected. Under test this is an in-memory keychain: the
@@ -41,27 +48,80 @@ final class KeychainManager: KeychainManagerProtocol {
// Use consistent service name for all keychain items
private let service = BitchatApp.bundleID
private let appGroup = "group.\(BitchatApp.bundleID)"
// AfterFirstUnlock, not WhenUnlocked: the mesh keeps running with the
// device locked (identity-cache saves failed with -25308 throughout
// locked-phone testing), and a wake-on-proximity relaunch via BLE state
// restoration must be able to read the noise keys before the user
// unlocks. Backup/sync semantics are unchanged (not ThisDeviceOnly).
private static let itemAccessibility = kSecAttrAccessibleAfterFirstUnlock
// unlocks. ThisDeviceOnly prevents private identities and group keys from
// migrating through device backups onto a second device.
private static let itemAccessibility = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
init() {
#if os(iOS)
reconcileInstallLifecycle()
migrateAccessibilityIfNeeded()
#endif
}
static func installLifecycleAction(
containerKnowsMarker: Bool,
markerRead: KeychainReadResult
) -> KeychainInstallLifecycleAction {
switch markerRead {
case .success:
return containerKnowsMarker ? .markerPresent : .clearStaleKeys
case .itemNotFound:
return .bootstrapMarker
case .accessDenied, .deviceLocked, .authenticationFailed, .otherError:
return .retryLater
}
}
#if os(iOS)
private static let installMarkerAccount = "install_lifecycle_marker"
private static let installMarkerDefaultsKey = "keychain.installLifecycleMarker.present"
/// Keychain items can survive app removal while the app container and its
/// UserDefaults do not. The first version carrying this marker bootstraps
/// without deleting existing users' identities. On a later reinstall, a
/// surviving keychain marker plus a missing defaults marker proves the app
/// container was replaced, so stale secrets are removed before use.
private func reconcileInstallLifecycle() {
let defaults = UserDefaults.standard
let containerKnowsMarker = defaults.bool(forKey: Self.installMarkerDefaultsKey)
let markerRead = retrieveDataWithResult(forKey: Self.installMarkerAccount)
switch Self.installLifecycleAction(
containerKnowsMarker: containerKnowsMarker,
markerRead: markerRead
) {
case .markerPresent:
defaults.set(true, forKey: Self.installMarkerDefaultsKey)
case .bootstrapMarker:
if case .success = saveDataWithResult(Data([1]), forKey: Self.installMarkerAccount) {
defaults.set(true, forKey: Self.installMarkerDefaultsKey)
}
case .clearStaleKeys:
_ = deleteAllKeychainData()
defaults.set(true, forKey: Self.installMarkerDefaultsKey)
case .retryLater:
// Do not guess that a temporarily unreadable marker is absent.
// Leaving the defaults flag unchanged lets a later construction
// retry once protected keychain data is available.
break
}
}
/// One-time upgrade of items created under WhenUnlocked. New saves get
/// the right class on their own (saves are delete-then-add), but the
/// long-lived identity keys are written once and would otherwise stay
/// unreadable while the device is locked.
private func migrateAccessibilityIfNeeded() {
let flag = "keychain.accessibility.afterFirstUnlock.migrated"
let flag = "keychain.accessibility.afterFirstUnlockThisDeviceOnly.migrated"
guard !UserDefaults.standard.bool(forKey: flag) else { return }
let query: [String: Any] = [
@@ -76,7 +136,7 @@ final class KeychainManager: KeychainManagerProtocol {
case errSecSuccess, errSecItemNotFound:
// Nothing to migrate on a fresh install; both are terminal.
UserDefaults.standard.set(true, forKey: flag)
SecureLogger.info("Keychain accessibility migrated to AfterFirstUnlock (status \(status))", category: .keychain)
SecureLogger.info("Keychain accessibility migrated to AfterFirstUnlockThisDeviceOnly (status \(status))", category: .keychain)
default:
// Likely errSecInteractionNotAllowed (relaunched while locked)
// leave the flag unset so the next launch retries.
@@ -492,6 +552,15 @@ final class KeychainManager: KeychainManagerProtocol {
SecureLogger.warning("Panic mode cleanup completed. Total items deleted: \(totalDeleted)", category: .keychain)
#if os(iOS)
// The non-secret marker is intentionally recreated after a panic so a
// later uninstall/reinstall can still be distinguished from an in-place
// upgrade. It never restores any wiped identity or relationship data.
if case .success = saveDataWithResult(Data([1]), forKey: Self.installMarkerAccount) {
UserDefaults.standard.set(true, forKey: Self.installMarkerDefaultsKey)
}
#endif
return totalDeleted > 0
}
@@ -526,22 +595,52 @@ final class KeychainManager: KeychainManagerProtocol {
/// Save data with a custom service name
func save(key: String, data: Data, service customService: String, accessible: CFString?) {
var query: [String: Any] = [
let primaryKeyQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: customService,
kSecAttrAccount as String: key,
kSecValueData as String: data
kSecAttrAccount as String: key
]
if let accessible = accessible {
query[kSecAttrAccessible as String] = accessible
}
var addQuery = primaryKeyQuery
addQuery.merge([
kSecValueData as String: data,
kSecAttrAccessible as String: accessible ?? Self.itemAccessibility,
kSecAttrSynchronizable as String: false
]) { _, new in new }
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
// Delete by the item's primary key only. Value/accessibility fields
// are add attributes, not valid selectors for replacing an existing
// item; including them can leave the old item in place and make the
// subsequent add fail as a duplicate.
let deleteStatus = SecItemDelete(primaryKeyQuery as CFDictionary)
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
SecureLogger.error(
NSError(domain: "Keychain", code: Int(deleteStatus)),
context: "Unable to replace custom-service keychain item",
category: .keychain
)
return
}
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
if addStatus != errSecSuccess {
SecureLogger.error(
NSError(domain: "Keychain", code: Int(addStatus)),
context: "Unable to save custom-service keychain item",
category: .keychain
)
}
}
/// Load data from a custom service
func load(key: String, service customService: String) -> Data? {
guard case .success(let data) = loadWithResult(key: key, service: customService) else {
return nil
}
return data
}
/// Load custom-service data without collapsing `itemNotFound` and
/// protected-data/keychain failures into the same nil result.
func loadWithResult(key: String, service customService: String) -> KeychainReadResult {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: customService,
@@ -551,9 +650,7 @@ final class KeychainManager: KeychainManagerProtocol {
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else { return nil }
return result as? Data
return classifyReadStatus(status, data: result as? Data)
}
/// Delete data from a custom service
+13 -21
View File
@@ -173,6 +173,15 @@ final class LocationNotesManager: ObservableObject {
deinit {
expiryPruneTimer?.invalidate()
connectivityRetryTimer?.invalidate()
// A live REQ must not outlive its manager: relays would keep
// streaming events nobody consumes. deinit is nonisolated, so hop to
// the main actor with just the captured closure and id.
if let sub = subscriptionID {
let unsubscribe = dependencies.unsubscribe
Task { @MainActor in
unsubscribe(sub)
}
}
}
/// Drops notes whose NIP-40 expiry has passed. Their ids stay in
@@ -190,27 +199,10 @@ final class LocationNotesManager: ObservableObject {
}
}
func setGeohash(_ newGeohash: String) {
let norm = newGeohash.lowercased()
guard norm != geohash else { return }
guard Geohash.isValidGeohash(norm) else {
SecureLogger.warning("LocationNotesManager: rejecting invalid geohash '\(norm)' (expected 1-12 valid base32 chars)", category: .session)
return
}
if let sub = subscriptionID {
dependencies.unsubscribe(sub)
subscriptionID = nil
}
// Set loading state before clearing to prevent empty state flicker
state = .loading
initialLoadComplete = false
errorMessage = nil
geohash = norm
ownPubkey = (try? dependencies.deriveIdentity(norm))?.publicKeyHex
notes.removeAll()
noteIDs.removeAll()
subscribe()
}
// A manager's geohash is fixed for its lifetime: instances are pooled
// per geohash (`LocationNotesPool`), so retargeting one in place would
// corrupt the pool's keying and refcounts. Release the manager and
// acquire the new cell instead.
func refresh() {
if let sub = subscriptionID {
+61
View File
@@ -0,0 +1,61 @@
//
// LocationNotesPool.swift
// bitchat
//
// Refcounted pool of LocationNotesManager instances keyed by geohash, so
// surfaces watching the same place (the nearby-notes counter and the notices
// sheet's geo tab) share one relay subscription instead of opening two
// identical 9-cell REQs.
// This is free and unencumbered software released into the public domain.
//
import Foundation
@MainActor
final class LocationNotesPool {
static let shared = LocationNotesPool()
private var entries: [String: (manager: LocationNotesManager, refs: Int)] = [:]
private let makeManager: @MainActor (String) -> LocationNotesManager
/// The factory is injectable so tests can pool managers built over stub
/// dependencies; live use derives one real manager per geohash.
init(makeManager: @escaping @MainActor (String) -> LocationNotesManager = { LocationNotesManager(geohash: $0) }) {
self.makeManager = makeManager
}
/// Returns the shared manager for `geohash` (case-insensitive), creating
/// it on first acquire and reviving a cancelled one on re-acquire.
/// Callers must never `cancel` a pooled manager release it and acquire
/// the new geohash instead.
func acquire(_ geohash: String) -> LocationNotesManager {
let key = geohash.lowercased()
if let entry = entries[key] {
entries[key] = (entry.manager, entry.refs + 1)
if entry.manager.state == .idle {
entry.manager.refresh()
}
return entry.manager
}
let manager = makeManager(key)
entries[key] = (manager, 1)
return manager
}
/// Balances `acquire`: the last release cancels the subscription and
/// drops the entry. Releasing an instance the pool doesn't own (a
/// test-injected manager) degrades to a plain `cancel()`.
func release(_ manager: LocationNotesManager?) {
guard let manager else { return }
guard let entry = entries[manager.geohash], entry.manager === manager else {
manager.cancel()
return
}
if entry.refs <= 1 {
entries[manager.geohash] = nil
manager.cancel()
} else {
entries[manager.geohash] = (entry.manager, entry.refs - 1)
}
}
}
+51 -16
View File
@@ -109,6 +109,8 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
private let teleportedStoreKey = "locationChannel.teleportedSet"
private let bookmarksKey = "locationChannel.bookmarks"
private let bookmarkNamesKey = "locationChannel.bookmarkNames"
private let bookmarkNamesSchemaVersionKey = "locationChannel.bookmarkNamesSchemaVersion"
private let bookmarkNamesCurrentSchemaVersion = 1
// MARK: - Published State (Channel)
@@ -222,6 +224,26 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
let dict = try? JSONDecoder().decode([String: String].self, from: data) {
bookmarkNames = dict
}
migrateBookmarkNamesIfNeeded()
}
/// Drops names cached before low-precision geohashes became country-first.
/// Correctly resolved names written after this migration must survive
/// subsequent launches, so the migration is explicitly versioned.
private func migrateBookmarkNamesIfNeeded() {
guard storage.integer(forKey: bookmarkNamesSchemaVersionKey) < bookmarkNamesCurrentSchemaVersion else {
return
}
let retainedNames = bookmarkNames.filter {
Self.normalizeGeohash($0.key).count > 2
}
if retainedNames.count != bookmarkNames.count {
bookmarkNames = retainedNames
persistBookmarkNames()
}
storage.set(bookmarkNamesCurrentSchemaVersion, forKey: bookmarkNamesSchemaVersionKey)
}
private func initializePermissionState() {
@@ -367,16 +389,16 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
}
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
updatePermissionState(from: status)
if case .authorized = permissionState {
let newState = updatePermissionState(from: status)
if newState == .authorized {
requestOneShotLocation()
}
}
@available(iOS 14.0, macOS 11.0, *)
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
updatePermissionState(from: manager.authorizationStatus)
if case .authorized = permissionState {
let newState = updatePermissionState(from: manager.authorizationStatus)
if newState == .authorized {
requestOneShotLocation()
}
}
@@ -393,7 +415,8 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
// MARK: - Private Helpers (Permission)
private func updatePermissionState(from status: CLAuthorizationStatus) {
@discardableResult
private func updatePermissionState(from status: CLAuthorizationStatus) -> PermissionState {
let newState: PermissionState
switch status {
case .notDetermined: newState = .notDetermined
@@ -402,7 +425,15 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
case .authorizedAlways, .authorizedWhenInUse, .authorized: newState = .authorized
@unknown default: newState = .restricted
}
// Do not rely on a mounted SwiftUI consumer to stop high-accuracy
// updates. Authorization can change while the app is backgrounded,
// and stopping here also closes the gap before the published state is
// delivered on the main actor.
if newState != .authorized {
endLiveRefresh()
}
Task { @MainActor in self.permissionState = newState }
return newState
}
// MARK: - Private Helpers (Channel Computation)
@@ -516,15 +547,15 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
}
private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) {
var uniqueAdmins: [String] = []
var seenAdmins = Set<String>()
var uniqueRegions: [String] = []
var seenRegions = Set<String>()
var idx = 0
func step() {
if idx >= points.count {
let finalName: String? = {
if uniqueAdmins.count >= 2 { return uniqueAdmins[0] + " and " + uniqueAdmins[1] }
return uniqueAdmins.first
if uniqueRegions.count >= 2 { return uniqueRegions[0] + " and " + uniqueRegions[1] }
return uniqueRegions.first
}()
if let finalName = finalName, !finalName.isEmpty {
DispatchQueue.main.async {
@@ -540,12 +571,16 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
guard self != nil else { return }
if let pm = placemarks?.first {
if let admin = pm.administrativeArea, !admin.isEmpty, !seenAdmins.contains(admin) {
seenAdmins.insert(admin)
uniqueAdmins.append(admin)
} else if let country = pm.country, !country.isEmpty, !seenAdmins.contains(country) {
seenAdmins.insert(country)
uniqueAdmins.append(country)
if let country = pm.country, !country.isEmpty {
if !seenRegions.contains(country) {
seenRegions.insert(country)
uniqueRegions.append(country)
}
} else if let admin = pm.administrativeArea,
!admin.isEmpty,
!seenRegions.contains(admin) {
seenRegions.insert(admin)
uniqueRegions.append(admin)
}
}
step()
@@ -557,7 +592,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
private static func nameForGeohashLength(_ len: Int, from pm: CLPlacemark) -> String? {
switch len {
case 0...2:
return pm.administrativeArea ?? pm.country
return pm.country ?? pm.administrativeArea
case 3...4:
return pm.administrativeArea ?? pm.subAdministrativeArea ?? pm.country
case 5:
+16 -2
View File
@@ -51,6 +51,14 @@ final class MeshSightingsTracker: ObservableObject {
defaults.set(Array(seenHashes), forKey: Keys.hashes)
}
/// Re-evaluates the day boundary for the UI. `recordSighting` handles
/// rollover when peers are seen, but an idle app open across midnight
/// would otherwise keep showing yesterday's tally until the next sighting;
/// the empty-state view calls this on its periodic refresh tick.
func refreshForDisplay() {
rollOverIfNeeded()
}
func clear() {
seenHashes.removeAll()
todayCount = 0
@@ -76,8 +84,10 @@ final class MeshSightingsTracker: ObservableObject {
defaults.set(today, forKey: Keys.dayKey)
defaults.set(Self.randomSalt(), forKey: Keys.salt)
defaults.removeObject(forKey: Keys.hashes)
defaults.removeObject(forKey: Keys.lastSeenAt)
seenHashes.removeAll()
todayCount = 0
lastSightingAt = nil
}
private func saltedHash(_ value: String) -> String {
@@ -92,12 +102,16 @@ final class MeshSightingsTracker: ObservableObject {
return digest.finalize().map { String(format: "%02x", $0) }.joined()
}
private static func dayKey(for date: Date) -> String {
private static let dayKeyFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.calendar = Calendar.current
formatter.timeZone = TimeZone.current
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: date)
return formatter
}()
private static func dayKey(for date: Date) -> String {
dayKeyFormatter.string(from: date)
}
private static func randomSalt() -> Data {
@@ -172,11 +172,11 @@ final class MessageDeduplicationService {
/// Cache for Nostr ACK deduplication (messageId:ackType:senderPubkey format)
private let nostrAckCache: LRUDeduplicationCache<Bool>
/// Optional cross-launch persistence for the Nostr event cache. NIP-59
/// randomizes gift-wrap timestamps, so DM subscriptions look back 24h and
/// relays redeliver the same events on every launch; without this record
/// each relaunch reprocesses old PMs and acks. Nil (tests, macOS callers
/// that don't opt in) keeps the cache purely in-memory.
/// Optional cross-launch persistence for the Nostr event cache. BitChat
/// randomizes private-envelope timestamps, so DM subscriptions look back
/// 24h and relays redeliver the same events on every launch; without this
/// record each relaunch reprocesses old PMs and acks. Nil (tests, macOS
/// callers that don't opt in) keeps the cache purely in-memory.
private let nostrEventStore: NostrProcessedEventStore?
private let nostrEventCapacity: Int
private var persistScheduled = false
@@ -246,6 +246,16 @@ final class MessageDeduplicationService {
ContentNormalizer.normalizedKey(content)
}
/// Removes the near-duplicate marker for a row that is being replaced,
/// not merely deleted. Bridge-first/radio-second reconciliation needs the
/// authenticated radio copy to pass the next pipeline flush after its
/// unauthenticated bridge alias is removed.
func forgetContent(_ content: String, ifRecordedAt timestamp: Date) {
let key = ContentNormalizer.normalizedKey(content)
guard contentCache.value(for: key) == timestamp else { return }
contentCache.remove(key)
}
// MARK: - Nostr Event Deduplication
/// Checks if a Nostr event has already been processed.
@@ -304,7 +314,7 @@ final class MessageDeduplicationService {
// MARK: - Clear
/// Clears all caches. This is the wipe/panic path: the persisted
/// gift-wrap record goes with everything else.
/// private-envelope record goes with everything else.
func clearAll() {
contentCache.clear()
nostrEventCache.clear()
@@ -315,7 +325,7 @@ final class MessageDeduplicationService {
/// Clears only the in-memory Nostr caches (events and ACKs). Runs on
/// every geohash channel switch, so the disk record deliberately
/// survives wiping it here would forfeit cross-launch gift-wrap dedup
/// survives wiping it here would forfeit cross-launch private-envelope dedup
/// each time the user changes channels (flagged by Codex on #1398).
func clearNostrCaches() {
nostrEventCache.clear()
@@ -251,9 +251,9 @@ final class MessageFormattingEngine {
isSelf: Bool,
isMentioned: Bool
) -> AttributedString {
// For very long content without special tokens, use plain formatting
let containsCashu = containsCashuToken(content)
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashu {
// For very long content, use plain formatting to avoid expensive
// regex/detector work. Cashu presence must not disable this guard.
if content.isOversizedForRichFormatting() {
return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf)
}
+147 -22
View File
@@ -56,11 +56,15 @@ final class MessageRouter {
/// relays as a courier drop, so delivery stops requiring a physical
/// courier encounter. No-op unless the bridge is enabled. Runs alongside
/// (not instead of) mesh couriers; receivers dedup by message ID.
/// Returns true when a fresh drop was sealed, so the sender's message
/// can show the "carried" state instead of sitting on "sending" forever
/// (the delivery ack has no radio route back until the peers next share
/// a transport).
var bridgeCourierDeposit: ((_ content: String, _ messageID: String, _ recipientNoiseKey: Data) -> Bool)?
/// Completion is true only after at least one default relay explicitly
/// accepts the event, so a socket write followed by rejection cannot
/// falsely show the sender's message as "carried".
var bridgeCourierDeposit: ((
_ content: String,
_ messageID: String,
_ recipientNoiseKey: Data,
_ completion: @escaping @MainActor (Bool) -> Void
) -> Void)?
/// Re-attempts bridge drops for retained messages whose recipient no
/// transport can promptly reach anymore. Covers sends that raced the BLE
@@ -77,9 +81,7 @@ final class MessageRouter {
}
guard !promptlyDeliverable else { continue }
for message in queue where now().timeIntervalSince(message.timestamp) <= Self.messageTTLSeconds {
if bridgeCourierDeposit?(message.content, message.messageID, recipientKey) == true {
onMessageCarried?(message.messageID, peerID)
}
requestBridgeCourierDeposit(message, for: peerID, recipientKey: recipientKey)
}
}
}
@@ -92,12 +94,17 @@ final class MessageRouter {
bridgeSweepTask = Task { @MainActor [weak self] in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
// Expire stale outbox entries in-session too otherwise a DM
// to a peer that never reconnects sits on "sending" until the
// next relaunch instead of surfacing as failed.
self?.cleanupExpiredMessages()
self?.retryBridgeCourierDeposits()
}
}
}
private var bridgeSweepTask: Task<Void, Never>?
private var bridgeDepositsInFlight = Set<String>()
private var outbox: [PeerID: [QueuedMessage]] = [:]
@@ -123,6 +130,9 @@ final class MessageRouter {
self.outboxStore = outboxStore
self.metrics = metrics
self.outbox = outboxStore?.load() ?? [:]
outboxStore?.setRecoveryHandler { [weak self] recovered in
self?.mergeRecoveredOutbox(recovered)
}
// Observe favorites changes to learn Nostr mapping and flush queued messages
NotificationCenter.default.addObserver(
@@ -148,6 +158,16 @@ final class MessageRouter {
}
}
/// Wire one typed event sink to every route, including the queue-backed
/// Nostr transport. Keeping this at the router boundary prevents a relay
/// admission failure from disappearing merely because only the primary
/// mesh transport was assigned a UI delegate.
func setEventDelegate(_ delegate: TransportEventDelegate?) {
for transport in transports {
transport.eventDelegate = delegate
}
}
// MARK: - Transport Selection
private func reachableTransport(for peerID: PeerID) -> Transport? {
@@ -161,14 +181,38 @@ final class MessageRouter {
// MARK: - Message Sending
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
if let transport = connectedTransport(for: peerID) {
// A live link is a strong delivery signal; trust it outright.
if let transport = connectedTransport(for: peerID), transport.canDeliverSecurely(to: peerID) {
// A live link that can complete an encrypted delivery is a
// strong delivery signal; trust it outright.
SecureLogger.debug("Routing PM via \(type(of: transport)) (connected) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
return
}
let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: now(), sendAttempts: 1)
if let transport = connectedTransport(for: peerID) {
// "Connected" without an established secure session is forgeable:
// link bindings heal on signature-verified "direct" announces, but
// directness rides on the unsigned TTL, so a replayed announce can
// bind an absent peer's ID to the replayer's link where the send
// stalls on a handshake the replayer can never complete. Send now
// (a genuine link finishes the handshake and delivers), but retain
// a copy and hand a sealed copy to couriers so nothing is silently
// lost; receivers dedup resends by message ID.
//
// Deliberate metadata tradeoff: every pre-handshake first DM to a
// connected peer hands nearby verified peers a sealed copy, so
// they learn a DM to this recipient exists (never its content
// the envelope is opaque). Accepted for delivery robustness; the
// deposit is cleared on ack. Don't "optimize" the courier call
// away.
SecureLogger.debug("Routing PM via \(type(of: transport)) (connected, no secure session) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
enqueue(message, for: peerID)
attemptCourierDeposit(messageID: messageID, for: peerID)
return
}
if let transport = reachableTransport(for: peerID) {
// Reachability without a connection is a freshness heuristic (e.g.
// the mesh retention window), so the send can silently go nowhere.
@@ -209,9 +253,7 @@ final class MessageRouter {
let entry = queuedMessage(messageID, for: peerID) else { return }
// The bridge drop needs no connected courier only the recipient
// key so it runs before the courier-slot bookkeeping.
if bridgeCourierDeposit?(entry.content, messageID, recipientKey) == true {
onMessageCarried?(messageID, peerID)
}
requestBridgeCourierDeposit(entry, for: peerID, recipientKey: recipientKey)
let remainingSlots = Self.maxCouriersPerMessage - entry.depositedCourierKeys.count
guard remainingSlots > 0 else { return }
@@ -296,6 +338,23 @@ final class MessageRouter {
outbox[peerID]?.first { $0.messageID == messageID }
}
private func requestBridgeCourierDeposit(
_ message: QueuedMessage,
for peerID: PeerID,
recipientKey: Data
) {
guard let bridgeCourierDeposit,
bridgeDepositsInFlight.insert(message.messageID).inserted else { return }
bridgeCourierDeposit(message.content, message.messageID, recipientKey) { [weak self] succeeded in
guard let self else { return }
self.bridgeDepositsInFlight.remove(message.messageID)
// A direct delivery may have cleared the outbox while the relay
// relay confirmation was in flight; do not regress its UI state.
guard succeeded, self.queuedMessage(message.messageID, for: peerID) != nil else { return }
self.onMessageCarried?(message.messageID, peerID)
}
}
private func recordCourierDeposit(messageID: String, for peerID: PeerID, courierKeys: [Data]) {
metrics?.record(.courierDeposited)
guard var queue = outbox[peerID],
@@ -316,16 +375,26 @@ final class MessageRouter {
outbox[peerID] = filtered.isEmpty ? nil : filtered
cleared = true
}
// The durable snapshot may still be hidden by protected data. Record
// the ack even when this cold-load view cannot find the message, then
// persist the current view so the store retains a removal tombstone.
outboxStore?.recordRemoval(messageID: messageID)
if cleared {
metrics?.record(.outboxDelivered)
persistOutbox()
}
persistOutbox()
}
private func enqueue(_ message: QueuedMessage, for peerID: PeerID) {
var message = message
var queue = outbox[peerID] ?? []
// Re-sending an already-queued ID replaces the entry (keeps attempt count fresh)
queue.removeAll { $0.messageID == message.messageID }
// Re-sending an already-queued ID replaces the entry (keeps attempt
// count fresh) but must not forget which couriers already carry it,
// or the replacement re-burns the same courier slots.
if let existing = queue.firstIndex(where: { $0.messageID == message.messageID }) {
message.depositedCourierKeys.formUnion(queue[existing].depositedCourierKeys)
queue.remove(at: existing)
}
queue.append(message)
// Enforce per-peer size limit with FIFO eviction
@@ -348,19 +417,58 @@ final class MessageRouter {
outboxStore?.save(outbox)
}
/// A cold BLE restoration can launch before protected files are readable.
/// The store initially returns an empty snapshot in that case, then calls
/// back after first unlock. Merge by message ID instead of replacing work
/// accepted during the locked wake, persist the union, and immediately
/// resume normal delivery attempts.
private func mergeRecoveredOutbox(_ recovered: MessageOutboxStore.Snapshot) {
for (peerID, recoveredQueue) in recovered {
var queue = outbox[peerID] ?? []
for var recoveredMessage in recoveredQueue {
if let index = queue.firstIndex(where: { $0.messageID == recoveredMessage.messageID }) {
recoveredMessage.sendAttempts = max(recoveredMessage.sendAttempts, queue[index].sendAttempts)
recoveredMessage.depositedCourierKeys.formUnion(queue[index].depositedCourierKeys)
queue[index] = recoveredMessage
} else {
queue.append(recoveredMessage)
}
}
queue.sort { $0.timestamp < $1.timestamp }
if queue.count > Self.maxMessagesPerPeer {
let overflow = queue.count - Self.maxMessagesPerPeer
for dropped in queue.prefix(overflow) {
dropMessage(dropped.messageID, for: peerID)
}
queue.removeFirst(overflow)
}
outbox[peerID] = queue
}
persistOutbox()
flushAllOutbox()
retryBridgeCourierDeposits()
}
/// Panic wipe: forget queued mail on disk and in memory.
func wipeOutbox() {
outbox.removeAll()
outboxStore?.wipe()
}
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
/// Returns true only when the receipt was handed to a reachable transport.
/// A false result means it was dropped (no route) and must NOT be recorded
/// as sent, or the sender's message would stay unread forever the receipt
/// is retried on the next read scan (chat open / foreground / reconnect).
@discardableResult
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool {
if let transport = reachableTransport(for: peerID) {
SecureLogger.debug("Routing READ ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", category: .session)
transport.sendReadReceipt(receipt, to: peerID)
return true
} else if !transports.isEmpty {
SecureLogger.debug("No reachable transport for READ ack to \(peerID.id.prefix(8))", category: .session)
SecureLogger.debug("No reachable transport for READ ack to \(peerID.id.prefix(8)) — leaving unsent for retry", category: .session)
}
return false
}
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
@@ -386,14 +494,31 @@ final class MessageRouter {
continue
}
if let transport = connectedTransport(for: peerID) {
// Live link: send and stop retaining.
if let transport = connectedTransport(for: peerID), transport.canDeliverSecurely(to: peerID) {
// Live link with a secure session: send and stop retaining.
SecureLogger.debug("Outbox -> \(type(of: transport)) (connected) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
metrics?.record(.outboxResent)
} else if let transport = connectedTransport(for: peerID) {
// "Connected" without a secure session possibly a stolen
// binding from a replayed announce: send (a genuine link
// finishes the handshake and delivers) but keep retaining
// until an ack clears it. These flushes do NOT count toward
// the attempt-cap drop: the message was transmitted over a
// live link, so a peer whose handshake stalls across
// reconnect flapping must not burn through the cap and lose
// the store-and-forward copy this retention exists to
// preserve. Retention stays bounded by the 24h outbox TTL
// and the per-peer FIFO cap.
SecureLogger.debug("Outbox -> \(type(of: transport)) (connected, no secure session) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
metrics?.record(.outboxResent)
remaining.append(message)
} else if let transport = reachableTransport(for: peerID) {
// Weak signal: send but keep retaining until an ack clears it,
// bounded by attempt count for peers that never ack.
// Reachability without a connection is a freshness heuristic,
// so the send can silently go nowhere: send but keep retaining
// until an ack clears it, bounded by attempt count for peers
// that never ack.
guard message.sendAttempts < Self.maxSendAttempts else {
SecureLogger.warning("📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session)
dropMessage(message.messageID, for: peerID)
@@ -154,6 +154,19 @@ final class NetworkActivationService: ObservableObject {
.store(in: &cancellables)
}
/// Stops all internet-facing work at the synchronous panic boundary.
/// `start()` may be called again only after the full wipe commits.
func stopForPanic() {
cancellables.removeAll()
started = false
reachabilityMonitor.stop()
activationAllowed = false
torAutoStartDesired = false
relayController.disconnect()
torController.setAutoStartAllowed(false)
applyTorState(torDesired: false)
}
func setUserTorEnabled(_ enabled: Bool) {
guard enabled != userTorEnabled else { return }
userTorEnabled = enabled
@@ -167,6 +180,7 @@ final class NetworkActivationService: ObservableObject {
}
private func reevaluate() {
guard started else { return }
let allowed = effectiveAllowed()
let torDesired = allowed && userTorEnabled
let statusChanged = allowed != activationAllowed
@@ -27,6 +27,8 @@ protocol NetworkReachabilityMonitoring: AnyObject {
var reachabilityPublisher: AnyPublisher<Bool, Never> { get }
/// Begin monitoring. Idempotent.
func start()
/// Stop monitoring and discard pending debounce work. Idempotent.
func stop()
}
/// Pure debounce/decision logic for reachability, split out so it can be
@@ -98,6 +100,7 @@ final class AlwaysReachableMonitor: NetworkReachabilityMonitoring {
Empty(completeImmediately: false).eraseToAnyPublisher()
}
func start() {}
func stop() {}
}
/// `NWPathMonitor`-backed reachability. All state lives on the main actor; the
@@ -146,6 +149,18 @@ final class NWPathReachabilityMonitor: NetworkReachabilityMonitoring {
#endif
}
func stop() {
guard started else { return }
started = false
flushWorkItem?.cancel()
flushWorkItem = nil
#if canImport(Network)
monitor?.pathUpdateHandler = nil
monitor?.cancel()
monitor = nil
#endif
}
/// Feed an observation into the debounce and publish committed changes.
/// Exposed internally so higher layers/tests could drive it if needed.
func ingest(reachable: Bool) {
+280 -30
View File
@@ -165,7 +165,6 @@ final class NoiseEncryptionService {
// Peer fingerprints (SHA256 hash of static public key)
private var peerFingerprints: [PeerID: String] = [:]
private var fingerprintToPeerID: [String: PeerID] = [:]
// Thread safety
private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent)
@@ -183,12 +182,24 @@ final class NoiseEncryptionService {
// Callbacks
private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication
private var onPeerAuthenticatedWithGenerationHandlers: [((PeerID, String, UUID) -> Void)] = []
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
/// Automatic rekey prepared XX message 1. The transport must claim the
/// exact attempt at its actual BLE handoff; a crossed inbound initiation
/// can invalidate the token before that point.
var onRekeyHandshakeReady:
((_ peerID: PeerID, _ initiation: NoiseHandshakeInitiation) -> Void)?
var onHandshakeRecoveryRequired:
((_ request: NoiseHandshakeRecoveryRequest) -> Void)?
/// An unauthenticated reconnect attempt failed or timed out and the
/// receive-only rollback session became the active transport again.
/// Transport queues must be drained for this exact restored generation.
var onSessionRestoredWithGeneration: ((_ peerID: PeerID, _ generation: UUID) -> Void)?
// Add a handler for peer authentication
func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) {
serviceQueue.async(flags: .barrier) { [weak self] in
self?.onPeerAuthenticatedHandlers.append(handler)
serviceQueue.sync(flags: .barrier) {
onPeerAuthenticatedHandlers.append(handler)
}
}
@@ -202,7 +213,29 @@ final class NoiseEncryptionService {
}
}
init(keychain: KeychainManagerProtocol) {
/// Generation-aware authentication notifications are used by protocols
/// whose state must be bound to one exact Noise transport session.
var onPeerAuthenticatedWithGeneration: ((PeerID, String, UUID) -> Void)? {
get { nil }
set {
guard let handler = newValue else { return }
serviceQueue.sync(flags: .barrier) {
onPeerAuthenticatedWithGenerationHandlers.append(handler)
}
}
}
init(
keychain: KeychainManagerProtocol,
ordinaryHandshakeTimeout: TimeInterval =
NoiseSecurityConstants.ordinaryHandshakeTimeout,
ordinaryResponderHandshakeTimeout: TimeInterval =
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout,
recentInitiatorCompletionGracePeriod: TimeInterval =
NoiseSecurityConstants.recentInitiatorCompletionGracePeriod,
ordinaryReconnectRollbackCooldown: TimeInterval =
NoiseSecurityConstants.ordinaryReconnectRollbackCooldown
) {
self.keychain = keychain
self.localPrekeys = LocalPrekeyStore(keychain: keychain)
@@ -292,11 +325,31 @@ final class NoiseEncryptionService {
self.signingPublicKey = signingKey.publicKey
// Initialize session manager
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain)
self.sessionManager = NoiseSessionManager(
localStaticKey: staticIdentityKey,
keychain: keychain,
ordinaryHandshakeTimeout: ordinaryHandshakeTimeout,
ordinaryResponderHandshakeTimeout:
ordinaryResponderHandshakeTimeout,
recentInitiatorCompletionGracePeriod:
recentInitiatorCompletionGracePeriod,
ordinaryReconnectRollbackCooldown:
ordinaryReconnectRollbackCooldown
)
// Set up session callbacks
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in
self?.handleSessionEstablished(peerID: peerID, remoteStaticKey: remoteStaticKey)
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey, generation in
self?.handleSessionEstablished(
peerID: peerID,
remoteStaticKey: remoteStaticKey,
sessionGeneration: generation
)
}
sessionManager.onSessionRestored = { [weak self] peerID, generation in
self?.onSessionRestoredWithGeneration?(peerID, generation)
}
sessionManager.onHandshakeRecoveryRequired = { [weak self] request in
self?.onHandshakeRecoveryRequired?(request)
}
// Start session maintenance timer
@@ -662,6 +715,90 @@ final class NoiseEncryptionService {
return handshakeData
}
/// Atomically admits and prepares one initial ordinary handshake. Returns
/// nil when another discovery callback already created a session.
func initiateHandshakeIfNeeded(
with peerID: PeerID,
retryOnTimeout: Bool = false
) throws -> NoiseHandshakeInitiation? {
guard peerID.isValid else {
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
throw NoiseSecurityError.invalidPeerID
}
guard let initiation = try sessionManager.initiateHandshakeIfAbsent(
with: peerID,
notifyOnTimeout: retryOnTimeout,
authorize: { [rateLimiter] in
guard rateLimiter.allowHandshake(from: peerID) else {
SecureLogger.warning(
.authenticationFailed(peerID: "Rate limited: \(peerID)")
)
throw NoiseSecurityError.rateLimitExceeded
}
}
) else {
return nil
}
SecureLogger.info(.handshakeStarted(peerID: peerID.id))
return initiation
}
/// Atomically prepares an ordinary reconnect for a peer whose cached
/// transport belongs to an earlier physical link. Failed authorization or
/// handshake setup preserves the established session.
func initiateReconnectHandshake(
with peerID: PeerID,
retryOnTimeout: Bool = false
) throws -> NoiseHandshakeInitiation {
guard peerID.isValid else {
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
throw NoiseSecurityError.invalidPeerID
}
return try sessionManager.initiateReconnectHandshake(
with: peerID,
notifyOnTimeout: retryOnTimeout,
authorize: { [rateLimiter] in
guard rateLimiter.allowHandshake(from: peerID) else {
SecureLogger.warning(
.authenticationFailed(peerID: "Rate limited: \(peerID)")
)
throw NoiseSecurityError.rateLimitExceeded
}
}
)
}
func prepareHandshakeRecovery(
_ request: NoiseHandshakeRecoveryRequest
) throws -> NoiseHandshakeRecoveryPreparation? {
try sessionManager.prepareHandshakeRecovery(
request,
authorizeAttempt: { [rateLimiter] in
guard rateLimiter.allowHandshake(from: request.peerID) else {
SecureLogger.warning(
.authenticationFailed(
peerID: "Rate limited: \(request.peerID)"
)
)
throw NoiseSecurityError.rateLimitExceeded
}
}
)
}
func cancelHandshakeRecovery(_ request: NoiseHandshakeRecoveryRequest) {
sessionManager.cancelHandshakeRecovery(request)
}
func claimHandshakeInitiation(
_ initiation: NoiseHandshakeInitiation,
for peerID: PeerID
) -> Data? {
sessionManager.claimHandshakeInitiation(initiation, for: peerID)
}
/// Process an incoming handshake message
func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? {
@@ -685,7 +822,10 @@ final class NoiseEncryptionService {
// For handshakes, we process the raw data directly without NoiseMessage wrapper
// The Noise protocol handles its own message format
let responsePayload = try sessionManager.handleIncomingHandshake(from: peerID, message: message)
let responsePayload = try sessionManager.handleIncomingHandshake(
from: peerID,
message: message
)
// Return raw response without wrapper
@@ -702,6 +842,13 @@ final class NoiseEncryptionService {
return sessionManager.getSession(for: peerID) != nil
}
/// True while an inbound ordinary XX responder is waiting for message 3.
/// A small amount of immediately-following ciphertext may arrive first
/// over BLE and must be retried only after responder promotion.
func isAwaitingResponderHandshakeCompletion(with peerID: PeerID) -> Bool {
sessionManager.isAwaitingResponderHandshakeCompletion(for: peerID)
}
// MARK: - Encryption/Decryption
/// Encrypt data for a specific peer
@@ -726,24 +873,86 @@ final class NoiseEncryptionService {
return try sessionManager.encrypt(data, for: peerID)
}
/// Decrypt data from a specific peer
func decrypt(_ data: Data, from peerID: PeerID) throws -> Data {
// Validate message size
guard NoiseSecurityValidator.validateMessageSize(data) else {
/// Encrypts a finalized private-media packet. Ordinary Noise application
/// messages retain the 64 KiB ceiling; this purpose-specific path permits
/// the bounded `BitchatFilePacket` envelope and refuses every other typed
/// payload so the larger allocation budget cannot become a generic bypass.
func encryptPrivateFilePayload(
_ data: Data,
for peerID: PeerID,
sessionGeneration: UUID? = nil
) throws -> Data {
guard NoisePayloadType.isPrivateFile(rawValue: data.first),
NoiseSecurityValidator.validatePrivateFileMessageSize(data) else {
throw NoiseSecurityError.messageTooLarge
}
// Check rate limit
guard rateLimiter.allowMessage(from: peerID) else {
throw NoiseSecurityError.rateLimitExceeded
}
// Check if we have an established session
guard hasEstablishedSession(with: peerID) else {
onHandshakeRequired?(peerID)
throw NoiseEncryptionError.handshakeRequired
}
// `maxPrivateFilePlaintextSize` already subtracts the cipher's fixed
// nonce/tag overhead, so the result is bounded without a second copy.
if let sessionGeneration {
return try sessionManager.encrypt(
data,
for: peerID,
expectedSessionGeneration: sessionGeneration
)
}
return try sessionManager.encrypt(data, for: peerID)
}
/// Decrypt data from a specific peer
func decrypt(_ data: Data, from peerID: PeerID) throws -> Data {
try decryptWithSessionGeneration(data, from: peerID).plaintext
}
func decryptWithSessionGeneration(
_ data: Data,
from peerID: PeerID,
establishedGenerationIsReady: (UUID) -> Bool = { _ in true }
) throws -> (plaintext: Data, sessionGeneration: UUID) {
// Standard transport ciphertext has 20 bytes of nonce/tag overhead.
// A larger ciphertext is admitted only up to the framed-file ceiling;
// after authenticated decryption it must prove it is `.privateFile`.
let isStandardCiphertext = NoiseSecurityValidator.validateCiphertextSize(data)
let isAdmittedCiphertext = isStandardCiphertext
|| NoiseSecurityValidator.validatePrivateFileCiphertextSize(data)
// A quarantined transport is deliberately unavailable for outbound
// state, but remains receive-only until the responder proves identity
// or the bounded rollback restores it.
guard sessionManager.hasReceiveSession(for: peerID) else {
throw NoiseEncryptionError.sessionNotEstablished
}
return try sessionManager.decrypt(data, from: peerID)
let result = try sessionManager.decryptWithSessionGeneration(
data,
from: peerID,
establishedGenerationIsReady:
establishedGenerationIsReady,
authorizeDecrypt: { [rateLimiter] in
guard isAdmittedCiphertext else {
throw NoiseSecurityError.messageTooLarge
}
guard rateLimiter.allowMessage(from: peerID) else {
throw NoiseSecurityError.rateLimitExceeded
}
}
)
if !isStandardCiphertext {
guard NoisePayloadType.isPrivateFile(rawValue: result.plaintext.first),
NoiseSecurityValidator.validatePrivateFileMessageSize(result.plaintext) else {
throw NoiseSecurityError.messageTooLarge
}
}
return result
}
// MARK: - Peer Management
@@ -755,6 +964,25 @@ final class NoiseEncryptionService {
}
}
func sessionGeneration(for peerID: PeerID) -> UUID? {
sessionManager.sessionGeneration(for: peerID)
}
/// Runs `body` while holding a read lease on the exact session generation.
/// Session insertion, replacement, and removal use the same manager
/// barrier, so they cannot interleave with an authenticated-state commit.
func withCurrentSessionGeneration<Result>(
for peerID: PeerID,
expected: UUID,
_ body: () -> Result
) -> Result? {
sessionManager.withCurrentSessionGeneration(
for: peerID,
expected: expected,
body
)
}
func clearEphemeralStateForPanic() {
sessionManager.removeAllSessions()
serviceQueue.sync(flags: .barrier) {
@@ -777,24 +1005,36 @@ final class NoiseEncryptionService {
// MARK: - Private Helpers
private func handleSessionEstablished(peerID: PeerID, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
private func handleSessionEstablished(
peerID: PeerID,
remoteStaticKey: Curve25519.KeyAgreement.PublicKey,
sessionGeneration: UUID
) {
// Calculate fingerprint
let fingerprint = remoteStaticKey.rawRepresentation.sha256Fingerprint()
// Store fingerprint mapping
serviceQueue.sync(flags: .barrier) {
// Registering handlers is synchronous, and this barrier snapshots them
// with the fingerprint update. Invoke the snapshot outside the queue:
// parallel Swift Testing workers must not block behind queued callback
// registration or allow a handler to re-enter serviceQueue.
let handlers: (
generationAware: [(PeerID, String, UUID) -> Void],
legacy: [(PeerID, String) -> Void]
) = serviceQueue.sync(flags: .barrier) {
peerFingerprints[peerID] = fingerprint
fingerprintToPeerID[fingerprint] = peerID
return (onPeerAuthenticatedWithGenerationHandlers, onPeerAuthenticatedHandlers)
}
// Log security event
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
// Notify all handlers about authentication
serviceQueue.async { [weak self] in
self?.onPeerAuthenticatedHandlers.forEach { handler in
handler(peerID, fingerprint)
}
// Notify all handlers about authentication.
handlers.generationAware.forEach { handler in
handler(peerID, fingerprint, sessionGeneration)
}
handlers.legacy.forEach { handler in
handler(peerID, fingerprint)
}
}
@@ -815,20 +1055,27 @@ final class NoiseEncryptionService {
let sessionsNeedingRekey = sessionManager.getSessionsNeedingRekey()
for (peerID, needsRekey) in sessionsNeedingRekey where needsRekey {
// Attempt to rekey the session
do {
try sessionManager.initiateRekey(for: peerID)
SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security)
// Signal that handshake is needed
onHandshakeRequired?(peerID)
try initiateAutomaticRekey(for: peerID)
} catch {
SecureLogger.error(error, context: "Failed to initiate rekey for peer: \(peerID)", category: .session)
}
}
}
private func initiateAutomaticRekey(for peerID: PeerID) throws {
let initiation = try sessionManager.initiateRekey(for: peerID)
SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security)
onRekeyHandshakeReady?(peerID, initiation)
onHandshakeRequired?(peerID)
}
#if DEBUG
func _test_initiateAutomaticRekey(for peerID: PeerID) throws {
try initiateAutomaticRekey(for: peerID)
}
#endif
deinit {
stopRekeyTimer()
}
@@ -915,6 +1162,9 @@ struct NoiseMessage: Codable {
enum NoiseEncryptionError: Error {
case handshakeRequired
case sessionNotEstablished
/// Manager keys are established or restored, but BLE has not installed
/// generation-bound transport state. No receive nonce was consumed.
case transportGenerationNotReady
/// Envelope references a prekey ID we don't hold (never ours, already
/// deleted after its grace window, or wiped in a panic).
case unknownPrekey
@@ -9,10 +9,10 @@
import BitLogger
import Foundation
/// Disk persistence for processed gift-wrap event IDs. NIP-59 randomizes
/// gift-wrap timestamps, so DM subscriptions must look back generously (24h)
/// and relays redeliver the same events on every launch without a
/// cross-launch record, each relaunch reprocesses old PMs and acks
/// Disk persistence for processed private-envelope event IDs. BitChat
/// randomizes envelope timestamps, so DM subscriptions must look back
/// generously (24h) and relays redeliver the same events on every launch
/// without a cross-launch record, each relaunch reprocesses old PMs and acks
/// (re-sent DELIVERED bursts, "delivered ack for unknown mid" noise).
///
/// Contents are event IDs already visible to every relay, so
+338 -25
View File
@@ -11,8 +11,12 @@ final class NostrTransport: Transport, @unchecked Sendable {
let favoriteStatusForNoiseKey: @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?
let favoriteStatusForPeerID: @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?
let currentIdentity: @MainActor () throws -> NostrIdentity?
let registerPendingGiftWrap: @MainActor (String) -> Void
let sendEvent: @MainActor (NostrEvent) -> Void
let registerPendingPrivateEnvelope: @MainActor (String) -> Void
let sendPrivateEnvelopeBatch: @MainActor (
[NostrEvent],
@escaping @MainActor () -> Void
) -> Bool
let envelopeRetryQueue: NostrPrivateEnvelopeRetryQueue
/// Emits whether a relay that carries private messages is up
/// (fail-closed behind Tor). A connected geohash/custom relay alone
/// doesn't count: DM sends target the default relay set and would
@@ -22,25 +26,35 @@ final class NostrTransport: Transport, @unchecked Sendable {
/// serialize behind each other; `live` passes the process-wide one.
let ackPacer: AckPacer
@MainActor
init(
notificationCenter: NotificationCenter,
loadFavorites: @escaping @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship],
favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?,
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?,
currentIdentity: @escaping @MainActor () throws -> NostrIdentity?,
registerPendingGiftWrap: @escaping @MainActor (String) -> Void,
sendEvent: @escaping @MainActor (NostrEvent) -> Void,
registerPendingPrivateEnvelope: @escaping @MainActor (String) -> Void,
sendPrivateEnvelopeBatch: @escaping @MainActor (
[NostrEvent],
@escaping @MainActor () -> Void
) -> Bool,
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void,
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never>,
ackPacer: AckPacer? = nil
ackPacer: AckPacer? = nil,
envelopeRetryQueue: NostrPrivateEnvelopeRetryQueue? = nil
) {
self.notificationCenter = notificationCenter
self.loadFavorites = loadFavorites
self.favoriteStatusForNoiseKey = favoriteStatusForNoiseKey
self.favoriteStatusForPeerID = favoriteStatusForPeerID
self.currentIdentity = currentIdentity
self.registerPendingGiftWrap = registerPendingGiftWrap
self.sendEvent = sendEvent
self.registerPendingPrivateEnvelope = registerPendingPrivateEnvelope
self.sendPrivateEnvelopeBatch = sendPrivateEnvelopeBatch
self.envelopeRetryQueue = envelopeRetryQueue ?? NostrPrivateEnvelopeRetryQueue(
sendPrivateEnvelopeBatch: sendPrivateEnvelopeBatch,
registerPendingPrivateEnvelope: registerPendingPrivateEnvelope,
scheduleAfter: scheduleAfter
)
self.relayConnectivity = relayConnectivity
// Default pacer drives its throttle through the same injected
// scheduler, so tests that step scheduleAfter manually keep
@@ -56,13 +70,19 @@ final class NostrTransport: Transport, @unchecked Sendable {
favoriteStatusForNoiseKey: { FavoritesPersistenceService.shared.getFavoriteStatus(for: $0) },
favoriteStatusForPeerID: { FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: $0) },
currentIdentity: { try idBridge.getCurrentNostrIdentity() },
registerPendingGiftWrap: { NostrRelayManager.registerPendingGiftWrap(id: $0) },
sendEvent: { NostrRelayManager.shared.sendEvent($0) },
registerPendingPrivateEnvelope: { NostrRelayManager.registerPendingPrivateEnvelope(id: $0) },
sendPrivateEnvelopeBatch: { events, terminalFailure in
NostrRelayManager.shared.sendPrivateEnvelopeBatch(
events,
terminalFailure: terminalFailure
)
},
scheduleAfter: { delay, action in
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
},
relayConnectivity: { NostrRelayManager.shared.$isDMRelayConnected.eraseToAnyPublisher() },
ackPacer: NostrTransport.sharedAckPacer
ackPacer: NostrTransport.sharedAckPacer,
envelopeRetryQueue: NostrTransport.sharedEnvelopeRetryQueue
)
}
}
@@ -128,7 +148,37 @@ final class NostrTransport: Transport, @unchecked Sendable {
}
}
static let sharedAckPacer = AckPacer()
// Geohash acknowledgements use short-lived NostrTransport instances, so
// the retry owner must be process-wide. A per-transport cap would still be
// globally unbounded under outage as throwaway instances accumulated.
@MainActor
private static let sharedEnvelopeRetryQueue = NostrPrivateEnvelopeRetryQueue(
sendPrivateEnvelopeBatch: { events, terminalFailure in
NostrRelayManager.shared.sendPrivateEnvelopeBatch(
events,
terminalFailure: terminalFailure
)
},
registerPendingPrivateEnvelope: {
NostrRelayManager.registerPendingPrivateEnvelope(id: $0)
},
scheduleAfter: { delay, action in
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
}
)
@MainActor
static func resetControlRetriesForPanicWipe() {
sharedEnvelopeRetryQueue.removeAll()
}
private enum PrivateEnvelopeFailurePolicy {
case userMessage(messageID: String)
case retry(retryKey: String)
}
private let dependencies: Dependencies
private let envelopeRetryQueue: NostrPrivateEnvelopeRetryQueue
private var favoriteStatusObserver: NSObjectProtocol?
// Reachability Cache (thread-safe)
@@ -145,7 +195,9 @@ final class NostrTransport: Transport, @unchecked Sendable {
idBridge: NostrIdentityBridge,
dependencies: Dependencies? = nil
) {
self.dependencies = dependencies ?? .live(idBridge: idBridge)
let resolvedDependencies = dependencies ?? .live(idBridge: idBridge)
self.dependencies = resolvedDependencies
self.envelopeRetryQueue = resolvedDependencies.envelopeRetryQueue
setupObservers()
@@ -172,6 +224,18 @@ final class NostrTransport: Transport, @unchecked Sendable {
}
}
#if DEBUG
@MainActor
func debugEnqueueControlRetry(key: String, events: [NostrEvent]) {
envelopeRetryQueue.enqueue(key: key, events: events, registerPending: false)
}
@MainActor
var debugControlRetryCount: Int {
envelopeRetryQueue.debugPendingCount
}
#endif
private func setupObservers() {
favoriteStatusObserver = dependencies.notificationCenter.addObserver(
forName: .favoriteStatusChanged,
@@ -231,6 +295,13 @@ final class NostrTransport: Transport, @unchecked Sendable {
isPeerReachable(peerID) && queue.sync { relaysConnected }
}
func canDeliverSecurely(to peerID: PeerID) -> Bool {
// Nostr has no link bindings to forge; a known recipient key plus a
// connected relay is the strongest delivery signal it has. The router
// already retains + couriers for Nostr sends, so keep that behavior.
canDeliverPromptly(to: peerID)
}
func peerNickname(peerID: PeerID) -> String? { nil }
func getPeerNicknames() -> [PeerID: String] { [:] }
@@ -254,7 +325,12 @@ final class NostrTransport: Transport, @unchecked Sendable {
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
return
}
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
sendPrivateEnvelope(
content: embedded,
recipientHex: recipientHex,
senderIdentity: senderIdentity,
failurePolicy: .userMessage(messageID: messageID)
)
}
}
@@ -280,7 +356,14 @@ final class NostrTransport: Transport, @unchecked Sendable {
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
return
}
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
sendPrivateEnvelope(
content: embedded,
recipientHex: recipientHex,
senderIdentity: senderIdentity,
failurePolicy: .retry(
retryKey: privateEnvelopeRetryKey(content: embedded, recipientHex: recipientHex)
)
)
}
}
@@ -312,7 +395,13 @@ extension NostrTransport {
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
return
}
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
sendPrivateEnvelope(
content: embedded,
recipientHex: recipientHex,
senderIdentity: identity,
registerPending: true,
failurePolicy: .userMessage(messageID: messageID)
)
}
}
}
@@ -333,17 +422,92 @@ extension NostrTransport {
}
}
/// Creates and sends a gift-wrapped private message event
/// Creates and sends a BitChat private-envelope event over Nostr.
@MainActor
private func sendWrappedMessage(content: String, recipientHex: String, senderIdentity: NostrIdentity, registerPending: Bool = false) {
guard let event = try? NostrProtocol.createPrivateMessage(content: content, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event", category: .session)
private func sendPrivateEnvelope(
content: String,
recipientHex: String,
senderIdentity: NostrIdentity,
registerPending: Bool = false,
failurePolicy: PrivateEnvelopeFailurePolicy
) {
guard let events = try? NostrProtocol.createPrivateEnvelopePublicationBatch(
content: content,
recipientPubkey: recipientHex,
senderIdentity: senderIdentity
) else {
SecureLogger.error("NostrTransport: failed to build Nostr private-envelope batch", category: .session)
return
}
if registerPending {
dependencies.registerPendingGiftWrap(event.id)
let accepted = dependencies.sendPrivateEnvelopeBatch(events) { [self] in
handlePrivateEnvelopeFailure(
events: events,
registerPending: registerPending,
policy: failurePolicy
)
}
guard accepted else {
SecureLogger.error(
"NostrTransport: private-envelope migration pair was not accepted for relay delivery",
category: .session
)
handlePrivateEnvelopeFailure(
events: events,
registerPending: registerPending,
policy: failurePolicy
)
return
}
registerPendingPrivateEnvelopesIfNeeded(events, registerPending: registerPending)
}
@MainActor
private func handlePrivateEnvelopeFailure(
events: [NostrEvent],
registerPending: Bool,
policy: PrivateEnvelopeFailurePolicy
) {
switch policy {
case .userMessage(let messageID):
deliverTransportEvent(.messageDeliveryStatusUpdated(
messageID: messageID,
status: .failed(reason: String(
localized: "content.delivery.reason.not_delivered",
comment: "Failure reason shown when a private message could not enter the relay delivery queue"
))
))
case .retry(let retryKey):
envelopeRetryQueue.enqueue(
key: retryKey,
events: events,
registerPending: registerPending
)
}
}
@MainActor
private func registerPendingPrivateEnvelopesIfNeeded(
_ events: [NostrEvent],
registerPending: Bool
) {
guard registerPending else { return }
for event in events {
dependencies.registerPendingPrivateEnvelope(event.id)
}
}
@MainActor
private func privateEnvelopeRetryKey(content: String, recipientHex: String) -> String {
"\(recipientHex.lowercased()):\(Data(content.utf8).sha256Fingerprint())"
}
@MainActor
private func deliverTransportEvent(_ event: TransportEvent) {
if let eventDelegate {
eventDelegate.didReceiveTransportEvent(event)
} else {
delegate?.receiveTransportEvent(event)
}
dependencies.sendEvent(event)
}
@@ -360,7 +524,14 @@ extension NostrTransport {
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
return
}
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
sendPrivateEnvelope(
content: ack,
recipientHex: recipientHex,
senderIdentity: senderIdentity,
failurePolicy: .retry(
retryKey: privateEnvelopeRetryKey(content: ack, recipientHex: recipientHex)
)
)
case .deliveredDirect(let messageID, let peerID):
guard let recipientNpub = resolveRecipientNpub(for: peerID),
@@ -371,17 +542,40 @@ extension NostrTransport {
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
return
}
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
sendPrivateEnvelope(
content: ack,
recipientHex: recipientHex,
senderIdentity: senderIdentity,
failurePolicy: .retry(
retryKey: privateEnvelopeRetryKey(content: ack, recipientHex: recipientHex)
)
)
case .deliveredGeohash(let messageID, let recipientHex, let identity):
SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
sendPrivateEnvelope(
content: embedded,
recipientHex: recipientHex,
senderIdentity: identity,
registerPending: true,
failurePolicy: .retry(
retryKey: privateEnvelopeRetryKey(content: embedded, recipientHex: recipientHex)
)
)
case .readGeohash(let messageID, let recipientHex, let identity):
SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
sendPrivateEnvelope(
content: embedded,
recipientHex: recipientHex,
senderIdentity: identity,
registerPending: true,
failurePolicy: .retry(
retryKey: privateEnvelopeRetryKey(content: embedded, recipientHex: recipientHex)
)
)
}
}
}
@@ -401,3 +595,122 @@ extension NostrTransport {
return nil
}
}
/// Bounded retry owner for non-user private control payloads. It is separate
/// from `NostrTransport` so a scheduled retry from a short-lived geohash
/// transport remains valid after that transport deinitializes. Scheduler
/// callbacks retain this queue, never the transport; an evicted key simply
/// becomes a harmless no-op when its already-scheduled callback fires.
@MainActor
final class NostrPrivateEnvelopeRetryQueue {
private struct PendingRetry {
let events: [NostrEvent]
let registerPending: Bool
var attempt: Int
var isScheduled: Bool
}
private let sendPrivateEnvelopeBatch: @MainActor (
[NostrEvent],
@escaping @MainActor () -> Void
) -> Bool
private let registerPendingPrivateEnvelope: @MainActor (String) -> Void
private let scheduleAfter: @Sendable (
TimeInterval,
@escaping @Sendable () -> Void
) -> Void
private var pending: [String: PendingRetry] = [:]
private var insertionOrder: [String] = []
init(
sendPrivateEnvelopeBatch: @escaping @MainActor (
[NostrEvent],
@escaping @MainActor () -> Void
) -> Bool,
registerPendingPrivateEnvelope: @escaping @MainActor (String) -> Void,
scheduleAfter: @escaping @Sendable (
TimeInterval,
@escaping @Sendable () -> Void
) -> Void
) {
self.sendPrivateEnvelopeBatch = sendPrivateEnvelopeBatch
self.registerPendingPrivateEnvelope = registerPendingPrivateEnvelope
self.scheduleAfter = scheduleAfter
}
func enqueue(key: String, events: [NostrEvent], registerPending: Bool) {
guard pending[key] == nil else { return }
if pending.count >= TransportConfig.nostrPrivateEnvelopeRetryQueueCap,
let evictedKey = insertionOrder.first {
insertionOrder.removeFirst()
pending.removeValue(forKey: evictedKey)
// These are control payloads, never user-authored messages. Keep
// the bounded-loss decision explicit rather than silently growing
// memory during a prolonged outage.
SecureLogger.warning(
"📮 Private control retry queue full — evicted oldest whole migration pair",
category: .session
)
}
pending[key] = PendingRetry(
events: events,
registerPending: registerPending,
attempt: 0,
isScheduled: false
)
insertionOrder.append(key)
schedule(key: key)
}
private func schedule(key: String) {
guard var item = pending[key], !item.isScheduled else { return }
item.isScheduled = true
pending[key] = item
let exponent = min(item.attempt, 5)
let delay = min(2.0 * pow(2.0, Double(exponent)), 60.0)
scheduleAfter(delay) { [self] in
Task { @MainActor [self] in
self.retry(key: key)
}
}
}
private func retry(key: String) {
guard var item = pending[key] else { return }
item.isScheduled = false
pending[key] = item
let accepted = sendPrivateEnvelopeBatch(item.events) { [self] in
self.enqueue(
key: key,
events: item.events,
registerPending: item.registerPending
)
}
if accepted {
remove(key: key)
if item.registerPending {
for event in item.events {
registerPendingPrivateEnvelope(event.id)
}
}
} else {
item.attempt += 1
pending[key] = item
schedule(key: key)
}
}
private func remove(key: String) {
pending.removeValue(forKey: key)
insertionOrder.removeAll { $0 == key }
}
func removeAll() {
pending.removeAll()
insertionOrder.removeAll()
}
var debugPendingCount: Int { pending.count }
func debugContains(key: String) -> Bool { pending[key] != nil }
}
+1 -1
View File
@@ -153,7 +153,7 @@ final class NotificationService {
private func registerCategories() {
let wave = UNNotificationAction(
identifier: Self.waveActionID,
title: "wave 👋",
title: String(localized: "notification.action.wave", comment: "Title of the notification action button that sends a friendly wave back to a nearby person"),
options: []
)
let nearby = UNNotificationCategory(
@@ -23,10 +23,11 @@ import Foundation
final class PrekeyBundleStore {
struct StoredBundle: Codable {
// noiseKey is read in loadFromDisk (dictionary keying), but the
// Periphery indexer intermittently misses that read and flakes CI
// with "assign-only" its USR is baselined (an in-source ignore
// can't work: strict mode flags it as superfluous on the runs where
// the indexer gets it right).
// Periphery indexer intermittently misses that read and flaked CI
// with "assign-only" even past its baselined USR. Covered
// deterministically by retain_codable_properties in .periphery.yml
// (an in-source ignore can't work: strict mode flags it as
// superfluous on the runs where the indexer gets it right).
let noiseKey: Data
var generatedAt: UInt64
var prekeyIDs: [UInt32]
+13 -5
View File
@@ -256,8 +256,6 @@ final class PrivateChatManager: ObservableObject {
return
}
sentReadReceipts.insert(message.id)
// Create read receipt using the simplified method
let receipt = ReadReceipt(
originalMessageID: message.id,
@@ -268,11 +266,21 @@ final class PrivateChatManager: ObservableObject {
// Route via MessageRouter to avoid handshakeRequired spam when session isn't established
if let router = messageRouter {
SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.id.prefix(8))… via router", category: .session)
Task { @MainActor in
router.sendReadReceipt(receipt, to: senderPeerID)
let messageID = message.id
// Claim the receipt synchronously so a second read scan in the
// same runloop pass (chat open triggers two) can't route a
// duplicate; release the claim on a failed route (no reachable
// transport) so a later read scan retries instead of permanently
// losing the receipt.
sentReadReceipts.insert(messageID)
Task { @MainActor [weak self] in
if !router.sendReadReceipt(receipt, to: senderPeerID) {
self?.sentReadReceipts.remove(messageID)
}
}
} else {
// Fallback: preserve previous behavior
// Fallback: preserve previous behavior (best-effort mesh send).
sentReadReceipts.insert(message.id)
meshService?.sendReadReceipt(receipt, to: senderPeerID)
}
}
@@ -11,6 +11,7 @@ final class TransferProgressManager {
case updated(id: String, sentFragments: Int, totalFragments: Int)
case completed(id: String, totalFragments: Int)
case cancelled(id: String, sentFragments: Int, totalFragments: Int)
case rejected(id: String, reason: String)
}
private let subject = PassthroughSubject<Event, Never>()
@@ -49,6 +50,17 @@ final class TransferProgressManager {
}
}
/// Fails a preflight check while keeping the outgoing placeholder visible
/// with an actionable reason instead of treating policy/size rejection as
/// a user cancellation.
func rejectBeforeStart(id: String, reason: String) {
queue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
self.states.removeValue(forKey: id)
self.subject.send(.rejected(id: id, reason: reason))
}
}
func snapshot(id: String) -> (sent: Int, total: Int)? {
var result: (sent: Int, total: Int)?
queue.sync {
+104
View File
@@ -83,10 +83,44 @@ enum TransportEvent: @unchecked Sendable {
case bluetoothStateUpdated(CBManagerState)
}
/// Downgrade-safe decision for a private-media recipient. Callers ask before
/// prompting, and BLEService checks again when it consumes any one-shot
/// legacy consent.
enum PrivateMediaSendPolicy: Equatable {
case encrypted
/// A public announce hinted at encrypted media (or a prior authenticated
/// pin exists), but this exact Noise session has not yet supplied its
/// authenticated peer-state proof. Callers wait boundedly; they must not
/// pre-queue encrypted bytes or silently select the legacy path.
case awaitingCapabilityProof
case legacyRequiresConsent
case blockedDowngrade
}
/// Receiver-only persistence surface for explicit private-media deletion.
/// Kept separate from `Transport` so sender retry branches can rebase without
/// inheriting or implementing receiver storage concerns.
protocol PrivateMediaDeletionPersisting: AnyObject {
@MainActor
func persistDeletedPrivateMedia(
messageIDs: [String],
payloadRelativePaths: [String: String],
protectedPayloadRelativePaths: Set<String>,
completion: @escaping @MainActor (Bool) -> Void
)
}
protocol TransportEventDelegate: AnyObject {
@MainActor func didReceiveTransportEvent(_ event: TransportEvent)
}
/// Optional typed-event contract for sinks that can synchronously decide
/// whether an inbound message was accepted.
protocol SynchronousMessageTransportEventDelegate: TransportEventDelegate {
@MainActor
func didReceiveTransportMessageSynchronously(_ message: BitchatMessage) -> Bool
}
protocol Transport: AnyObject {
// Event sink
var delegate: BitchatDelegate? { get set }
@@ -116,6 +150,14 @@ protocol Transport: AnyObject {
/// npub as reachable even with no relay connection, where a send only
/// joins a queue waiting for internet that may never come.
func canDeliverPromptly(to peerID: PeerID) -> Bool
/// Whether a send to this peer can complete an end-to-end encrypted
/// delivery right now (e.g. an established Noise session). Distinct from
/// connectivity: a "connected" link binding alone is forgeable link
/// bindings heal on signature-verified "direct" announces, but directness
/// rides on the unsigned TTL, so a replayed announce can wear an absent
/// peer's ID on the replayer's link. Routers must not trust a connected
/// link outright without this.
func canDeliverSecurely(to peerID: PeerID) -> Bool
func peerNickname(peerID: PeerID) -> String?
func getPeerNicknames() -> [PeerID: String]
@@ -155,6 +197,20 @@ protocol Transport: AnyObject {
func sendDeliveryAck(for messageID: String, to peerID: PeerID)
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
func sendFilePrivate(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String,
allowLegacyFallback: Bool
)
/// Automatic whole-file retry is admitted only while this exact Noise
/// generation authenticates bit 9. It must never queue across a session
/// replacement or enter the signed raw legacy path.
func sendFilePrivateReceiptRetry(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String
)
func cancelTransfer(_ transferId: String)
// Live voice / push-to-talk (mesh transports only): one encoded
@@ -200,6 +256,16 @@ protocol Transport: AnyObject {
/// Capabilities the peer advertised in its last verified announce;
/// empty for peers that predate the capabilities TLV.
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
/// The exact current Noise generation that authenticated both encrypted
/// private media (bit 8) and durable receipts/retry (bit 9).
func authenticatedPrivateMediaReceiptSessionGeneration(
to peerID: PeerID
) -> UUID?
func resolvePrivateMediaSendPolicy(
to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
)
/// Sends an encoded vouch-attestation batch inside the Noise session.
func sendVouchAttestations(_ payload: Data, to peerID: PeerID)
/// Appends a peer-authenticated observer. Unlike
@@ -216,6 +282,9 @@ protocol Transport: AnyObject {
// this device is carrying for gossip sync, decoded for display as
// "heard here earlier" timeline echoes.
func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void)
/// Drops any carried public messages from a (newly blocked) sender so
/// they can't resurface as archived echoes on a later launch.
func purgeArchivedPublicMessages(from peerID: PeerID)
}
/// A carried public mesh message from the store-and-forward window, decoded
@@ -244,6 +313,10 @@ extension Transport {
// straight to the radio; queue-backed transports override this.
func canDeliverPromptly(to peerID: PeerID) -> Bool { isPeerReachable(peerID) }
// Transports without a forgeable link-binding layer (everything but the
// BLE mesh) have no stronger delivery signal than prompt delivery.
func canDeliverSecurely(to peerID: PeerID) -> Bool { canDeliverPromptly(to: peerID) }
// Noise identity hooks default to inert for transports that do not carry
// Noise sessions (e.g. NostrTransport).
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { nil }
@@ -263,6 +336,21 @@ extension Transport {
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {}
func broadcastGroupMessage(_ envelope: Data) {}
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { .blockedDowngrade }
func authenticatedPrivateMediaReceiptSessionGeneration(
to peerID: PeerID
) -> UUID? {
nil
}
func resolvePrivateMediaSendPolicy(
to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
) {
let policy = privateMediaSendPolicy(to: peerID)
Task { @MainActor in
completion(policy == .awaitingCapabilityProof ? .blockedDowngrade : policy)
}
}
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {}
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {}
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
@@ -279,6 +367,20 @@ extension Transport {
func currentMeshTopology() -> MeshTopologySnapshot? { nil }
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
func sendFilePrivate(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String,
allowLegacyFallback: Bool
) {
guard !allowLegacyFallback else { return }
sendFilePrivate(packet, to: peerID, transferId: transferId)
}
func sendFilePrivateReceiptRetry(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String
) {}
func cancelTransfer(_ transferId: String) {}
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
@@ -291,6 +393,8 @@ extension Transport {
func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) {
Task { @MainActor in completion([]) }
}
func purgeArchivedPublicMessages(from peerID: PeerID) {}
}
protocol TransportPeerEventsDelegate: AnyObject {
+57 -2
View File
@@ -9,6 +9,19 @@ enum TransportConfig {
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends
// Bounded wait for the session-authenticated capability proof used by
// private-media migration. Expiry never auto-sends clear bytes; it only
// resolves to the existing one-shot consent or downgrade-blocked path.
static let privateMediaCapabilityProofTimeoutSeconds: TimeInterval = 5
static let privateMediaCapabilityProofPendingPeerCap: Int = 64
static let privateMediaCapabilityProofWaitersPerPeerCap: Int = 16
/// Accepted private-media receipts and explicit-deletion tombstones each
/// receive this independent capacity.
static let privateMediaReceivedLedgerCapacity: Int = 4_096
/// A bounded retry horizon prevents stable receipt state from growing into
/// permanent application history.
static let privateMediaReceivedLedgerTTLSeconds: TimeInterval =
7 * 24 * 60 * 60
static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
// Fragment relay TTL in sparse graphs; matches messageTTLDefault so media
@@ -46,6 +59,7 @@ enum TransportConfig {
static let privateChatCap: Int = 1337
static let meshTimelineCap: Int = 1337
static let geoTimelineCap: Int = 1337
static let geoNicknameParticipantsCap: Int = 1337
static let contentLRUCap: Int = 2000
static let geoSamplingEventLRUCap: Int = 2000
@@ -81,6 +95,11 @@ enum TransportConfig {
static let nostrDuplicateEventLogInterval: Int = 50
// Sample interval for per-event debug logs on the inbound hot path.
static let nostrInboundEventLogInterval: Int = 100
// Reject oversized/untrusted relay frames before JSON parse / store.
static let nostrMaxInboundMessageBytes: Int = 256 * 1024
static let nostrMaxEventTags: Int = 64
static let nostrMaxEventTagValues: Int = 16
static let nostrMaxEventTagValueBytes: Int = 1024
// Conversation store diagnostics (field observability)
// Sample interval for the periodic store-audit "OK" heartbeat line
@@ -98,6 +117,12 @@ enum TransportConfig {
static let uiSenderRateBucketRefillPerSec: Double = 1.0
static let uiContentRateBucketCapacity: Double = 3
static let uiContentRateBucketRefillPerSec: Double = 0.5
// Bound attacker-keyed bucket maps (sender IDs / content digests).
static let uiSenderRateBucketMaxEntries: Int = 2000
static let uiContentRateBucketMaxEntries: Int = 2000
static let uiRateBucketIdleTTL: TimeInterval = 10 * 60
// Cap teleported-participant markers so remote events cannot grow the set.
static let geoTeleportedParticipantsCap: Int = 1337
// UI sleeps/delays
static let uiStartupInitialDelaySeconds: TimeInterval = 1.0
@@ -171,7 +196,22 @@ enum TransportConfig {
static let nostrGeoRelayCount: Int = 5
static let nostrGeohashSampleLookbackSeconds: TimeInterval = 300
static let nostrGeohashSampleLimit: Int = 100
static let nostrDMSubscribeLookbackSeconds: TimeInterval = 86400
/// New iOS public-envelope timestamps are deliberately shifted into the
/// past for privacy and relay compatibility.
static let nostrPrivateEnvelopeTimestampFuzzSeconds: TimeInterval = 15 * 60
/// Deployed Android clients can shift legacy kind-1059 timestamps by the
/// full preceding 48 hours.
static let nostrLegacyAndroidTimestampFuzzSeconds: TimeInterval = 48 * 60 * 60
/// Private mail remains eligible for delivery for the same 24-hour window
/// as the persistent sender outbox. The relay query must add timestamp
/// randomization to that window rather than replacing it: an Android event
/// sent at t0 can legitimately be stamped t0-48h and fetched at t0+24h.
static let nostrPrivateEnvelopeDeliveryWindowSeconds: TimeInterval = 24 * 60 * 60
static let nostrDMSubscribeClockSkewSeconds: TimeInterval = 15 * 60
static let nostrDMSubscribeLookbackSeconds: TimeInterval =
nostrPrivateEnvelopeDeliveryWindowSeconds
+ nostrLegacyAndroidTimestampFuzzSeconds
+ nostrDMSubscribeClockSkewSeconds
// A sampled chat message this recent means "a conversation is happening
// there" for the empty-timeline nearby-activity hint.
static let uiGeohashChatActivityWindowSeconds: TimeInterval = 900
@@ -199,11 +239,20 @@ enum TransportConfig {
// Reconnect delays get ±20% random jitter so relays that dropped together
// (e.g. a network blip) don't thundering-herd the same reconnect instant.
static let nostrRelayBackoffJitterRatio: Double = 0.2
static let nostrRelayDefaultFetchLimit: Int = 100
/// Migration recovery uses one independent relay filter per wire kind so
/// primary traffic cannot consume the legacy result budget (or vice
/// versa). Five hundred per kind bounds startup work while preserving a
/// materially deeper offline mailbox than the generic feed default.
static let nostrPrivateEnvelopeFetchLimitPerKind: Int = 500
// How many consecutive Tor-readiness waits (each bounded by TorManager's
// bootstrap deadline) to attempt before unblocking pending EOSE callers.
static let nostrTorReadyMaxWaitAttempts: Int = 3
static let nostrPendingSendQueueCap: Int = 200
/// Control-payload pairs (delivery/read acknowledgements and favorite
/// notifications) that could not enter the relay queue. User messages do
/// not use this queue: they fail visibly, while direct messages also
/// remain in the router outbox.
static let nostrPrivateEnvelopeRetryQueueCap: Int = 256
// Sample interval for the send-queue overflow warning (first + every Nth
// dropped event). Drops are ephemeral presence/geo traffic log-only.
static let nostrPendingSendDropLogInterval: Int = 10
@@ -215,6 +264,9 @@ enum TransportConfig {
// Fallback deadline for treating a subscription's initial fetch as complete
// when a relay never sends EOSE (generous to cover Tor circuit setup).
static let nostrSubscriptionEOSEFallbackSeconds: TimeInterval = 10.0
// A bridge drop is durable only after NIP-01 `OK`. Relays that omit `OK` must
// not pin the router's in-flight state indefinitely.
static let nostrConfirmedSendAckTimeoutSeconds: TimeInterval = 10.0
// After this long, a relay marked permanently failed gets another chance.
static let nostrRelayFailureCooldownSeconds: TimeInterval = 600.0
@@ -345,6 +397,9 @@ enum TransportConfig {
// Cooldown between speculative multi-hop handovers of the same envelope
// toward a recipient heard only via relayed announces.
static let courierRemoteHandoverCooldownSeconds: TimeInterval = 10 * 60
// Recently opened courier inner message IDs kept for receiver-side dedup
// (redundant copies ride distinct seals, so only the inner ID matches).
static let courierOpenedMessageIDCap: Int = 512
// One-time prekey bundles (forward-secret courier sealing)
// Own gossip-sync round for bundles: modest cadence, bounded peer count,
@@ -275,6 +275,12 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
return nil
}
identityManager.setBlocked(fingerprint, isBlocked: blocked)
if blocked {
// Purge while the fingerprintpeerID mapping is still known: the
// archived-echo seed filter can't resolve offline strangers, so
// scrub their carried messages now rather than at relaunch.
meshService.purgeArchivedPublicMessages(from: peerID)
}
updatePeers()
return fingerprint
}
+17
View File
@@ -716,6 +716,23 @@ final class GossipSyncManager {
}
}
/// Block-time hygiene: drop the carried public messages from a blocked
/// sender and persist immediately, so nothing of theirs can resurface as
/// an archived echo on the next launch. Narrower than `removeState(for:)`
/// the peer's announcement and in-flight fragments are untouched.
func removePublicMessages(from peerID: PeerID) {
queue.async { [weak self] in
guard let self else { return }
let countBefore = self.messages.packets.count
self.messages.remove { PeerID(hexData: $0.senderID) == peerID }
guard self.messages.packets.count != countBefore else { return }
self.archiveDirty = true
// Persist now rather than waiting for maintenance: a relaunch in
// the gap would restore the purged messages from disk.
self.persistArchiveIfDirty()
}
}
private func removeState(for peerID: PeerID) {
// Deliberately keeps the peer's prekey bundle: bundles exist to reach
// owners who left the mesh, and they age out on their own schedule.
+40
View File
@@ -0,0 +1,40 @@
import Foundation
/// In-app override for the UI language, on top of the system per-app
/// language. Apple resolves localization from the AppleLanguages default at
/// process start, so a new choice takes effect on the next launch callers
/// surface a "restart to apply" note after changing it.
enum AppLanguageSettings {
/// "" means no override: follow the device (or per-app system) language.
static let overrideKey = "app.languageOverride"
private static let appleLanguagesKey = "AppleLanguages"
/// Language codes the app ships translations for, straight from the
/// built bundle so this never drifts from the string catalog.
static var availableLanguages: [String] {
Bundle.main.localizations
.filter { $0 != "Base" }
.sorted { endonym(for: $0).localizedCaseInsensitiveCompare(endonym(for: $1)) == .orderedAscending }
}
/// The language's name in that language ("فارسی", "") so every user
/// can find their own entry regardless of the current UI language.
static func endonym(for code: String) -> String {
let locale = Locale(identifier: code)
let name = locale.localizedString(forIdentifier: code) ?? code
return name.lowercased(with: locale)
}
/// Persists the override (nil clears it). AppleLanguages drives the
/// actual localization lookup on next launch.
static func setOverride(_ code: String?) {
let defaults = UserDefaults.standard
if let code, !code.isEmpty {
defaults.set(code, forKey: overrideKey)
defaults.set([code], forKey: appleLanguagesKey)
} else {
defaults.removeObject(forKey: overrideKey)
defaults.removeObject(forKey: appleLanguagesKey)
}
}
}
@@ -55,6 +55,9 @@ extension ChatViewModel: ChatDeliveryContext {
func markMessageDelivered(_ messageID: String) {
messageRouter.markDelivered(messageID)
mediaTransferCoordinator.confirmPrivateMediaDelivery(
messageID: messageID
)
}
}
@@ -53,7 +53,8 @@ protocol ChatLifecycleContext: AnyObject {
// MARK: Routing & receipts
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
@discardableResult
func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool
func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date)
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
@@ -248,8 +249,12 @@ final class ChatLifecycleCoordinator {
? peerID
: (context.unifiedPeer(for: peerID)?.peerID ?? peerID)
context.routeReadReceipt(receipt, to: recipientPeerID)
context.markReadReceiptSent(message.id)
// Only record the receipt as sent when it actually left via a
// reachable transport; a dropped receipt stays unmarked so the
// next read scan retries it instead of burning it forever.
if context.routeReadReceipt(receipt, to: recipientPeerID) {
context.markReadReceiptSent(message.id)
}
}
}
+223 -53
View File
@@ -10,6 +10,7 @@ import Foundation
@MainActor
protocol ChatLiveVoiceContext: AnyObject {
var nickname: String { get }
var myPeerID: PeerID { get }
var selectedPrivateChatPeer: PeerID? { get }
/// Whether the public mesh timeline is what's on screen (autoplay gate
/// for public bursts).
@@ -30,6 +31,12 @@ protocol ChatLiveVoiceContext: AnyObject {
func upsertPublicMeshMessage(_ message: BitchatMessage)
@discardableResult
func removePrivateMessage(withID messageID: String) -> BitchatMessage?
/// Records and sends the finalized note's read receipt after a live
/// bubble adopts its wire-derivable message ID.
func hasSentReadReceipt(_ messageID: String) -> Bool
@discardableResult
func markReadReceiptSent(_ messageID: String) -> Bool
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
/// Removes a message from whichever conversation holds it.
func removeMessage(withID messageID: String, cleanupFile: Bool)
/// Publishes who is currently talking live in the public mesh channel
@@ -59,7 +66,7 @@ extension ChatViewModel: ChatLiveVoiceContext {
}
/// Where a live voice burst lives: a Noise DM or the public mesh timeline.
enum VoiceBurstScope: Equatable {
enum VoiceBurstScope: Hashable {
case directMessage
case publicMesh
}
@@ -72,6 +79,16 @@ enum VoiceBurstScope: Equatable {
/// bubble so nobody sees a duplicate.
@MainActor
final class ChatLiveVoiceCoordinator {
/// Burst IDs are sender-chosen, so they only identify a burst *within*
/// an authenticated (peer, scope) pair: keying assemblies by the full
/// triple stops an attacker who observed a public burst ID from racing
/// a START to capture the real talker's frames.
private struct AssemblyKey: Hashable {
let peerID: PeerID
let scope: VoiceBurstScope
let burstID: Data
}
private final class Assembly {
let burstID: Data
let peerID: PeerID
@@ -97,6 +114,8 @@ final class ChatLiveVoiceCoordinator {
var idleTimeout: Task<Void, Never>?
var gapRedrain: Task<Void, Never>?
var key: AssemblyKey { AssemblyKey(peerID: peerID, scope: scope, burstID: burstID) }
init(burstID: Data, peerID: PeerID, scope: VoiceBurstScope, nickname: String, message: BitchatMessage, fileURL: URL, fileHandle: FileHandle) {
self.burstID = burstID
self.peerID = peerID
@@ -123,11 +142,32 @@ final class ChatLiveVoiceCoordinator {
private static let finishedBurstsCap = 32
private unowned let context: any ChatLiveVoiceContext
private var assemblies: [Data: Assembly] = [:]
private var finishedBursts: [Data: FinishedBurst] = [:]
private let fileStore: BLEIncomingFileStore
/// Captures live in the store's directories, so file operations go
/// through the store's (injectable) file manager.
private var fileManager: FileManager { fileStore.fileManager }
private var assemblies: [AssemblyKey: Assembly] = [:]
private var finishedBursts: [AssemblyKey: FinishedBurst] = [:]
/// Players still draining after their assembly the sole strong owner
/// was discarded on burst END. Without this hold the player deallocates
/// mid-tail (or mid session-acquire, killing the whole burst's audio)
/// and its registered session token would only be reclaimed by the
/// deinit backstop. Entries remove themselves via `onStopped`.
private var drainingPlayers: [ObjectIdentifier: PTTBurstPlayer] = [:]
init(context: any ChatLiveVoiceContext) {
/// `sweepsOnInit` exists for tests whose coordinator shares the real
/// application-support directory: they pass `false` so parallel test
/// runs never sweep each other's in-flight capture files.
init(context: any ChatLiveVoiceContext, fileStore: BLEIncomingFileStore = BLEIncomingFileStore(), sweepsOnInit: Bool = true) {
self.context = context
self.fileStore = fileStore
// Orphaned partial captures from a previous session (live-only bursts
// whose finalized note never arrived) are dead weight the quota can
// never reclaim (eviction skips voice_live_* by design) sweep them
// on startup.
if sweepsOnInit {
sweepStaleLiveCaptures()
}
}
// MARK: - Inbound frames
@@ -160,11 +200,12 @@ final class ChatLiveVoiceCoordinator {
return
}
if let assembly = assemblies[packet.burstID] {
// The sender is authenticated (Noise session or packet
// signature); a different peer or scope reusing the same burst
// ID is a collision or a replay drop it.
guard assembly.peerID == peerID, assembly.scope == scope else { return }
// The sender is authenticated (Noise session or packet signature),
// and the key binds the burst ID to that (peer, scope): a colliding
// START from another peer opens its own assembly instead of
// capturing this one's frames.
let key = AssemblyKey(peerID: peerID, scope: scope, burstID: packet.burstID)
if let assembly = assemblies[key] {
apply(packet, to: assembly)
return
}
@@ -178,7 +219,7 @@ final class ChatLiveVoiceCoordinator {
return
}
guard let assembly = makeAssembly(burstID: packet.burstID, peerID: peerID, scope: scope, nickname: nickname, timestamp: timestamp) else { return }
assemblies[packet.burstID] = assembly
assemblies[key] = assembly
updatePublicTalkerIndicator()
apply(packet, to: assembly)
case .end, .canceled:
@@ -192,6 +233,21 @@ final class ChatLiveVoiceCoordinator {
assemblies.values.contains { $0.messageID == message.id }
}
/// Stop every live file handle/player before the panic media directory is
/// removed. This prevents an in-flight assembly from continuing to write
/// through an unlinked file after the wipe returns.
func resetForPanic() {
for assembly in Array(assemblies.values) {
cancelAssembly(assembly)
}
for player in drainingPlayers.values {
player.stop()
}
drainingPlayers.removeAll(keepingCapacity: false)
finishedBursts.removeAll(keepingCapacity: false)
updatePublicTalkerIndicator()
}
/// Called for every inbound private message: when it is the finalized
/// voice note of a burst we assembled (matched by burst ID in the file
/// name), swap it into the existing live bubble and report `true` so the
@@ -203,20 +259,36 @@ final class ChatLiveVoiceCoordinator {
let burstID = Self.burstID(fromVoiceFileName: String(message.content.dropFirst(prefix.count)))
else { return false }
// Bind the note to the burst's authenticated sender and scope: an
// attacker's burst reusing the same ID lives under its own key and
// never matches the real sender's note (registry is capped at
// `finishedBurstsCap`, so the linear scan is cheap).
func matches(_ key: AssemblyKey) -> Bool {
key.burstID == burstID
&& (message.senderPeerID == nil || key.peerID == message.senderPeerID)
&& message.isPrivate == (key.scope == .directMessage)
}
// The note usually lands after END, but a lost END or a fast transfer
// can beat it close out the live assembly first.
if let assembly = assemblies[burstID] {
if let assembly = assemblies.first(where: { matches($0.key) })?.value {
finalize(assembly)
}
pruneFinishedBursts()
guard let finished = finishedBursts[burstID] else { return false }
// Bind the note to the burst's authenticated sender and scope.
guard message.senderPeerID == nil || message.senderPeerID == finished.peerID else { return false }
guard message.isPrivate == (finished.scope == .directMessage) else { return false }
guard let entry = finishedBursts.first(where: { matches($0.key) }) else { return false }
let finished = entry.value
// A DM live bubble starts before the finalized file exists and
// therefore has a receiver-local random ID. Adopt the finalized
// message's deterministic ID so delivery/read ACKs address the same
// row as the sender's media placeholder. Public notes retain their
// live-bubble ID because public transfers have no private receipts.
let replacementID = finished.scope == .directMessage
? message.id
: finished.messageID
let replacement = BitchatMessage(
id: finished.messageID,
id: replacementID,
sender: message.sender,
content: message.content,
timestamp: finished.messageTimestamp,
@@ -230,15 +302,39 @@ final class ChatLiveVoiceCoordinator {
)
switch finished.scope {
case .directMessage:
// Capture read state before rekeying. The user may have read the
// live bubble and navigated away before the finalized .m4a lands.
let shouldSendAdoptedReadReceipt =
context.hasSentReadReceipt(finished.messageID)
|| context.selectedPrivateChatPeer == finished.peerID
// Insert first so replacing the only row in a DM never
// transiently deletes its conversation, unread state, or current
// selection. Then remove the receiver-local live-bubble alias.
context.upsertPrivateMessage(replacement, in: finished.peerID)
if replacementID != finished.messageID {
context.removePrivateMessage(withID: finished.messageID)
}
// The live bubble may already have emitted a receiver-local READ
// before the sender created its finalized media row. Re-emit once
// for the adopted stable ID now that the file has arrived.
if shouldSendAdoptedReadReceipt,
context.markReadReceiptSent(replacementID) {
let receipt = ReadReceipt(
originalMessageID: replacementID,
readerID: context.myPeerID,
readerNickname: context.nickname
)
context.sendMeshReadReceipt(receipt, to: finished.peerID)
}
case .publicMesh:
context.upsertPublicMeshMessage(replacement)
}
// The complete .m4a replaces the partial live capture.
WaveformCache.shared.purge(url: finished.fileURL)
try? FileManager.default.removeItem(at: finished.fileURL)
finishedBursts.removeValue(forKey: burstID)
try? fileManager.removeItem(at: finished.fileURL)
finishedBursts.removeValue(forKey: entry.key)
context.notifyUIChanged()
SecureLogger.debug("PTT: absorbed finalized note for burst \(burstID.hexEncodedString())", category: .session)
@@ -248,14 +344,19 @@ final class ChatLiveVoiceCoordinator {
// MARK: - Assembly lifecycle
private func makeAssembly(burstID: Data, peerID: PeerID, scope: VoiceBurstScope, nickname: String, timestamp: Date) -> Assembly? {
guard let fileURL = Self.makeIncomingURL(burstID: burstID) else {
guard let fileURL = makeIncomingURL(burstID: burstID, peerID: peerID, scope: scope) else {
SecureLogger.error("PTT: cannot resolve incoming media directory for burst \(burstID.hexEncodedString())", category: .session)
return nil
}
FileManager.default.createFile(atPath: fileURL.path, contents: nil)
// BCH-01-002: live captures share the incoming-media quota with
// finalized transfers; reserve the burst's worst case up front.
// Eviction skips voice_live_* names, so partials still streaming in
// are safe no matter which caller triggers enforcement.
fileStore.enforceQuota(reservingBytes: TransportConfig.pttMaxBurstBytes)
fileManager.createFile(atPath: fileURL.path, contents: nil)
guard let handle = try? FileHandle(forWritingTo: fileURL) else {
SecureLogger.error("PTT: cannot open capture file for burst \(burstID.hexEncodedString())", category: .session)
try? FileManager.default.removeItem(at: fileURL)
try? fileManager.removeItem(at: fileURL)
return nil
}
@@ -413,30 +514,47 @@ final class ChatLiveVoiceCoordinator {
}
try? assembly.fileHandle?.close()
assembly.fileHandle = nil
assemblies.removeValue(forKey: assembly.burstID)
assemblies.removeValue(forKey: assembly.key)
updatePublicTalkerIndicator()
guard assembly.deliveredFrames > 0 else {
// Nothing audible ever arrived drop the empty bubble.
removeBubble(of: assembly)
try? FileManager.default.removeItem(at: assembly.fileURL)
try? fileManager.removeItem(at: assembly.fileURL)
context.notifyUIChanged()
return
}
assembly.player?.finishAfterDrain()
if let player = assembly.player, !player.stopped {
// Park the draining player: this method just dropped the
// assembly, and nothing else holds the player strongly. It may
// still be playing out its tail or still acquiring the audio
// session off-main so it must stay alive until it stops.
let id = ObjectIdentifier(player)
drainingPlayers[id] = player
player.onStopped = { [weak self] in
self?.drainingPlayers.removeValue(forKey: id)
}
player.finishAfterDrain()
}
// The bubble's waveform may have been computed from a partial file.
WaveformCache.shared.purge(url: assembly.fileURL)
// Republish so the row re-renders without its LIVE treatment even if
// no finalized note ever arrives to swap in.
republishBubble(of: assembly)
// The capture is the bubble's replayable audio from here on (unless a
// finalized note arrives to swap in): move it off its voice_live_
// name so only genuinely in-flight files match the startup sweep and
// the quota's live-capture guard a kept fallback is never swept,
// and it ages out of the quota like any finalized media.
let fileURL = promoteToFallback(assembly.fileURL)
// Republish so the row re-renders without its LIVE treatment and
// points at the promoted file even if no note ever arrives.
republishBubble(of: assembly, fileURL: fileURL)
pruneFinishedBursts()
finishedBursts[assembly.burstID] = FinishedBurst(
finishedBursts[assembly.key] = FinishedBurst(
messageID: assembly.messageID,
peerID: assembly.peerID,
scope: assembly.scope,
fileURL: assembly.fileURL,
fileURL: fileURL,
messageTimestamp: assembly.messageTimestamp,
expiresAt: Date().addingTimeInterval(TransportConfig.pttFinishedBurstRegistrySeconds)
)
@@ -453,12 +571,48 @@ final class ChatLiveVoiceCoordinator {
}
}
private func republishBubble(of assembly: Assembly) {
private func republishBubble(of assembly: Assembly, fileURL: URL) {
// Same row (same ID), content re-pointed at `fileURL`; the delivery
// status is carried over because the inbound pipeline may have
// updated it on the shared original.
let message = BitchatMessage(
id: assembly.messageID,
sender: assembly.nickname,
content: "\(MimeType.Category.audio.messagePrefix)\(fileURL.lastPathComponent)",
timestamp: assembly.messageTimestamp,
isRelay: false,
isPrivate: assembly.scope == .directMessage,
recipientNickname: assembly.scope == .directMessage ? context.nickname : nil,
senderPeerID: assembly.peerID,
deliveryStatus: assembly.message.deliveryStatus
)
switch assembly.scope {
case .directMessage:
context.upsertPrivateMessage(assembly.message, in: assembly.peerID)
context.upsertPrivateMessage(message, in: assembly.peerID)
case .publicMesh:
context.upsertPublicMeshMessage(assembly.message)
context.upsertPublicMeshMessage(message)
}
}
/// Moves a finished capture off its `voice_live_` prefix (onto plain
/// `voice_`), so the in-flight patterns the startup sweep and the
/// quota's eviction guard only ever match captures still streaming in.
/// On failure the live name is kept: worst case the file is reclaimed at
/// the next startup, exactly the pre-promotion behavior.
private func promoteToFallback(_ liveURL: URL) -> URL {
let liveName = liveURL.lastPathComponent
guard liveName.hasPrefix(BLEIncomingFileStore.liveCapturePrefix) else { return liveURL }
let fallbackName = "voice_" + liveName.dropFirst(BLEIncomingFileStore.liveCapturePrefix.count)
let destination = liveURL.deletingLastPathComponent().appendingPathComponent(fallbackName)
do {
// A leftover from an earlier burst that reused the same
// (peer, scope, burstID) triple would block the move.
try? fileManager.removeItem(at: destination)
try fileManager.moveItem(at: liveURL, to: destination)
return destination
} catch {
SecureLogger.warning("PTT: keeping live-capture name for finished burst — promotion failed: \(error)", category: .session)
return liveURL
}
}
@@ -468,11 +622,11 @@ final class ChatLiveVoiceCoordinator {
assembly.player?.stop()
try? assembly.fileHandle?.close()
assembly.fileHandle = nil
assemblies.removeValue(forKey: assembly.burstID)
assemblies.removeValue(forKey: assembly.key)
updatePublicTalkerIndicator()
removeBubble(of: assembly)
WaveformCache.shared.purge(url: assembly.fileURL)
try? FileManager.default.removeItem(at: assembly.fileURL)
try? fileManager.removeItem(at: assembly.fileURL)
context.notifyUIChanged()
}
@@ -480,10 +634,10 @@ final class ChatLiveVoiceCoordinator {
private func rescheduleIdleTimeout(for assembly: Assembly) {
assembly.idleTimeout?.cancel()
let burstID = assembly.burstID
let key = assembly.key
assembly.idleTimeout = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: UInt64(TransportConfig.pttBurstEndTimeoutSeconds * 1_000_000_000))
guard !Task.isCancelled, let self, let assembly = self.assemblies[burstID] else { return }
guard !Task.isCancelled, let self, let assembly = self.assemblies[key] else { return }
// Talker went silent/out of range without an END.
self.finalize(assembly)
}
@@ -491,10 +645,10 @@ final class ChatLiveVoiceCoordinator {
private func scheduleGapRedrain(for assembly: Assembly) {
assembly.gapRedrain?.cancel()
let burstID = assembly.burstID
let key = assembly.key
assembly.gapRedrain = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: UInt64((Self.gapSkipSeconds + 0.05) * 1_000_000_000))
guard !Task.isCancelled, let self, let assembly = self.assemblies[burstID] else { return }
guard !Task.isCancelled, let self, let assembly = self.assemblies[key] else { return }
self.drainInOrder(assembly)
self.finalizeIfComplete(assembly)
}
@@ -521,21 +675,37 @@ final class ChatLiveVoiceCoordinator {
return Data(hexString: hex)
}
private static func makeIncomingURL(burstID: Data) -> URL? {
guard let base = try? FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
) else { return nil }
let directory = base
.appendingPathComponent("files", isDirectory: true)
.appendingPathComponent("\(MimeType.Category.audio.mediaDir)/incoming", isDirectory: true)
do {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
} catch {
return nil
private static let incomingSubdirectory = "\(MimeType.Category.audio.mediaDir)/incoming"
/// The peer ID and scope in the name mirror the assembly key: colliding
/// burst IDs from different senders or from the same sender across DM
/// and public land on distinct files instead of truncating each other.
/// `burstID(fromVoiceFileName:)` still rejects every `voice_live_*` name,
/// so live captures can never absorb a note.
private func makeIncomingURL(burstID: Data, peerID: PeerID, scope: VoiceBurstScope) -> URL? {
guard let directory = try? fileStore.incomingDirectory(subdirectory: Self.incomingSubdirectory) else { return nil }
let scopeTag = scope == .directMessage ? "dm" : "mesh"
return directory.appendingPathComponent("\(BLEIncomingFileStore.liveCapturePrefix)\(burstID.hexEncodedString())_\(peerID.id)_\(scopeTag).aac")
}
/// Deletes partial live captures left behind by a previous session.
/// In-session cleanup needs no sweep: absorb, cancel, and empty-finalize
/// delete their capture file, and finalize promotes keepers off the
/// `voice_live_` name. So anything the sweep matches was orphaned by a
/// crash mid-burst safe to delete, because chat rows are in-memory
/// only (ConversationStore never persists; the gossip archive replays
/// `MessageType.message` packets only), so no row from a previous
/// process can reference the file.
private func sweepStaleLiveCaptures() {
guard let directory = try? fileStore.incomingDirectory(subdirectory: Self.incomingSubdirectory),
let contents = try? fileManager.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles]
)
else { return }
for url in contents where url.lastPathComponent.hasPrefix(BLEIncomingFileStore.liveCapturePrefix) && url.pathExtension == "aac" {
try? fileManager.removeItem(at: url)
}
return directory.appendingPathComponent("voice_live_\(burstID.hexEncodedString()).aac")
}
}
File diff suppressed because it is too large Load Diff
@@ -71,12 +71,8 @@ final class ChatMessageFormatter {
let content = message.content
let nsContent = content as NSString
let nsLen = nsContent.length
let containsCashuEarly: Bool = {
let regex = Patterns.quickCashuPresence
return regex.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) > 0
}()
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly {
if content.isOversizedForRichFormatting() {
var plainStyle = AttributeContainer()
plainStyle.foregroundColor = baseColor
plainStyle.font = isSelf
@@ -44,7 +44,10 @@ protocol ChatOutgoingContext: AnyObject {
func sendGeohash(context: ChatViewModel.GeoOutgoingContext)
/// Ships the bridged (rendezvous) copy of a just-sent public mesh
/// message; no-op when the bridge is off or the send is nearby-only.
func bridgeOutgoingPublicMessage(_ content: String, messageID: String)
/// Takes the origin coordinates (sender + wire timestamp) the bridge
/// derives the cross-device-stable mesh message ID from them, not from
/// our local timeline UUID (which no other device can recompute).
func bridgeOutgoingPublicMessage(_ content: String, senderPeerID: PeerID, timestamp: Date)
// MARK: Geohash identity (shared with the other contexts)
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
@@ -66,8 +69,8 @@ extension ChatViewModel: ChatOutgoingContext {
lastPublicActivityAt[key] = Date()
}
func bridgeOutgoingPublicMessage(_ content: String, messageID: String) {
BridgeService.shared.bridgeOutgoing(content: content, messageID: messageID)
func bridgeOutgoingPublicMessage(_ content: String, senderPeerID: PeerID, timestamp: Date) {
BridgeService.shared.bridgeOutgoing(content: content, senderPeerID: senderPeerID, timestamp: timestamp)
}
}
@@ -147,7 +150,7 @@ private extension ChatOutgoingCoordinator {
messageID: message.id,
timestamp: message.timestamp
)
context.bridgeOutgoingPublicMessage(trimmed, messageID: message.id)
context.bridgeOutgoingPublicMessage(trimmed, senderPeerID: context.myPeerID, timestamp: message.timestamp)
}
/// Geohash sends mine a NIP-13 nonce tag first (off the main actor, see

Some files were not shown because too many files have changed in this diff Show More