Compare commits

...
Author SHA1 Message Date
jack 726906681f Make Noise generation race test deterministic 2026-07-12 12:08:06 -04:00
jack 34961b9ad6 Avoid authentication callback queue starvation 2026-07-12 11:57:13 -04:00
jack 0288404e26 Bind capability state to Noise generations 2026-07-12 11:42:42 -04:00
jack 6063d42500 Authenticate private media capabilities in Noise 2026-07-12 11:19:09 -04:00
jack add586eb2c Merge Noise peer binding dependency 2026-07-12 10:15:01 -04:00
jack 8c6b60c675 Harden private media migration compatibility 2026-07-12 10:09:33 -04:00
jack 7faa9d7d4a Encrypt private media before fragmentation 2026-07-10 21:00:54 +02:00
jack a7551a3046 Bind Noise sessions to claimed peer identities 2026-07-10 20:51:50 +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
184 changed files with 18330 additions and 1603 deletions
+20 -5
View File
@@ -131,10 +131,10 @@ jobs:
echo "No coverage data found; skipping summary." echo "No coverage data found; skipping summary."
fi fi
# SPM tests above only compile the macOS slice; this job covers the # SPM tests do not link the shipping app targets. This job covers the
# iOS-conditional code paths (UIKit, CoreBluetooth restoration, etc.). # iOS-conditional paths and both universal Release link configurations.
ios-build: ios-build:
name: Build iOS app (simulator) name: Build Release apps (universal)
runs-on: macos-latest runs-on: macos-latest
timeout-minutes: 15 timeout-minutes: 15
@@ -143,14 +143,29 @@ jobs:
uses: actions/checkout@v5 uses: actions/checkout@v5
- name: Build iOS (simulator, no signing) - 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: | run: |
set -o pipefail set -o pipefail
xcodebuild -project bitchat.xcodeproj \ xcodebuild -project bitchat.xcodeproj \
-scheme "bitchat (iOS)" \ -scheme "bitchat (iOS)" \
-configuration Release \
-sdk iphonesimulator \ -sdk iphonesimulator \
-destination 'generic/platform=iOS Simulator' \ -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 \ CODE_SIGNING_ALLOWED=NO \
build 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: schemes:
- bitchat (macOS) - bitchat (macOS)
retain_swift_ui_previews: true 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 relative_results: true
baseline: .periphery.baseline.json baseline: .periphery.baseline.json
+1
View File
@@ -2,6 +2,7 @@
# (CI checkouts are fresh, so this only matters in a working tree). # (CI checkouts are fresh, so this only matters in a working tree).
excluded: excluded:
- .build - .build
- .claude
- .swiftpm - .swiftpm
- .DerivedData - .DerivedData
- DerivedData - DerivedData
+1 -1
View File
@@ -1,4 +1,4 @@
MARKETING_VERSION = 1.7.0 MARKETING_VERSION = 1.7.1
CURRENT_PROJECT_VERSION = 1 CURRENT_PROJECT_VERSION = 1
IPHONEOS_DEPLOYMENT_TARGET = 16.0 IPHONEOS_DEPLOYMENT_TARGET = 16.0
+107 -117
View File
@@ -1,165 +1,155 @@
# bitchat Privacy Policy # bitchat Privacy Policy
*Last updated: June 2026* *Last updated: July 2026*
## Our Commitment ## 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 ## Summary
- **No personal data collection** - We don't collect names, emails, or phone numbers - **No project-operated accounts or messaging servers** — Bluetooth mesh is peer-to-peer; optional internet features use public or user-selected Nostr relays.
- **No accounts or company servers** - Mesh chat works peer-to-peer; optional Nostr features use public or user-selected relays - **No analytics, advertising, telemetry, or tracking** — the app does not contain an analytics or advertising SDK.
- **No tracking** - We have no analytics, telemetry, or user tracking - **No sale of data** — the project does not sell user data or build advertising profiles.
- **Open source** - You can verify these claims by reading our code - **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. Public keys are shared when required for messaging, verification, groups, or Nostr events.
- Keys remain until they are rotated, removed by the relevant feature, erased with panic wipe, or removed with the app.
1. **Identity Keys** 2. **Nickname, preferences, and relationships**
- Cryptographic private keys generated on first launch or when optional Nostr identities are created - Your nickname, settings, favorites, petnames, read-receipt identifiers, and bounded operational metadata are stored locally.
- Stored locally in your device's secure storage - The share extension briefly places content you choose to share in the app-group preferences so the main app can import it.
- 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** 3. **Private group state**
- The display name you choose (or auto-generated) - Group names, rosters, creator identity, and key epoch are stored as protected files in Application Support.
- Stored only on your device - 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.
- Shared with peers you communicate with
3. **Message History** (if enabled) 4. **Queued and carried private messages**
- When room owners enable retention, messages are saved locally - 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.
- Stored encrypted on your device - 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.
- You can delete this at any time - A panic wipe deletes both stores.
4. **Favorite Peers** 5. **Recent public mesh messages and notices**
- Public keys of peers you mark as favorites - 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.
- Stored only on your device - 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.
- Allows you to recognize these peers in future sessions - These items are public to the mesh or board where they are posted; they are not confidential messages.
5. **Optional Location Channel State** 6. **Media attachments**
- Your selected geohash channel, bookmarked geohashes, teleport flags, and bookmark display names - Voice notes and images you send or receive can be stored under Application Support so they remain playable while referenced by the app.
- Stored locally on your device so the location-channel UI can restore your choices - 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.
- Per-geohash Nostr identities are derived locally from a device seed stored in secure storage
- Exact latitude and longitude are not persisted by bitchat
### 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: ## Temporary Session Data
- 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
## 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: ### With Nearby Mesh Users
- 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 Room Members Depending on the feature you use, nearby peers can receive:
When you join a password-protected room: - Your chosen nickname and public Noise/signing identity material.
- Your messages are visible to others with the password - 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.
- Your nickname appears in the member list - Public mesh messages, public notices, and group-control packets you intentionally send.
- Room owners can see you've joined - 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: ### With Private Group Members
- 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.
## 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**: ### With Nostr Relays and Internet Gateways
- 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
## Encryption Internet-backed features are optional. When enabled or used:
All private messages use end-to-end encryption: - Private fallback messages use encrypted NIP-17 gift wraps. Relays can observe event and network metadata but not the message plaintext.
- **X25519** for key exchange - 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.
- **AES-256-GCM** for message encryption - 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.
- **Ed25519** for digital signatures - Bridge courier drops contain opaque end-to-end encrypted envelopes and a rotating recipient tag. Relays still observe timing and network metadata.
- **Argon2id** for password-protected rooms - 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: ## Location and Apple Services
- **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
## 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: - Exact coordinates are not included in bitchat mesh or Nostr payloads and are not persisted by bitchat.
- Used only for peer-to-peer communication - A selected geohash can still reveal an approximate area to peers and relays.
- Bluetooth is not used for tracking - When bitchat asks the operating system for a friendly place name, Apple's `CLGeocoder` service may process the location under Apple's privacy terms.
- You can revoke this permission at any time in system settings - 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: - 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.
- Used to compute local geohash channels and display names - Voice-note and live-audio files can remain in Application Support under the media retention rules above.
- Requested as when-in-use permission - Camera access is used to scan peer-verification QR codes. Photo-library access is used when you choose an image to send.
- Exact coordinates are not shared in messages or stored by bitchat - These permissions can be revoked in system settings. bitchat does not record microphone or camera input while the related capture UI is inactive.
- 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 ## 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. NIP-44 v2 private payloads use secp256k1 key agreement, HKDF-SHA256, and XChaCha20-Poly1305.
- 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 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 ## 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. 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.
## 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
## Changes to This Policy ## Changes to This Policy
If we update this policy: 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.
- 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
## Contact ## Contact
bitchat is an open source project. For privacy questions: 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 - View the source: [https://github.com/permissionlesstech/bitchat](https://github.com/permissionlesstech/bitchat)
- Open an issue on GitHub.
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.
--- ---
*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.*
+13 -1
View File
@@ -92,7 +92,8 @@
A6E32D232E762EAB0032EA8A /* Exceptions for "bitchatShareExtension" folder in "bitchatShareExtension" target */ = { A6E32D232E762EAB0032EA8A /* Exceptions for "bitchatShareExtension" folder in "bitchatShareExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet; isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = ( membershipExceptions = (
ShareViewController.swift, Info.plist,
bitchatShareExtension.entitlements,
); );
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */; target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
}; };
@@ -258,9 +259,13 @@
buildConfigurationList = E4EA6DC648DF55FF84032EB5 /* Build configuration list for PBXNativeTarget "bitchatShareExtension" */; buildConfigurationList = E4EA6DC648DF55FF84032EB5 /* Build configuration list for PBXNativeTarget "bitchatShareExtension" */;
buildPhases = ( buildPhases = (
0A08E70F08F55FD5BA8C7EF3 /* Sources */, 0A08E70F08F55FD5BA8C7EF3 /* Sources */,
7E9B64F63F93443FB7BA12DF /* Resources */,
); );
buildRules = ( buildRules = (
); );
fileSystemSynchronizedGroups = (
A6E32D212E762EAB0032EA8A /* bitchatShareExtension */,
);
name = bitchatShareExtension; name = bitchatShareExtension;
productName = bitchatShareExtension; productName = bitchatShareExtension;
productReference = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; productReference = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */;
@@ -388,6 +393,13 @@
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */, E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */,
); );
}; };
7E9B64F63F93443FB7BA12DF /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
+13 -5
View File
@@ -313,21 +313,29 @@ private extension AppRuntime {
} }
func checkForSharedContent() { func checkForSharedContent() {
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID), guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else { return }
let sharedContent = userDefaults.string(forKey: "sharedContent"), 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 { 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 return
} }
guard Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds else { guard Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds else {
clearSharedContent()
return return
} }
let contentKind = SharedContentKind(rawValue: userDefaults.string(forKey: "sharedContentType") ?? "") ?? .text let contentKind = SharedContentKind(rawValue: userDefaults.string(forKey: "sharedContentType") ?? "") ?? .text
userDefaults.removeObject(forKey: "sharedContent") clearSharedContent()
userDefaults.removeObject(forKey: "sharedContentType")
userDefaults.removeObject(forKey: "sharedContentDate")
switch contentKind { switch contentKind {
case .url: case .url:
+18 -1
View File
@@ -225,8 +225,25 @@ final class Conversation: ObservableObject, Identifiable {
guard let current else { return false } guard let current else { return false }
if current == new { return true } 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) { 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 return true
default: default:
return false return false
+12
View File
@@ -12,6 +12,7 @@ final class ConversationUIModel: ObservableObject {
@Published private(set) var currentNickname: String @Published private(set) var currentNickname: String
@Published private(set) var isBatchingPublic = false @Published private(set) var isBatchingPublic = false
@Published private(set) var canSendMediaInCurrentContext = true @Published private(set) var canSendMediaInCurrentContext = true
@Published private(set) var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest?
/// Who is talking live in the public mesh channel right now (floor /// Who is talking live in the public mesh channel right now (floor
/// courtesy: the composer mic tints "busy" while someone holds the floor). /// courtesy: the composer mic tints "busy" while someone holds the floor).
@Published private(set) var activeLiveVoiceTalker: String? @Published private(set) var activeLiveVoiceTalker: String?
@@ -153,6 +154,13 @@ final class ConversationUIModel: ObservableObject {
chatViewModel.sendVoiceNote(at: url) 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 /// Capture backend for the mic gesture: live PTT when the current DM
/// peer can hear it now, classic voice note otherwise. /// peer can hear it now, classic voice note otherwise.
func makeVoiceCaptureSession() -> VoiceCaptureSession { func makeVoiceCaptureSession() -> VoiceCaptureSession {
@@ -193,6 +201,10 @@ final class ConversationUIModel: ObservableObject {
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.assign(to: &$activeLiveVoiceTalker) .assign(to: &$activeLiveVoiceTalker)
chatViewModel.$legacyPrivateMediaConsentRequest
.receive(on: DispatchQueue.main)
.assign(to: &$legacyPrivateMediaConsentRequest)
conversations.$activeChannel conversations.$activeChannel
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] channel in .sink { [weak self] channel in
+55 -6
View File
@@ -17,16 +17,52 @@ final class NearbyNotesCounter: ObservableObject {
static let shared = NearbyNotesCounter() static let shared = NearbyNotesCounter()
@Published private(set) var noteCount = 0 @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 manager: LocationNotesManager?
private var managerCancellable: AnyCancellable? private var managerCancellable: AnyCancellable?
private var channelsCancellable: AnyCancellable? private var channelsCancellable: AnyCancellable?
private var permissionCancellable: AnyCancellable?
private var settingCancellable: AnyCancellable? private var settingCancellable: AnyCancellable?
private var activeHolders = 0 private var activeHolders = 0
private let locationManager: LocationChannelManager 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.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 /// Begins (or keeps) the notes subscription for the current building
@@ -38,6 +74,13 @@ final class NearbyNotesCounter: ObservableObject {
channelsCancellable = locationManager.$availableChannels channelsCancellable = locationManager.$availableChannels
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.retarget() } .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 // The app-info kill switch must take effect immediately, not on the
// next location change or remount. // next location change or remount.
settingCancellable = NotificationCenter.default settingCancellable = NotificationCenter.default
@@ -51,33 +94,39 @@ final class NearbyNotesCounter: ObservableObject {
activeHolders = max(0, activeHolders - 1) activeHolders = max(0, activeHolders - 1)
guard activeHolders == 0 else { return } guard activeHolders == 0 else { return }
channelsCancellable = nil channelsCancellable = nil
permissionCancellable = nil
settingCancellable = nil settingCancellable = nil
managerCancellable = nil managerCancellable = nil
manager?.cancel() releaseManager(manager)
manager = nil manager = nil
noteCount = 0 noteCount = 0
} }
private func retarget() { private func retarget() {
guard activeHolders > 0, guard activeHolders > 0,
revealed,
LocationNotesSettings.enabled, LocationNotesSettings.enabled,
locationManager.permissionState == .authorized, locationManager.permissionState == .authorized,
let geohash = locationManager.availableChannels let geohash = locationManager.availableChannels
.first(where: { $0.level == .building })?.geohash .first(where: { $0.level == .building })?.geohash
else { else {
managerCancellable = nil managerCancellable = nil
manager?.cancel() releaseManager(manager)
manager = nil manager = nil
noteCount = 0 noteCount = 0
return return
} }
if let manager { if let manager {
manager.setGeohash(geohash) guard manager.geohash != geohash.lowercased() else { return }
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 manager = fresh
managerCancellable = fresh.$notes managerCancellable = fresh.$notes
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
@@ -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
+395 -30
View File
@@ -6,10 +6,142 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import AVFoundation @preconcurrency import AVFoundation
import BitLogger import BitLogger
import Foundation 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. /// Plays one inbound live voice burst with a small jitter buffer.
/// ///
/// Frames are decoded and scheduled back-to-back on an `AVAudioPlayerNode`; /// 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 /// buffer arrives, which self-heals timing without explicit silence
/// insertion. Playback starts once `TransportConfig.pttJitterBufferSeconds` /// insertion. Playback starts once `TransportConfig.pttJitterBufferSeconds`
/// of audio is queued or `pttJitterDeadlineSeconds` has elapsed. /// 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 @MainActor
final class PTTBurstPlayer { final class PTTBurstPlayer {
private let engine = AVAudioEngine() /// Restart-on-reconfigure ceiling: a burst is at most ~2 minutes, so a
private let node = AVAudioPlayerNode() /// 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 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 queuedBuffers: [AVAudioPCMBuffer] = []
private var queuedDuration: TimeInterval = 0 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 engineStarted = false
private var finished = 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 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 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 } guard let format = PTTAudioFormat.pcmFormat, let decoder = PTTFrameDecoder() else { return nil }
self.decoder = decoder self.decoder = decoder
engine.attach(node) self.coordinator = coordinator ?? .shared
engine.connect(node, to: engine.mainMixerNode, format: format) self.exclusivity = exclusivity ?? .shared
let factory = makeEngine ?? { SystemPTTPlaybackEngine(format: format) }
self.makeEngine = factory
self.engine = factory()
deadlineTask = Task { [weak self] in deadlineTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(TransportConfig.pttJitterDeadlineSeconds * 1_000_000_000)) 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 /// Decodes and queues frames (in burst order). Starts playback when the
/// jitter buffer fills. /// jitter buffer fills.
func enqueue(_ frames: [Data]) { func enqueue(_ frames: [Data]) {
@@ -64,49 +261,119 @@ final class PTTBurstPlayer {
/// The burst ended: stop once everything scheduled has played out. /// The burst ended: stop once everything scheduled has played out.
func finishAfterDrain() { func finishAfterDrain() {
finished = true 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() stopIfDrained()
} }
/// Immediate stop (cancel, another playback taking over, teardown). /// Immediate stop (cancel, another playback taking over, interruption,
/// teardown).
func stop() { func stop() {
guard !stopped else { return } guard !stopped else { return }
stopped = true stopped = true
deadlineTask?.cancel() deadlineTask?.cancel()
removeConfigObserver()
queuedBuffers = [] queuedBuffers = []
retireScheduledBuffers()
if engineStarted { if engineStarted {
node.stop()
engine.stop() engine.stop()
} }
isPlaying = false isPlaying = false
VoiceNotePlaybackCoordinator.shared.deactivate(self) releaseSessionToken()
exclusivity.deactivate(self)
onStopped?()
} }
private func startIfReady(force: Bool) { 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 } guard force || queuedDuration >= TransportConfig.pttJitterBufferSeconds else { return }
#if os(iOS) // Acquiring the session suspends for its blocking IPC (off the main
do { // actor); frames arriving meanwhile keep queueing and are flushed
let session = AVAudioSession.sharedInstance() // onto the engine once it starts.
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers]) acquiringSession = true
try session.setActive(true, options: []) playbackReservation = exclusivity.reserve(self)
} catch { Task { [weak self] in
SecureLogger.error("PTT playback session activation failed: \(error)", category: .session) 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 {
// 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 { do {
try engine.start() try engine.start()
} catch { } catch {
SecureLogger.error("PTT playback engine failed to start: \(error)", category: .session) SecureLogger.error("PTT playback engine failed to start: \(error)", category: .session)
stopped = true // stop() removes the observer, hands the token back, and
// fires onStopped for any parked draining owner.
stop()
return return
} }
}
engineStarted = true 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 isPlaying = true
VoiceNotePlaybackCoordinator.shared.activate(self) engine.play()
node.play()
let buffered = queuedBuffers let buffered = queuedBuffers
queuedBuffers = [] queuedBuffers = []
@@ -116,21 +383,119 @@ final class PTTBurstPlayer {
} }
} }
private func schedule(_ buffer: AVAudioPCMBuffer) { /// The audio session was reconfigured underneath the running engine
scheduledCount += 1 /// (category escalation for talk-over, or an engine configuration
node.scheduleBuffer(buffer) { [weak self] in /// 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 Task { @MainActor [weak self] in
guard let self else { return } self?.restartEngine()
self.scheduledCount -= 1 }
}
}
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() self.stopIfDrained()
} }
} }
} }
private func retireScheduledBuffers() {
engineGeneration += 1
for scheduled in scheduledBuffers {
_ = scheduled.completionState.retireIfPending()
}
scheduledBuffers = []
}
private func stopIfDrained() { 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() stop()
} }
private func releaseSessionToken() {
sessionToken.map(coordinator.release)
sessionToken = nil
}
} }
extension PTTBurstPlayer: ExclusivePlayback { extension PTTBurstPlayer: ExclusivePlayback {
+179 -36
View File
@@ -10,12 +10,85 @@ import AVFoundation
import BitLogger import BitLogger
import Foundation 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: /// Captures microphone audio for a live push-to-talk burst, producing both:
/// - live AAC frames via `onFrames` (called on the capture queue), and /// - live AAC frames via `onFrames` (called on the capture queue), and
/// - a finalized `.m4a` voice note on `stop()` the same artifact /// - a finalized `.m4a` voice note on `stop()` the same artifact
/// `VoiceRecorder` produces, so the existing voice-note send pipeline /// `VoiceRecorder` produces, so the existing voice-note send pipeline
/// handles delivery to receivers that missed the live stream. /// 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 /// Hard cap matching `VoiceRecorder.maxRecordingDuration`: past it the
/// engine keeps running (the UI owns the gesture) but stops encoding. /// engine keeps running (the UI owns the gesture) but stops encoding.
private static let maxCaptureDuration: TimeInterval = 120 private static let maxCaptureDuration: TimeInterval = 120
@@ -26,6 +99,9 @@ final class PTTCaptureEngine {
/// enable the mic (AURemoteIO -10851, observed on iPhone field tests). /// enable the mic (AURemoteIO -10851, observed on iPhone field tests).
private var engine = AVAudioEngine() private var engine = AVAudioEngine()
private let queue = DispatchQueue(label: "chat.bitchat.ptt.capture", qos: .userInitiated) 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. // Capture-queue-confined state.
private var resampler: PTTInputResampler? private var resampler: PTTInputResampler?
@@ -35,10 +111,10 @@ final class PTTCaptureEngine {
private var encodedFrameCount = 0 private var encodedFrameCount = 0
private var running = false private var running = false
private var captureStart = Date() private var captureStart = Date()
/// Whether `engine.start()` succeeded for the current capture (main-actor /// Whether `engine.start()` succeeded for the current capture
/// callers only; see `stopEngineIfStarted`). /// (see `stopEngineIfStarted`).
private var engineStarted = false @MainActor private var engineStarted = false
@MainActor private var configChangeObserver: NSObjectProtocol?
/// Called on the capture queue with each batch of encoded AAC frames. /// Called on the capture queue with each batch of encoded AAC frames.
var onFrames: (([Data]) -> Void)? var onFrames: (([Data]) -> Void)?
@@ -47,11 +123,41 @@ final class PTTCaptureEngine {
case audioSetupFailed case audioSetupFailed
} }
func start(outputURL: URL) throws { init(coordinator: AudioSessionCoordinator = .shared) {
#if os(iOS) self.coordinator = coordinator
try Self.configureAudioSession() self.sessionLease = PTTCaptureSessionLease(coordinator: coordinator)
#endif }
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 // Fresh engine per capture so its input unit binds to the session
// that is active *now* (see `engine` doc comment). // that is active *now* (see `engine` doc comment).
engine = AVAudioEngine() engine = AVAudioEngine()
@@ -83,13 +189,30 @@ final class PTTCaptureEngine {
} }
engine.inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] buffer, _ in 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() engine.prepare()
do { do {
try engine.start() try engine.start()
} catch { } catch {
SecureLogger.error("PTT: capture engine failed to start (input: \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch): \(error)", category: .session) 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) engine.inputNode.removeTap(onBus: 0)
queue.sync { self.teardown(deleteFile: true) } queue.sync { self.teardown(deleteFile: true) }
throw error throw error
@@ -100,7 +223,9 @@ final class PTTCaptureEngine {
/// Stops capture and finalizes the `.m4a`. Returns the file URL and the /// Stops capture and finalizes the `.m4a`. Returns the file URL and the
/// number of encoded AAC frames (each `PTTAudioFormat.frameDuration` long). /// number of encoded AAC frames (each `PTTAudioFormat.frameDuration` long).
@MainActor
func stop() -> (url: URL?, encodedFrames: Int) { func stop() -> (url: URL?, encodedFrames: Int) {
captureGeneration.invalidate()
stopEngineIfStarted() stopEngineIfStarted()
let result: (URL?, Int) = queue.sync { let result: (URL?, Int) = queue.sync {
let url = fileURL let url = fileURL
@@ -108,34 +233,70 @@ final class PTTCaptureEngine {
teardown(deleteFile: false) teardown(deleteFile: false)
return (url, frames) return (url, frames)
} }
#if os(iOS) releaseSessionToken()
Self.deactivateAudioSession()
#endif
return result return result
} }
@MainActor
func cancel() { func cancel() {
captureGeneration.invalidate()
stopEngineIfStarted() stopEngineIfStarted()
queue.sync { teardown(deleteFile: true) } queue.sync { teardown(deleteFile: true) }
#if os(iOS) releaseSessionToken()
Self.deactivateAudioSession() }
#endif
/// 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 /// Touching `inputNode` on an engine that never started instantiates its
/// input unit against whatever session is active and spams AURemoteIO /// input unit against whatever session is active and spams AURemoteIO
/// errors a canceled-before-start hold must not touch the engine. /// errors a canceled-before-start hold must not touch the engine.
@MainActor
private func stopEngineIfStarted() { private func stopEngineIfStarted() {
if let observer = configChangeObserver {
NotificationCenter.default.removeObserver(observer)
configChangeObserver = nil
}
guard engineStarted else { return } guard engineStarted else { return }
engineStarted = false engineStarted = false
engine.inputNode.removeTap(onBus: 0) engine.inputNode.removeTap(onBus: 0)
engine.stop() engine.stop()
} }
@MainActor
private func releaseSessionToken() {
sessionLease.release()
}
// MARK: - Capture queue // MARK: - Capture queue
private func process(_ buffer: AVAudioPCMBuffer) { private func process(_ buffer: AVAudioPCMBuffer, generation: UInt) {
guard running, guard captureGeneration.isCurrent(generation),
running,
Date().timeIntervalSince(captureStart) < Self.maxCaptureDuration, Date().timeIntervalSince(captureStart) < Self.maxCaptureDuration,
let resampled = resampler?.resample(buffer) let resampled = resampler?.resample(buffer)
else { return } else { return }
@@ -162,22 +323,4 @@ final class PTTCaptureEngine {
} }
fileURL = nil 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
} }
@@ -31,25 +31,45 @@ protocol VoiceCaptureSession: AnyObject {
/// The classic record-then-send backend, wrapping the shared `VoiceRecorder`. /// The classic record-then-send backend, wrapping the shared `VoiceRecorder`.
@MainActor @MainActor
final class VoiceNoteCaptureSession: VoiceCaptureSession { final class VoiceNoteCaptureSession: VoiceCaptureSession {
private let recorder: VoiceRecorder
private let owner = VoiceRecorder.RecordingOwner()
var isLive: Bool { false } var isLive: Bool { false }
init(recorder: VoiceRecorder = .shared) {
self.recorder = recorder
}
func requestPermission() async -> Bool { func requestPermission() async -> Bool {
await VoiceRecorder.shared.requestPermission() await recorder.requestPermission()
} }
func start() async throws { func start() async throws {
try await VoiceRecorder.shared.startRecording() try await recorder.startRecording(owner: owner)
} }
func finish() async -> URL? { func finish() async -> URL? {
await VoiceRecorder.shared.stopRecording() await recorder.stopRecording(owner: owner)
} }
func cancel() async { func cancel() async {
await VoiceRecorder.shared.cancelRecording() await recorder.cancelRecording(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 /// Live push-to-talk backend: streams `VoiceBurstPacket`s to one peer while
/// recording, then finalizes the same audio as a standard voice note whose /// recording, then finalizes the same audio as a standard voice note whose
/// file name carries the burst ID (`voice_<burstID>.m4a`) so receivers that /// file name carries the burst ID (`voice_<burstID>.m4a`) so receivers that
@@ -60,7 +80,8 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
let burstID: Data let burstID: Data
private let sendPacket: (Data) -> Void 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 /// Capture-queue-confined stream state: packetizes frames and lazily
/// emits START so packet order is guaranteed by queue serialization. /// emits START so packet order is guaranteed by queue serialization.
private final class StreamState { private final class StreamState {
@@ -79,10 +100,17 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
/// - Parameter sendPacket: delivers one encoded `VoiceBurstPacket` to the /// - Parameter sendPacket: delivers one encoded `VoiceBurstPacket` to the
/// target peer; must be safe to call from any queue (BLEService hops to /// target peer; must be safe to call from any queue (BLEService hops to
/// its own message queue internally). /// its own message queue internally).
init(sendPacket: @escaping (Data) -> Void) { init(
self.burstID = VoiceBurstPacket.makeBurstID() 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.sendPacket = sendPacket
self.stream = StreamState(burstID: burstID) self.capture = capture ?? PTTCaptureEngine()
self.now = now
self.stream = StreamState(burstID: self.burstID)
} }
func requestPermission() async -> Bool { func requestPermission() async -> Bool {
@@ -117,16 +145,31 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
} }
} }
do { 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 { } catch {
// The HAL can briefly report a dead input right after the audio // The HAL can briefly report a dead input right after the audio
// session (re)activates while the route settles; one retry after // session (re)activates while the route settles; one retry after
// a short pause covers it (observed on iPhone field tests). // a short pause covers it (observed on iPhone field tests).
SecureLogger.warning("PTT: capture start failed (\(error)) — retrying once after route settle", category: .session) SecureLogger.warning("PTT: capture start failed (\(error)) — retrying once after route settle", category: .session)
try? await Task.sleep(nanoseconds: 150_000_000) 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
} }
startDate = Date() try await capture.start(outputURL: outputURL)
}
startDate = now()
SecureLogger.info("PTT: live burst \(burstID.hexEncodedString()) capture started", category: .session) SecureLogger.info("PTT: live burst \(burstID.hexEncodedString()) capture started", category: .session)
} }
@@ -134,11 +177,16 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
guard !completed else { return nil } guard !completed else { return nil }
completed = true completed = true
let elapsed = startDate.map { Date().timeIntervalSince($0) } ?? 0 let elapsed = startDate.map { now().timeIntervalSince($0) } ?? 0
let (url, encodedFrames) = capture.stop() let (url, encodedFrames) = capture.stop()
// stop() drained the capture queue, so touching `stream` is safe now. // 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) sendControlPacket(.canceled)
if let url { if let url {
try? FileManager.default.removeItem(at: url) try? FileManager.default.removeItem(at: url)
@@ -149,18 +197,24 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
for packet in stream.packetizer.flush() { for packet in stream.packetizer.flush() {
sendPacket(packet) 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)) 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) SecureLogger.info("PTT: live burst \(burstID.hexEncodedString()) finished — \(stream.packetizer.dataPacketCount) data packets, \(encodedFrames) frames, \(durationMs) ms", category: .session)
return url return url
} }
func cancel() async { func cancel() async {
guard !completed else { return } let alreadyCompleted = completed
completed = true 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() capture.cancel()
if !alreadyCompleted {
sendControlPacket(.canceled) sendControlPacket(.canceled)
} }
}
private func sendControlPacket(_ kind: VoiceBurstPacket.Kind) { private func sendControlPacket(_ kind: VoiceBurstPacket.Kind) {
guard let packet = VoiceBurstPacket(burstID: burstID, seq: stream.packetizer.nextSeq, kind: kind) else { return } guard let packet = VoiceBurstPacket(burstID: burstID, seq: stream.packetizer.nextSeq, kind: kind) else { return }
@@ -9,6 +9,9 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
@Published private(set) var duration: TimeInterval = 0 @Published private(set) var duration: TimeInterval = 0
@Published private(set) var progress: Double = 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" /// rounded so 4.9s shows "00:05"
var roundedDuration: Int { var roundedDuration: Int {
guard duration.isFinite else { return 0 } guard duration.isFinite else { return 0 }
@@ -24,9 +27,24 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
private var player: AVAudioPlayer? private var player: AVAudioPlayer?
private var timer: Timer? private var timer: Timer?
private var url: URL 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.url = url
self.sessionCoordinatorOverride = sessionCoordinator
self.exclusivity = exclusivity ?? .shared
super.init() super.init()
// Don't load anything eagerly - wait until user interaction or view is fully displayed // 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 { deinit {
timer?.invalidate() 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) { func replaceURL(_ url: URL) {
@@ -68,11 +96,15 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
func play() { func play() {
guard ensurePlayerReady() else { return } guard ensurePlayerReady() else { return }
VoiceNotePlaybackCoordinator.shared.activate(self) exclusivity.activate(self)
player?.play() isPlaying = true
startTimer() startTimer()
updateProgress() 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() { func pause() {
@@ -80,6 +112,7 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
stopTimer() stopTimer()
updateProgress() updateProgress()
isPlaying = false isPlaying = false
releaseSession()
} }
func stop() { func stop() {
@@ -88,7 +121,8 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
stopTimer() stopTimer()
updateProgress() updateProgress()
isPlaying = false isPlaying = false
VoiceNotePlaybackCoordinator.shared.deactivate(self) releaseSession()
exclusivity.deactivate(self)
} }
func seek(to fraction: Double) { func seek(to fraction: Double) {
@@ -96,8 +130,11 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
let clamped = max(0, min(1, fraction)) let clamped = max(0, min(1, fraction))
if let player = player { if let player = player {
player.currentTime = clamped * player.duration player.currentTime = clamped * player.duration
if isPlaying { // While the session acquire is still in flight, don't start
player.play() // audio pre-activation the pending acquire's completion starts
// playback (from the new position) once the session resolves.
if isPlaying, !sessionAcquireInFlight {
startPreparedPlayer()
} }
updateProgress() updateProgress()
} }
@@ -112,18 +149,20 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
self.stopTimer() self.stopTimer()
self.updateProgress() self.updateProgress()
self.isPlaying = false self.isPlaying = false
VoiceNotePlaybackCoordinator.shared.deactivate(self) self.releaseSession()
self.exclusivity.deactivate(self)
} }
} }
// MARK: - Private Helpers // MARK: - Private Helpers
private func preparePlayer(for url: URL) { 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 { do {
let player = try AVAudioPlayer(contentsOf: url) let player = try AVAudioPlayer(contentsOf: url)
player.delegate = self player.delegate = self
player.prepareToPlay()
self.player = player self.player = player
duration = player.duration duration = player.duration
currentTime = player.currentTime currentTime = player.currentTime
@@ -141,16 +180,79 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
if player == nil { if player == nil {
preparePlayer(for: url) preparePlayer(for: url)
} }
#if os(iOS) return player != nil
let session = AVAudioSession.sharedInstance() }
/// 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 { do {
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers]) token = try await coordinator.acquire(.playback) { [weak self] in
try session.setActive(true, options: []) self?.pause()
}
} catch { } catch {
SecureLogger.error("Failed to activate audio session: \(error)", category: .session) SecureLogger.error("Failed to activate audio session: \(error)", category: .session)
} }
#endif guard let self else {
return player != nil // 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() { private func startTimer() {
@@ -197,21 +299,59 @@ extension VoiceNotePlaybackController: ExclusivePlayback {
final class VoiceNotePlaybackCoordinator { final class VoiceNotePlaybackCoordinator {
static let shared = VoiceNotePlaybackCoordinator() static let shared = VoiceNotePlaybackCoordinator()
struct Reservation: Equatable {
fileprivate let generation: UInt64
}
private weak var activeController: (any ExclusivePlayback)? 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 { if activeController === controller {
return return true
} }
activeController?.pauseForExclusivity() activeController?.pauseForExclusivity()
activeController = controller activeController = controller
return true
}
func isCurrent(_ reservation: Reservation, for controller: any ExclusivePlayback) -> Bool {
latestReservation == reservation && latestReservedController === controller
} }
func deactivate(_ controller: any ExclusivePlayback) { func deactivate(_ controller: any ExclusivePlayback) {
if activeController === controller { if activeController === controller {
activeController = nil activeController = nil
} }
if latestReservedController === controller {
latestReservedController = nil
}
} }
} }
+212 -57
View File
@@ -1,21 +1,95 @@
import Foundation import Foundation
import AVFoundation 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. /// Manages audio capture for mesh voice notes with predictable encoding settings.
actor VoiceRecorder { actor VoiceRecorder {
enum RecorderError: Error { enum RecorderError: Error, Equatable {
case microphoneAccessDenied case microphoneAccessDenied
case recordingInProgress case recordingInProgress
case failedToStartRecording
} }
static let shared = VoiceRecorder() static let shared = VoiceRecorder()
private let paddingInterval: TimeInterval = 0.5
private let maxRecordingDuration: TimeInterval = 120
static let minRecordingDuration: TimeInterval = 1 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 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 // MARK: - Permissions
@@ -41,82 +115,130 @@ actor VoiceRecorder {
// MARK: - Recording Lifecycle // MARK: - Recording Lifecycle
@discardableResult @discardableResult
func startRecording() throws -> URL { func startRecording(owner: RecordingOwner) async throws -> URL {
if recorder?.isRecording == true { if activeOwner != nil {
throw RecorderError.recordingInProgress throw RecorderError.recordingInProgress
} }
#if os(iOS) guard permissionGranted() else {
let session = AVAudioSession.sharedInstance()
guard session.recordPermission == .granted else {
throw RecorderError.microphoneAccessDenied throw RecorderError.microphoneAccessDenied
} }
#if targetEnvironment(simulator)
// allowBluetoothHFP is not available on iOS Simulator activeOwner = owner
try session.setCategory( startInFlight = true
.playAndRecord,
mode: .default, // The acquire suspends while the blocking session IPC runs on the
options: [.defaultToSpeaker, .allowBluetoothA2DP] // coordinator's queue (never this actor's thread or main).
) let token: AudioSessionCoordinator.Token
#else do {
try session.setCategory( token = try await sessionCoordinator.acquire(.capture) { [weak self] in
.playAndRecord, Task { await self?.handleSessionInterruption(for: owner) }
mode: .default, }
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP] } catch {
) guard activeOwner === owner else {
#endif throw CancellationError()
try session.setActive(true, options: .notifyOthersOnDeactivation) }
#endif startInFlight = false
#if os(macOS) activeOwner = nil
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else { throw error
throw RecorderError.microphoneAccessDenied
} }
#endif
let outputURL = try makeOutputURL() // Actor reentrancy: release/cancel may have ended this hold while the
let settings: [String: Any] = [ // blocking session activation was still in progress.
AVFormatIDKey: kAudioFormatMPEG4AAC, guard activeOwner === owner, startInFlight else {
AVSampleRateKey: 16_000, sessionCoordinator.release(token)
AVNumberOfChannelsKey: 1, throw CancellationError()
AVEncoderBitRateKey: 16_000 }
] startInFlight = false
sessionToken = token
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings) var outputURL: URL?
do {
let newURL = try makeOutputURL()
outputURL = newURL
let audioRecorder = try recorderFactory.makeRecorder(url: newURL)
audioRecorder.isMeteringEnabled = true audioRecorder.isMeteringEnabled = true
audioRecorder.prepareToRecord() guard audioRecorder.prepareToRecord() else {
audioRecorder.record(forDuration: maxRecordingDuration) throw RecorderError.failedToStartRecording
}
guard audioRecorder.record(forDuration: maxRecordingDuration) else {
throw RecorderError.failedToStartRecording
}
recorder = audioRecorder recorder = audioRecorder
currentURL = outputURL currentURL = newURL
return outputURL 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? { func stopRecording(owner: RecordingOwner) async -> URL? {
guard let recorder, recorder.isRecording else { guard activeOwner === owner else { return nil }
return currentURL
// `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 let sessionURL = currentURL
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)) try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000))
}
}
recorder.stop() // 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 }
// A new session may have started during the sleep don't touch its state if activeRecorder.isRecording {
if self.recorder === recorder { activeRecorder.stop()
cleanupSession() }
releaseSessionToken()
self.recorder = nil self.recorder = nil
currentURL = nil currentURL = nil
} activeOwner = nil
return sessionURL 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 { if let recorder, recorder.isRecording {
recorder.stop() recorder.stop()
} }
cleanupSession() releaseSessionToken()
if let currentURL { if let currentURL {
try? FileManager.default.removeItem(at: currentURL) try? FileManager.default.removeItem(at: currentURL)
} }
@@ -124,14 +246,45 @@ actor VoiceRecorder {
currentURL = nil currentURL = nil
} }
/// 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 // 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 { private func makeOutputURL() throws -> URL {
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd_HHmmss" 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) try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: nil)
return baseDirectory.appendingPathComponent(fileName) return baseDirectory.appendingPathComponent(fileName)
} }
@@ -146,9 +299,11 @@ actor VoiceRecorder {
#endif #endif
} }
private func cleanupSession() { /// Fire-and-forget: the coordinator hops the blocking deactivation IPC
#if os(iOS) /// onto its own queue.
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) private func releaseSessionToken() {
#endif 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; // Fingerprint -> when we verified it (orders outgoing vouch batches;
// entries verified before this field exists sort as oldest) // entries verified before this field exists sort as oldest)
var verifiedAt: [String: Date]? = nil 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 markVouchBatchSent(to fingerprint: String, at date: Date)
func signingPublicKey(forFingerprint fingerprint: String) -> Data? func signingPublicKey(forFingerprint fingerprint: String) -> Data?
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] 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. /// Singleton manager for secure identity state persistence and retrieval.
@@ -157,6 +165,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
// Thread safety // Thread safety
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent) 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`. // Pending-save coalescing flag. Reads/writes are serialized on `queue`.
// Persistence is done with a fire-and-forget `queue.async(.barrier)` rather // Persistence is done with a fire-and-forget `queue.async(.barrier)` rather
@@ -214,6 +223,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
self.encryptionKey = loadedKey self.encryptionKey = loadedKey
self.encryptionKeyIsEphemeral = keyIsEphemeral self.encryptionKeyIsEphemeral = keyIsEphemeral
queue.setSpecific(key: queueSpecificKey, value: 1)
// Only read the persisted cache when we hold the real key; with an // Only read the persisted cache when we hold the real key; with an
// ephemeral key the decrypt would fail and discard the real cache. // 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) { func updateSocialIdentity(_ identity: SocialIdentity) {
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname
+4 -2
View File
@@ -31,6 +31,8 @@
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.social-networking</string>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string> <string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSBluetoothAlwaysUsageDescription</key> <key>NSBluetoothAlwaysUsageDescription</key>
@@ -40,9 +42,9 @@
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>bitchat uses the camera to scan QR codes to verify peers.</string> <string>bitchat uses the camera to scan QR codes to verify peers.</string>
<key>NSLocationWhenInUseUsageDescription</key> <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> <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> <key>NSPhotoLibraryUsageDescription</key>
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string> <string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
+360
View File
@@ -1,6 +1,186 @@
{ {
"sourceLanguage" : "en", "sourceLanguage" : "en",
"strings" : { "strings" : {
"notification.action.wave" : {
"comment" : "Title of the notification action button that sends a friendly wave back to a nearby person",
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "لوّح 👋"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "হাত নাড়ুন 👋"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "winken 👋"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "wave 👋"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "saludar 👋"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "kumaway 👋"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "saluer 👋"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "לנופף 👋"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "हाथ हिलाएँ 👋"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "melambai 👋"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "saluta 👋"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "手を振る 👋"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "손 흔들기 👋"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "lambai 👋"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "हात हल्लाउनुहोस् 👋"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "zwaaien 👋"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "pomachaj 👋"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "acenar 👋"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "acenar 👋"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "помахать 👋"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "vinka 👋"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "கை அசைக்கவும் 👋"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "โบกมือ 👋"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "el salla 👋"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "помахати 👋"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "ہاتھ ہلائیں 👋"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "vẫy tay 👋"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "挥手 👋"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "揮手 👋"
}
}
}
},
"#%@" : { "#%@" : {
"comment" : "Non-localizable channel hashtag format used in code", "comment" : "Non-localizable channel hashtag format used in code",
"extractionState" : "manual", "extractionState" : "manual",
@@ -31415,6 +31595,186 @@
} }
} }
}, },
"content.empty.check_notes" : {
"comment" : "Empty mesh timeline action that starts looking for notes left at this place; before tapping, no lookup runs",
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "تحقّق من الملاحظات المتروكة هنا"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "এখানে রাখা নোট আছে কি না দেখুন"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "nachsehen, ob hier notizen hinterlassen wurden"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "check for notes left here"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "buscar notas dejadas aquí"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "tingnan kung may mga note na naiwan dito"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "vérifier s'il y a des notes laissées ici"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "בדיקה אם הושארו כאן פתקים"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "देखें कि यहाँ नोट छोड़े गए हैं या नहीं"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "periksa catatan yang ditinggalkan di sini"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "controlla se ci sono note lasciate qui"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "ここに残されたメモを確認"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "여기 남겨진 쪽지 확인"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "semak nota yang ditinggalkan di sini"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "यहाँ छोडिएका नोटहरू छन् कि हेर्नुहोस्"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "kijk of hier notities zijn achtergelaten"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "sprawdź, czy zostawiono tutaj notatki"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "ver se há notas deixadas aqui"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "ver se há notas deixadas aqui"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "проверить, есть ли здесь заметки"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "kolla om anteckningar lämnats här"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "இங்கே விடப்பட்ட குறிப்புகள் உள்ளதா எனப் பார்க்கவும்"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "ดูว่ามีโน้ตทิ้งไว้ที่นี่หรือไม่"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "buraya bırakılan notlara bak"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "перевірити, чи залишено тут нотатки"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "دیکھیں کہ یہاں نوٹ چھوڑے گئے ہیں یا نہیں"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "kiểm tra ghi chú để lại ở đây"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "查看这里留下的留言"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "查看這裡留下的留言"
}
}
}
},
"content.empty.location_intro" : { "content.empty.location_intro" : {
"comment" : "First line of an empty geohash timeline naming the channel", "comment" : "First line of an empty geohash timeline naming the channel",
"extractionState" : "manual", "extractionState" : "manual",
+1 -1
View File
@@ -30,7 +30,7 @@ struct NoisePayload {
// Safely get the first byte // Safely get the first byte
let firstByte = data[data.startIndex] let firstByte = data[data.startIndex]
guard let type = NoisePayloadType(rawValue: firstByte) else { guard let type = NoisePayloadType.decoded(rawValue: firstByte) else {
return nil return nil
} }
@@ -6,15 +6,37 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import BitFoundation
import Foundation import Foundation
enum NoiseSecurityConstants { enum NoiseSecurityConstants {
// Maximum message size to prevent memory exhaustion // Maximum message size to prevent memory exhaustion
static let maxMessageSize = 65535 // 64KB as per Noise spec 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 // Maximum handshake message size
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern 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
// Session timeout - sessions older than this should be renegotiated // Session timeout - sessions older than this should be renegotiated
static let sessionTimeout: TimeInterval = 86400 // 24 hours static let sessionTimeout: TimeInterval = 86400 // 24 hours
@@ -15,6 +15,19 @@ struct NoiseSecurityValidator {
return data.count <= NoiseSecurityConstants.maxMessageSize 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 /// Validate handshake message size
static func validateHandshakeMessageSize(_ data: Data) -> Bool { static func validateHandshakeMessageSize(_ data: Data) -> Bool {
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
+1
View File
@@ -11,4 +11,5 @@ enum NoiseSessionError: Error, Equatable {
case notEstablished case notEstablished
case sessionNotFound case sessionNotFound
case alreadyEstablished case alreadyEstablished
case peerIdentityMismatch
} }
+198 -47
View File
@@ -13,11 +13,20 @@ import BitFoundation
final class NoiseSessionManager { final class NoiseSessionManager {
private var sessions: [PeerID: NoiseSession] = [:] private var sessions: [PeerID: NoiseSession] = [:]
/// Opaque identity for each exact entry in `sessions`. The generation is
/// created and removed under the same barrier as the session itself, so a
/// caller can never authenticate data with one session and lease another.
private var sessionGenerations: [PeerID: UUID] = [:]
/// A responder rehandshake must not evict a working transport session
/// before the candidate proves that its authenticated static key belongs
/// to the claimed wire ID. Candidates therefore live outside `sessions`
/// until the XX handshake completes and the binding is validated.
private var responderCandidates: [PeerID: NoiseSession] = [:]
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent) private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
// Callbacks // Callbacks
var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)? var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey, UUID) -> Void)?
var onSessionFailed: ((PeerID, Error) -> Void)? var onSessionFailed: ((PeerID, Error) -> Void)?
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) { init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
@@ -54,6 +63,10 @@ final class NoiseSessionManager {
if let session = sessions.removeValue(forKey: peerID) { if let session = sessions.removeValue(forKey: peerID) {
session.reset() // Clear sensitive data before removing session.reset() // Clear sensitive data before removing
} }
sessionGenerations.removeValue(forKey: peerID)
if let candidate = responderCandidates.removeValue(forKey: peerID) {
candidate.reset()
}
} }
} }
@@ -62,7 +75,12 @@ final class NoiseSessionManager {
for (_, session) in sessions { for (_, session) in sessions {
session.reset() session.reset()
} }
for (_, candidate) in responderCandidates {
candidate.reset()
}
sessions.removeAll() sessions.removeAll()
sessionGenerations.removeAll()
responderCandidates.removeAll()
} }
} }
@@ -79,11 +97,14 @@ final class NoiseSessionManager {
// Remove any existing non-established session // Remove any existing non-established session
if let existingSession = sessions[peerID], !existingSession.isEstablished() { if let existingSession = sessions[peerID], !existingSession.isEstablished() {
_ = sessions.removeValue(forKey: peerID) _ = sessions.removeValue(forKey: peerID)
sessionGenerations.removeValue(forKey: peerID)
existingSession.reset()
} }
// Create new initiator session // Create new initiator session
let session = sessionFactory(peerID, .initiator) let session = sessionFactory(peerID, .initiator)
sessions[peerID] = session sessions[peerID] = session
sessionGenerations[peerID] = UUID()
do { do {
let handshakeData = try session.startHandshake() let handshakeData = try session.startHandshake()
@@ -91,6 +112,8 @@ final class NoiseSessionManager {
} catch { } catch {
// Clean up failed session // Clean up failed session
_ = sessions.removeValue(forKey: peerID) _ = sessions.removeValue(forKey: peerID)
sessionGenerations.removeValue(forKey: peerID)
session.reset()
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription)) SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
throw error throw error
} }
@@ -98,41 +121,64 @@ final class NoiseSessionManager {
} }
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? { func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
// Process everything within the synchronized block to prevent race conditions // Process everything within the synchronized block to prevent race conditions.
return try managerQueue.sync(flags: .barrier) { // Return establishment metadata and publish the callback only after the
var shouldCreateNew = false // manager barrier is released, avoiding both a deadlock and a window in
var existingSession: NoiseSession? = nil // which `processHandshakeMessage` returns before authentication state.
let result: (
if let existing = sessions[peerID] { response: Data?,
// If we have an established session, the peer must have cleared their session establishedSession: (
// for a good reason (e.g., decryption failure, restart, etc.) remoteKey: Curve25519.KeyAgreement.PublicKey,
// We should accept the new handshake to re-establish encryption generation: UUID
if existing.isEstablished() { )?
SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session) ) = try managerQueue.sync(flags: .barrier) {
_ = sessions.removeValue(forKey: peerID)
shouldCreateNew = true
} else {
// If we're in the middle of a handshake and receive a new initiation,
// reset and start fresh (the other side may have restarted)
if existing.getState() == .handshaking && message.count == 32 {
_ = sessions.removeValue(forKey: peerID)
shouldCreateNew = true
} else {
existingSession = existing
}
}
} else {
shouldCreateNew = true
}
// Get or create session
let session: NoiseSession let session: NoiseSession
if shouldCreateNew { let isReplacementCandidate: Bool
if let candidate = responderCandidates[peerID] {
// A fresh XX message 1 supersedes an incomplete candidate,
// but never the established session it is trying to replace.
if message.count == NoiseSecurityConstants.xxInitialMessageSize {
candidate.reset()
let replacement = sessionFactory(peerID, .responder)
responderCandidates[peerID] = replacement
session = replacement
} else {
session = candidate
}
isReplacementCandidate = true
} else if let existing = sessions[peerID] {
if existing.isEstablished() {
SecureLogger.info(
"Validating replacement handshake from \(peerID) while preserving the established session",
category: .session
)
let candidate = sessionFactory(peerID, .responder)
responderCandidates[peerID] = candidate
session = candidate
isReplacementCandidate = true
} else if existing.getState() == .handshaking,
message.count == NoiseSecurityConstants.xxInitialMessageSize {
// No established transport state exists to preserve. A
// fresh initiation replaces the incomplete handshake.
_ = sessions.removeValue(forKey: peerID)
sessionGenerations.removeValue(forKey: peerID)
existing.reset()
let replacement = sessionFactory(peerID, .responder)
sessions[peerID] = replacement
sessionGenerations[peerID] = UUID()
session = replacement
isReplacementCandidate = false
} else {
session = existing
isReplacementCandidate = false
}
} else {
let newSession = sessionFactory(peerID, .responder) let newSession = sessionFactory(peerID, .responder)
sessions[peerID] = newSession sessions[peerID] = newSession
sessionGenerations[peerID] = UUID()
session = newSession session = newSession
} else { isReplacementCandidate = false
session = existingSession!
} }
// Process the handshake message within the synchronized block // Process the handshake message within the synchronized block
@@ -140,19 +186,46 @@ final class NoiseSessionManager {
let response = try session.processHandshakeMessage(message) let response = try session.processHandshakeMessage(message)
// Check if session is established after processing // Check if session is established after processing
var establishedSession: (
remoteKey: Curve25519.KeyAgreement.PublicKey,
generation: UUID
)?
if session.isEstablished() { if session.isEstablished() {
if let remoteKey = session.getRemoteStaticPublicKey() { guard let remoteKey = session.getRemoteStaticPublicKey(),
// Schedule callback outside the synchronized block to prevent deadlock authenticatedRemoteKey(remoteKey, matches: peerID) else {
DispatchQueue.global().async { [weak self] in throw NoiseSessionError.peerIdentityMismatch
self?.onSessionEstablished?(peerID, remoteKey)
}
}
} }
return response if isReplacementCandidate {
_ = responderCandidates.removeValue(forKey: peerID)
let previous = sessions.updateValue(session, forKey: peerID)
sessionGenerations[peerID] = UUID()
if let previous, previous !== session {
previous.reset()
}
}
guard let generation = sessionGenerations[peerID] else {
throw NoiseEncryptionError.sessionNotEstablished
}
establishedSession = (remoteKey, generation)
}
return (response, establishedSession)
} catch { } catch {
// Reset the session on handshake failure so next attempt can start fresh // A failed candidate is discarded without touching the
// established session. Ordinary failed handshakes retain the
// historical cleanup behavior.
if isReplacementCandidate {
if let storedCandidate = responderCandidates[peerID],
storedCandidate === session {
_ = responderCandidates.removeValue(forKey: peerID)
}
} else if let storedSession = sessions[peerID],
storedSession === session {
_ = sessions.removeValue(forKey: peerID) _ = sessions.removeValue(forKey: peerID)
sessionGenerations.removeValue(forKey: peerID)
}
session.reset()
// Schedule callback outside the synchronized block to prevent deadlock // Schedule callback outside the synchronized block to prevent deadlock
DispatchQueue.global().async { [weak self] in DispatchQueue.global().async { [weak self] in
@@ -163,24 +236,102 @@ final class NoiseSessionManager {
throw error throw error
} }
} }
if let established = result.establishedSession {
onSessionEstablished?(peerID, established.remoteKey, established.generation)
}
return result.response
}
/// Mesh handshakes normally use a 16-hex wire ID. Full Noise-key IDs are
/// also accepted by internal callers when they exactly match the static
/// key. Non-wire identifiers remain available to protocol test harnesses;
/// BLE packet ingress always supplies a short hexadecimal ID.
private func authenticatedRemoteKey(
_ remoteKey: Curve25519.KeyAgreement.PublicKey,
matches claimedPeerID: PeerID
) -> Bool {
let rawKey = remoteKey.rawRepresentation
if claimedPeerID.isShort {
return PeerID(publicKey: rawKey) == claimedPeerID
}
if let claimedNoiseKey = claimedPeerID.noiseKey {
return claimedNoiseKey == rawKey
}
return true
} }
// MARK: - Encryption/Decryption // MARK: - Encryption/Decryption
func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data { func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
guard let session = getSession(for: peerID) else { try managerQueue.sync {
guard let session = sessions[peerID] else {
throw NoiseSessionError.sessionNotFound throw NoiseSessionError.sessionNotFound
} }
return try session.encrypt(plaintext) return try session.encrypt(plaintext)
} }
}
/// Encrypts only if `expected` still names the current established entry.
/// A rekey between capability proof and media encryption therefore fails
/// closed instead of sending on an unproven replacement session.
func encrypt(
_ plaintext: Data,
for peerID: PeerID,
expectedSessionGeneration expected: UUID
) throws -> Data {
try managerQueue.sync {
guard let session = sessions[peerID],
session.isEstablished(),
sessionGenerations[peerID] == expected else {
throw NoiseEncryptionError.sessionNotEstablished
}
return try session.encrypt(plaintext)
}
}
func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data { func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data {
guard let session = getSession(for: peerID) else { try decryptWithSessionGeneration(ciphertext, from: peerID).plaintext
throw NoiseSessionError.sessionNotFound
} }
return try session.decrypt(ciphertext) func sessionGeneration(for peerID: PeerID) -> UUID? {
managerQueue.sync {
guard sessions[peerID]?.isEstablished() == true else { return nil }
return sessionGenerations[peerID]
}
}
/// Decrypts while holding the manager's read lease. Session promotion and
/// removal require its barrier, so the returned generation always names
/// the exact session object that authenticated these bytes.
func decryptWithSessionGeneration(
_ ciphertext: Data,
from peerID: PeerID
) throws -> (plaintext: Data, sessionGeneration: UUID) {
try managerQueue.sync {
guard let session = sessions[peerID] else {
throw NoiseSessionError.sessionNotFound
}
guard session.isEstablished(),
let generation = sessionGenerations[peerID] else {
throw NoiseEncryptionError.sessionNotEstablished
}
return (try session.decrypt(ciphertext), generation)
}
}
/// Runs a state commit under a read lease for the exact established
/// session. Rekey, replacement, and removal all need the same barrier.
func withCurrentSessionGeneration<Result>(
for peerID: PeerID,
expected: UUID,
_ body: () -> Result
) -> Result? {
managerQueue.sync {
guard sessions[peerID]?.isEstablished() == true,
sessionGenerations[peerID] == expected else { return nil }
return body()
}
} }
// MARK: - Key Management // MARK: - Key Management
@@ -207,11 +358,11 @@ final class NoiseSessionManager {
} }
} }
func initiateRekey(for peerID: PeerID) throws { func initiateRekey(for peerID: PeerID) throws -> Data {
// Remove old session // Remove old session
removeSession(for: peerID) removeSession(for: peerID)
// Initiate new handshake // Initiate new handshake
_ = try initiateHandshake(with: peerID) return try initiateHandshake(with: peerID)
} }
} }
+11 -4
View File
@@ -24,8 +24,12 @@ final class SecureNoiseSession: NoiseSession {
throw NoiseSecurityError.sessionExhausted throw NoiseSecurityError.sessionExhausted
} }
// Validate message size // Ordinary Noise messages keep the protocol ceiling. Finalized media
guard NoiseSecurityValidator.validateMessageSize(plaintext) else { // 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 throw NoiseSecurityError.messageTooLarge
} }
@@ -42,8 +46,11 @@ final class SecureNoiseSession: NoiseSession {
throw NoiseSecurityError.sessionExpired throw NoiseSecurityError.sessionExpired
} }
// Validate message size // The payload type is encrypted, so a large candidate can only be
guard NoiseSecurityValidator.validateMessageSize(ciphertext) else { // 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 throw NoiseSecurityError.messageTooLarge
} }
+17 -7
View File
@@ -264,22 +264,32 @@ struct NostrProtocol {
/// Create a mesh-bridge public message (kind 20000) for a geohash-cell /// Create a mesh-bridge public message (kind 20000) for a geohash-cell
/// rendezvous. The distinct `r` tag keeps bridge traffic out of geohash /// rendezvous. The distinct `r` tag keeps bridge traffic out of geohash
/// channel subscriptions (which filter on `#g`); `m` carries the original /// channel subscriptions (which filter on `#g`); `m` is
/// mesh message ID so receivers dedup the bridged copy against the radio /// `[stable ID, mesh sender ID, wire timestamp in ms]`. Element 1 is the
/// copy by timeline ID. /// 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( static func createBridgeMeshEvent(
content: String, content: String,
cell: String, cell: String,
senderIdentity: NostrIdentity, senderIdentity: NostrIdentity,
nickname: String? = nil, nickname: String? = nil,
meshMessageID: String? = nil meshSenderID: String? = nil,
meshTimestampMs: UInt64? = nil
) throws -> NostrEvent { ) throws -> NostrEvent {
var tags = [["r", cell]] var tags = [["r", cell]]
if let nickname = nickname?.trimmedOrNilIfEmpty { if let nickname = nickname?.trimmedOrNilIfEmpty {
tags.append(["n", nickname]) tags.append(["n", nickname])
} }
if let meshMessageID = meshMessageID?.trimmedOrNilIfEmpty { if let meshSenderID = meshSenderID?.trimmedOrNilIfEmpty, let meshTimestampMs {
tags.append(["m", meshMessageID]) let stableID = MeshMessageIdentity.stableID(
senderIDHex: meshSenderID,
timestampMs: meshTimestampMs,
content: content
)
tags.append(["m", stableID, meshSenderID, String(meshTimestampMs)])
} }
let event = NostrEvent( let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex, pubkey: senderIdentity.publicKeyHex,
@@ -325,7 +335,7 @@ struct NostrProtocol {
) throws -> NostrEvent { ) throws -> NostrEvent {
let tags = [ let tags = [
["x", recipientTagHex], ["x", recipientTagHex],
["expiration", String(Int(expiresAt.timeIntervalSince1970))], ["expiration", String(Int(expiresAt.timeIntervalSince1970))]
] ]
let event = NostrEvent( let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex, pubkey: senderIdentity.publicKeyHex,
+135 -4
View File
@@ -216,6 +216,15 @@ final class NostrRelayManager: ObservableObject {
} }
private var messageQueue: [PendingSend] = [] private var messageQueue: [PendingSend] = []
private let messageQueueLock = NSLock() private let messageQueueLock = NSLock()
/// Non-queued sends whose callers require relay durability. A WebSocket
/// write only proves bytes left this process; NIP-20 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 // Total pending sends dropped at the queue cap; drives the sampled
// overflow warning (first + every Nth drop). // overflow warning (first + every Nth drop).
private var pendingSendDropCount = 0 private var pendingSendDropCount = 0
@@ -312,6 +321,9 @@ final class NostrRelayManager: ObservableObject {
for (_, tracker) in trackers { for (_, tracker) in trackers {
tracker.callback() tracker.callback()
} }
let confirmed = confirmedSends.values.map(\.completion)
confirmedSends.removeAll()
confirmed.forEach { $0(false) }
pendingTorConnectionURLs.removeAll() pendingTorConnectionURLs.removeAll()
awaitingTorForConnections = false awaitingTorForConnections = false
torReadyWaitAttempts = 0 torReadyWaitAttempts = 0
@@ -345,6 +357,7 @@ final class NostrRelayManager: ObservableObject {
duplicateInboundEventDropCountBySubscription.removeAll() duplicateInboundEventDropCountBySubscription.removeAll()
inboundEventLogCount = 0 inboundEventLogCount = 0
Self.pendingGiftWrapIDs.removeAll() Self.pendingGiftWrapIDs.removeAll()
confirmedSends.removeAll()
messageQueueLock.lock() messageQueueLock.lock()
messageQueue.removeAll() messageQueue.removeAll()
@@ -419,6 +432,97 @@ final class NostrRelayManager: ObservableObject {
} }
} }
/// Attempts an event only on currently connected target relays and
/// reports whether at least one relay explicitly accepted it via NIP-20
/// 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 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 func enqueuePendingSend(_ event: NostrEvent, pendingRelays: Set<String>) { private func enqueuePendingSend(_ event: NostrEvent, pendingRelays: Set<String>) {
messageQueueLock.lock() messageQueueLock.lock()
messageQueue.append(PendingSend(event: event, pendingRelays: pendingRelays)) messageQueue.append(PendingSend(event: event, pendingRelays: pendingRelays))
@@ -923,6 +1027,7 @@ final class NostrRelayManager: ObservableObject {
// Send initial ping to verify connection // Send initial ping to verify connection
task.sendPing { [weak self] error in task.sendPing { [weak self] error in
DispatchQueue.main.async { DispatchQueue.main.async {
guard self?.connections[urlString] === task else { return }
if error == nil { if error == nil {
SecureLogger.debug("✅ Connected to Nostr relay: \(urlString)", category: .session) SecureLogger.debug("✅ Connected to Nostr relay: \(urlString)", category: .session)
self?.updateRelayStatus(urlString, isConnected: true) self?.updateRelayStatus(urlString, isConnected: true)
@@ -932,7 +1037,11 @@ final class NostrRelayManager: ObservableObject {
SecureLogger.error("❌ Failed to connect to Nostr relay \(urlString): \(error?.localizedDescription ?? "Unknown error")", category: .session) SecureLogger.error("❌ Failed to connect to Nostr relay \(urlString): \(error?.localizedDescription ?? "Unknown error")", category: .session)
self?.updateRelayStatus(urlString, isConnected: false, error: error) self?.updateRelayStatus(urlString, isConnected: false, error: error)
// Trigger disconnection handler for proper backoff // 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 +1099,20 @@ final class NostrRelayManager: ObservableObject {
Task.detached(priority: .utility) { Task.detached(priority: .utility) {
guard let parsed = ParsedInbound(message) else { return } guard let parsed = ParsedInbound(message) else { return }
await MainActor.run { await MainActor.run {
guard self.connections[relayUrl] === task else { return }
self.handleParsedMessage(parsed, from: relayUrl) self.handleParsedMessage(parsed, from: relayUrl)
} }
} }
// Continue receiving // Continue receiving
Task { @MainActor in Task { @MainActor in
guard self.connections[relayUrl] === task else { return }
self.receiveMessage(from: task, relayUrl: relayUrl) self.receiveMessage(from: task, relayUrl: relayUrl)
} }
case .failure(let error): case .failure(let error):
DispatchQueue.main.async { DispatchQueue.main.async {
self.handleDisconnection(relayUrl: relayUrl, error: error) self.handleDisconnection(relayUrl: relayUrl, error: error, connection: task)
} }
} }
} }
@@ -1055,6 +1166,7 @@ final class NostrRelayManager: ObservableObject {
} }
} }
case .ok(let eventId, let success, let reason): case .ok(let eventId, let success, let reason):
resolveConfirmedSend(eventID: eventId, relayURL: relayUrl, accepted: success)
if success { if success {
_ = Self.pendingGiftWrapIDs.remove(eventId) _ = Self.pendingGiftWrapIDs.remove(eventId)
SecureLogger.debug("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)", category: .session) SecureLogger.debug("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)", category: .session)
@@ -1071,7 +1183,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) let req = NostrRequest.event(event)
do { do {
@@ -1084,17 +1201,20 @@ final class NostrRelayManager: ObservableObject {
DispatchQueue.main.async { DispatchQueue.main.async {
if let error = error { if let error = error {
SecureLogger.error("❌ Failed to send event to \(relayUrl): \(error)", category: .session) SecureLogger.error("❌ Failed to send event to \(relayUrl): \(error)", category: .session)
completion?(false)
} else { } else {
// SecureLogger.debug(" Event sent to relay: \(relayUrl)", category: .session) // SecureLogger.debug(" Event sent to relay: \(relayUrl)", category: .session)
// Update relay stats // Update relay stats
if let index = self?.relays.firstIndex(where: { $0.url == relayUrl }) { if let index = self?.relays.firstIndex(where: { $0.url == relayUrl }) {
self?.relays[index].messagesSent += 1 self?.relays[index].messagesSent += 1
} }
completion?(true)
} }
} }
} }
} catch { } catch {
SecureLogger.error("Failed to encode event: \(error)", category: .session) SecureLogger.error("Failed to encode event: \(error)", category: .session)
completion?(false)
} }
} }
@@ -1158,9 +1278,20 @@ final class NostrRelayManager: ObservableObject {
eoseTrackers[id] = tracker 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 }
connections.removeValue(forKey: relayUrl) connections.removeValue(forKey: relayUrl)
subscriptions.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) updateRelayStatus(relayUrl, isConnected: false, error: error)
settleEOSETrackers(droppingRelay: relayUrl) settleEOSETrackers(droppingRelay: relayUrl)
// If networking is disallowed, do not schedule reconnection // If networking is disallowed, do not schedule reconnection
+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>
+25
View File
@@ -79,12 +79,35 @@ enum NoisePayloadType: UInt8 {
case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update) case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update)
// Live voice (push-to-talk) // Live voice (push-to-talk)
case voiceFrame = 0x08 // One live voice-burst packet (see VoiceBurstPacket) 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) // Verification (QR-based OOB binding)
case verifyChallenge = 0x10 // Verification challenge case verifyChallenge = 0x10 // Verification challenge
case verifyResponse = 0x11 // Verification response case verifyResponse = 0x11 // Verification response
// Transitive verification (web of trust) // Transitive verification (web of trust)
case vouch = 0x12 // Batch of vouch attestations 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 { var description: String {
switch self { switch self {
case .privateMessage: return "privateMessage" case .privateMessage: return "privateMessage"
@@ -93,6 +116,8 @@ enum NoisePayloadType: UInt8 {
case .groupInvite: return "groupInvite" case .groupInvite: return "groupInvite"
case .groupKeyUpdate: return "groupKeyUpdate" case .groupKeyUpdate: return "groupKeyUpdate"
case .voiceFrame: return "voiceFrame" case .voiceFrame: return "voiceFrame"
case .privateFile: return "privateFile"
case .authenticatedPeerState: return "authenticatedPeerState"
case .verifyChallenge: return "verifyChallenge" case .verifyChallenge: return "verifyChallenge"
case .verifyResponse: return "verifyResponse" case .verifyResponse: return "verifyResponse"
case .vouch: return "vouch" 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 { struct PrivateMessagePacket {
let messageID: String let messageID: String
let content: String let content: String
@@ -3,5 +3,5 @@ import BitFoundation
extension PeerCapabilities { extension PeerCapabilities {
/// Capabilities this build advertises in its announce packets. /// Capabilities this build advertises in its announce packets.
/// Each feature adds its bit here when it ships. /// Each feature adds its bit here when it ships.
static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups] static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups, .privateMedia]
} }
+38 -2
View File
@@ -16,10 +16,19 @@ struct BLEAnnounceHandlerEnvironment {
let now: () -> Date let now: () -> Date
/// Noise public key already recorded for the peer, if any (registry read). /// Noise public key already recorded for the peer, if any (registry read).
let existingNoisePublicKey: (PeerID) -> Data? 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. /// Verifies the packet signature against the announced signing key.
let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
/// Direct link state for the peer (BLE-queue read). /// Direct link state for the peer (BLE-queue read).
let linkState: (PeerID) -> (hasPeripheral: Bool, hasCentral: Bool) 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. /// Runs the registry mutation phase under the collections barrier.
let withRegistryBarrier: (() -> Void) -> Void let withRegistryBarrier: (() -> Void) -> Void
/// Upserts the verified announce into the peer registry. /// Upserts the verified announce into the peer registry.
@@ -124,17 +133,44 @@ final class BLEAnnounceHandler {
hasSignature: hasSignature, hasSignature: hasSignature,
signatureValid: signatureValid, signatureValid: signatureValid,
existingNoisePublicKey: existingNoisePublicKey, existingNoisePublicKey: existingNoisePublicKey,
announcedNoisePublicKey: announcement.noisePublicKey announcedNoisePublicKey: announcement.noisePublicKey,
authenticatedSigningPublicKey: env.authenticatedSigningPublicKey(
announcement.noisePublicKey
),
announcedSigningPublicKey: announcement.signingPublicKey
) )
if case .reject(.keyMismatch) = trustDecision { if case .reject(.keyMismatch) = trustDecision {
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security) 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 let verifiedAnnounce = trustDecision.isVerified
var isNewPeer = false var isNewPeer = false
var isReconnectedPeer = false var isReconnectedPeer = false
let directLinkState = env.linkState(peerID) let directLinkState = env.linkState(peerID)
let isDirectAnnounce = packet.ttl == env.messageTTL 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 { env.withRegistryBarrier {
let hasPeripheralConnection = directLinkState.hasPeripheral let hasPeripheralConnection = directLinkState.hasPeripheral
@@ -152,7 +188,7 @@ final class BLEAnnounceHandler {
let update = env.upsertVerifiedAnnounce( let update = env.upsertVerifiedAnnounce(
peerID, peerID,
announcement, announcement,
isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription, hasPeripheralConnection || hasCentralSubscription || (isDirectAnnounce && !linkBoundToOtherPeer),
now now
) )
isNewPeer = update.isNewPeer isNewPeer = update.isNewPeer
@@ -56,6 +56,7 @@ enum BLEAnnounceTrustRejection: Equatable {
case missingSignature case missingSignature
case invalidSignature case invalidSignature
case keyMismatch case keyMismatch
case authenticatedSigningKeyMismatch
} }
enum BLEAnnounceTrustDecision: Equatable { enum BLEAnnounceTrustDecision: Equatable {
@@ -72,12 +73,19 @@ enum BLEAnnounceTrustPolicy {
hasSignature: Bool, hasSignature: Bool,
signatureValid: Bool, signatureValid: Bool,
existingNoisePublicKey: Data?, existingNoisePublicKey: Data?,
announcedNoisePublicKey: Data announcedNoisePublicKey: Data,
authenticatedSigningPublicKey: Data? = nil,
announcedSigningPublicKey: Data? = nil
) -> BLEAnnounceTrustDecision { ) -> BLEAnnounceTrustDecision {
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey { if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
return .reject(.keyMismatch) return .reject(.keyMismatch)
} }
if let authenticatedSigningPublicKey,
announcedSigningPublicKey != authenticatedSigningPublicKey {
return .reject(.authenticatedSigningKeyMismatch)
}
guard hasSignature else { guard hasSignature else {
return .reject(.missingSignature) return .reject(.missingSignature)
} }
+44 -8
View File
@@ -15,7 +15,10 @@ enum BLEFanoutSelector {
excludedLinks: Set<BLEIngressLinkID> = [], excludedLinks: Set<BLEIngressLinkID> = [],
peripheralPeerBindings: [String: PeerID] = [:], peripheralPeerBindings: [String: PeerID] = [:],
centralPeerBindings: [String: PeerID] = [:], centralPeerBindings: [String: PeerID] = [:],
preferredPeripheralPerPeer: [PeerID: String] = [:],
collapseDuplicatePeerLinks: Bool = true,
directedPeerHint: PeerID?, directedPeerHint: PeerID?,
requireDirectPeerLink: Bool = false,
packetType: UInt8, packetType: UInt8,
messageID: String messageID: String
) -> BLEFanoutSelection { ) -> BLEFanoutSelection {
@@ -31,10 +34,14 @@ enum BLEFanoutSelector {
to: directedPeerHint, to: directedPeerHint,
links: rawAllowed, links: rawAllowed,
peripheralPeerBindings: peripheralPeerBindings, peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings centralPeerBindings: centralPeerBindings,
preferredPeripheralPerPeer: preferredPeripheralPerPeer
) { ) {
return directedSelection return directedSelection
} }
if directedPeerHint != nil, requireDirectPeerLink {
return BLEFanoutSelection(peripheralIDs: [], centralIDs: [])
}
if let directedPeerHint, if let directedPeerHint,
hasBoundLink( hasBoundLink(
to: directedPeerHint, to: directedPeerHint,
@@ -46,11 +53,20 @@ enum BLEFanoutSelector {
return BLEFanoutSelection(peripheralIDs: [], centralIDs: []) return BLEFanoutSelection(peripheralIDs: [], centralIDs: [])
} }
let allowed = collapseDuplicateLinksPerPeer( // 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, rawAllowed,
peripheralPeerBindings: peripheralPeerBindings, peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings centralPeerBindings: centralPeerBindings,
preferredPeripheralPerPeer: preferredPeripheralPerPeer
) )
: rawAllowed
guard shouldSubset(packetType: packetType, directedPeerHint: directedPeerHint) else { guard shouldSubset(packetType: packetType, directedPeerHint: directedPeerHint) else {
return BLEFanoutSelection( return BLEFanoutSelection(
@@ -97,7 +113,8 @@ enum BLEFanoutSelector {
to peerID: PeerID, to peerID: PeerID,
links: (peripheralIDs: [String], centralIDs: [String]), links: (peripheralIDs: [String], centralIDs: [String]),
peripheralPeerBindings: [String: PeerID], peripheralPeerBindings: [String: PeerID],
centralPeerBindings: [String: PeerID] centralPeerBindings: [String: PeerID],
preferredPeripheralPerPeer: [PeerID: String]
) -> BLEFanoutSelection? { ) -> BLEFanoutSelection? {
let directLinks = collapseDuplicateLinksPerPeer( let directLinks = collapseDuplicateLinksPerPeer(
( (
@@ -105,7 +122,8 @@ enum BLEFanoutSelector {
centralIDs: links.centralIDs.filter { centralPeerBindings[$0] == peerID } centralIDs: links.centralIDs.filter { centralPeerBindings[$0] == peerID }
), ),
peripheralPeerBindings: peripheralPeerBindings, peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings centralPeerBindings: centralPeerBindings,
preferredPeripheralPerPeer: preferredPeripheralPerPeer
) )
guard !directLinks.peripheralIDs.isEmpty || !directLinks.centralIDs.isEmpty else { guard !directLinks.peripheralIDs.isEmpty || !directLinks.centralIDs.isEmpty else {
@@ -140,7 +158,8 @@ enum BLEFanoutSelector {
private static func collapseDuplicateLinksPerPeer( private static func collapseDuplicateLinksPerPeer(
_ links: (peripheralIDs: [String], centralIDs: [String]), _ links: (peripheralIDs: [String], centralIDs: [String]),
peripheralPeerBindings: [String: PeerID], peripheralPeerBindings: [String: PeerID],
centralPeerBindings: [String: PeerID] centralPeerBindings: [String: PeerID],
preferredPeripheralPerPeer: [PeerID: String]
) -> (peripheralIDs: [String], centralIDs: [String]) { ) -> (peripheralIDs: [String], centralIDs: [String]) {
guard !peripheralPeerBindings.isEmpty || !centralPeerBindings.isEmpty else { guard !peripheralPeerBindings.isEmpty || !centralPeerBindings.isEmpty else {
return links return links
@@ -148,13 +167,30 @@ enum BLEFanoutSelector {
var seenPeers = Set<PeerID>() var seenPeers = Set<PeerID>()
var keptPeripheralIDs: [String] = [] 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 { for id in links.peripheralIDs {
if let peer = peripheralPeerBindings[id], !seenPeers.insert(peer).inserted { guard let peer = peripheralPeerBindings[id],
continue 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) 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] = [] var keptCentralIDs: [String] = []
for id in links.centralIDs { for id in links.centralIDs {
if let peer = centralPeerBindings[id], !seenPeers.insert(peer).inserted { if let peer = centralPeerBindings[id], !seenPeers.insert(peer).inserted {
+101 -62
View File
@@ -16,6 +16,8 @@ struct BLEFileTransferHandlerEnvironment {
let peersSnapshot: () -> [PeerID: BLEPeerInfo] let peersSnapshot: () -> [PeerID: BLEPeerInfo]
/// Verifies a packet's signature against a candidate signing key (registry path). /// Verifies a packet's signature against a candidate signing key (registry path).
let verifyPacketSignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool 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. /// Resolves a display name from a verified packet signature for peers missing from the registry.
let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String? let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String?
/// Tracks the broadcast file packet for gossip sync. /// Tracks the broadcast file packet for gossip sync.
@@ -46,54 +48,105 @@ final class BLEFileTransferHandler {
self.environment = environment self.environment = environment
} }
/// Returns `false` when the packet fails sender authentication and must /// Returns `false` when the raw packet fails sender authentication (or is
/// not be relayed onward. Every other outcome returns `true`: files /// a live self-echo) and must not be relayed onward. Authentication runs
/// directed to another peer are forwarded untouched, and local-only drops /// before the routing decision, so a forged directed packet cannot use a
/// (malformed payload, quota, save failure) don't affect multi-hop /// node that is not its recipient as an unsigned forwarding hop.
/// delivery to nodes that may handle them fine.
@discardableResult @discardableResult
func handle(_ packet: BitchatPacket, from peerID: PeerID) -> Bool { func handle(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
let env = environment let env = environment
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return true } let localPeerID = env.localPeerID()
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: env.localPeerID()) else {
return true
}
let peersSnapshot = env.peersSnapshot() let peersSnapshot = env.peersSnapshot()
guard let senderNickname = resolveSenderNickname(
guard let senderNickname = authenticatedRawSenderNickname(
packet: packet, packet: packet,
from: peerID, from: peerID,
isBroadcast: !deliveryPlan.isPrivateMessage,
peers: peersSnapshot, peers: peersSnapshot,
env: env env: env
) else { ) 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 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 { if deliveryPlan.shouldTrackForSync {
env.trackPacketSeen(packet) env.trackPacketSeen(packet)
} }
_ = storeIncomingPayload(
packet.payload,
from: peerID,
senderNickname: senderNickname,
timestamp: Date(timeIntervalSince1970: Double(packet.timestamp) / 1000),
isPrivate: deliveryPlan.isPrivateMessage,
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,
env: env
)
}
private func storeIncomingPayload(
_ payload: Data,
from peerID: PeerID,
senderNickname: String,
timestamp: Date,
isPrivate: Bool,
env: BLEFileTransferHandlerEnvironment
) -> Bool {
let filePacket: BitchatFilePacket let filePacket: BitchatFilePacket
let mime: MimeType let mime: MimeType
switch BLEIncomingFileValidator.validate(payload: packet.payload) { switch BLEIncomingFileValidator.validate(payload: payload) {
case .success(let acceptance): case .success(let acceptance):
filePacket = acceptance.filePacket filePacket = acceptance.filePacket
mime = acceptance.mime mime = acceptance.mime
case .failure(.malformedPayload): case .failure(.malformedPayload):
SecureLogger.error("❌ Failed to decode file transfer payload", category: .session) SecureLogger.error("❌ Failed to decode file transfer payload", category: .session)
return true return false
case .failure(.payloadTooLarge(let bytes)): case .failure(.payloadTooLarge(let bytes)):
SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security) SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security)
return true return false
case .failure(.unsupportedMime(let mimeType, let bytes)): 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) 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)): 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) SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(bytes)b prefix=[\(prefixHex)] from \(peerID.id.prefix(8))...", category: .security)
return true return false
} }
// BCH-01-002: Enforce storage quota before saving // BCH-01-002: Enforce storage quota before saving
@@ -106,28 +159,27 @@ final class BLEFileTransferHandler {
mime.defaultExtension, mime.defaultExtension,
mime.category.rawValue mime.category.rawValue
) else { ) else {
return true return false
} }
if deliveryPlan.isPrivateMessage { if isPrivate {
env.updatePeerLastSeen(peerID) env.updatePeerLastSeen(peerID)
} }
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
let message = BitchatMessage( let message = BitchatMessage(
sender: senderNickname, sender: senderNickname,
content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)", content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)",
timestamp: ts, timestamp: timestamp,
isRelay: false, isRelay: false,
originalSender: nil, originalSender: nil,
isPrivate: deliveryPlan.isPrivateMessage, isPrivate: isPrivate,
recipientNickname: nil, recipientNickname: nil,
senderPeerID: peerID, senderPeerID: peerID,
// Received messages need an explicit status: BitchatMessage // Received messages need an explicit status: BitchatMessage
// defaults private messages to .sending, which the media views // defaults private messages to .sending, which the media views
// render as an in-flight send (empty reveal mask, disabled tap). // render as an in-flight send (empty reveal mask, disabled tap).
deliveryStatus: deliveryPlan.isPrivateMessage deliveryStatus: isPrivate
? .delivered(to: env.localNickname(), at: ts) ? .delivered(to: env.localNickname(), at: timestamp)
: nil : nil
) )
@@ -137,51 +189,38 @@ final class BLEFileTransferHandler {
return true return true
} }
/// Resolves the authenticated display name for a file transfer's sender. /// Every remaining raw file transfer is signed, regardless of whether it
/// /// is broadcast, addressed to us, or merely passing through. Registry
/// Directed (private) transfers are addressed to us specifically and keep /// signing keys are preferred; persisted identities cover peers that have
/// the lenient connected-peer path. Broadcast transfers carry an /// rotated or are not currently present in the registry.
/// attacker-controllable `senderID` exactly like public messages and public private func authenticatedRawSenderNickname(
/// 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(
packet: BitchatPacket, packet: BitchatPacket,
from peerID: PeerID, from peerID: PeerID,
isBroadcast: Bool,
peers: [PeerID: BLEPeerInfo], peers: [PeerID: BLEPeerInfo],
env: BLEFileTransferHandlerEnvironment env: BLEFileTransferHandlerEnvironment
) -> String? { ) -> String? {
guard isBroadcast else { guard packet.signature != 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( return BLEPeerSenderDisplayName.resolveKnownPeer(
peerID: peerID, peerID: peerID,
localPeerID: env.localPeerID(), localPeerID: localPeerID,
localNickname: env.localNickname(), localNickname: env.localNickname(),
peers: peers, peers: peers,
// The packet signature authenticates the announced peer; the old
// connected-but-unsigned leniency is not involved.
allowConnectedUnverified: true allowConnectedUnverified: true
) ?? env.signedSenderDisplayName(packet, peerID) ) ?? signedDisplayName ?? BLEPeerSenderDisplayName.anonymousNickname(for: peerID)
}
// 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 }
return BLEPeerSenderDisplayName.resolveKnownPeer(
peerID: peerID,
localPeerID: env.localPeerID(),
localNickname: env.localNickname(),
peers: peers,
allowConnectedUnverified: false
) ?? signedDisplayName
} }
} }
@@ -201,8 +201,11 @@ struct BLEFragmentAssemblyBuffer {
} }
private static func assemblyLimit(for originalType: UInt8) -> Int { 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. // 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 return FileTransferLimits.maxFramedFileBytes
} }
@@ -5,7 +5,16 @@ import Foundation
struct BLEIncomingFileStore { struct BLEIncomingFileStore {
private static let quotaBytes: Int64 = 100 * 1024 * 1024 private static let quotaBytes: Int64 = 100 * 1024 * 1024
private let fileManager: FileManager /// 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 baseDirectory: URL?
private let dateProvider: () -> Date private let dateProvider: () -> Date
@@ -15,6 +24,14 @@ struct BLEIncomingFileStore {
self.dateProvider = dateProvider self.dateProvider = dateProvider
} }
/// 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( func save(
data: Data, data: Data,
preferredName: String?, preferredName: String?,
@@ -39,6 +56,11 @@ struct BLEIncomingFileStore {
} }
} }
/// 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) { func enforceQuota(reservingBytes: Int) {
do { do {
let base = try filesDirectory() let base = try filesDirectory()
@@ -72,6 +94,7 @@ struct BLEIncomingFileStore {
var freedSpace: Int64 = 0 var freedSpace: Int64 = 0
for file in allFiles.sorted(by: { $0.modified < $1.modified }) { for file in allFiles.sorted(by: { $0.modified < $1.modified }) {
guard freedSpace < needToFree else { break } guard freedSpace < needToFree else { break }
guard !file.url.lastPathComponent.hasPrefix(Self.liveCapturePrefix) else { continue }
do { do {
try fileManager.removeItem(at: file.url) try fileManager.removeItem(at: file.url)
freedSpace += file.size freedSpace += file.size
+24 -2
View File
@@ -164,7 +164,11 @@ final class BLELinkStateStore {
guard let peerID else { return [] } guard let peerID else { return [] }
var links: Set<BLEIngressLinkID> = [] 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)) links.insert(.peripheral(peripheralUUID))
} }
for (centralUUID, mappedPeerID) in centralToPeerID where mappedPeerID == peerID { for (centralUUID, mappedPeerID) in centralToPeerID where mappedPeerID == peerID {
@@ -173,6 +177,13 @@ final class BLELinkStateStore {
return links 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? { func peerID(forPeripheralID peripheralID: String) -> PeerID? {
assertOwned() assertOwned()
return peripherals[peripheralID]?.peerID return peripherals[peripheralID]?.peerID
@@ -221,9 +232,20 @@ final class BLELinkStateStore {
func removePeripheral(_ peripheralID: String) -> PeerID? { func removePeripheral(_ peripheralID: String) -> PeerID? {
assertOwned() assertOwned()
let peerID = peripherals.removeValue(forKey: peripheralID)?.peerID let peerID = peripherals.removeValue(forKey: peripheralID)?.peerID
if let 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) peerToPeripheralUUID.removeValue(forKey: peerID)
} }
}
return peerID return peerID
} }
@@ -2,6 +2,11 @@ import BitFoundation
import BitLogger import BitLogger
import Foundation import Foundation
struct BLENoiseDecryptionResult {
let plaintext: Data
let sessionGeneration: UUID
}
/// Narrow environment for `BLENoisePacketHandler`. /// Narrow environment for `BLENoisePacketHandler`.
/// ///
/// All queue hops (collections barrier writes, main-actor UI notification) /// All queue hops (collections barrier writes, main-actor UI notification)
@@ -27,9 +32,16 @@ struct BLENoisePacketHandlerEnvironment {
/// Updates the registry last-seen timestamp for the peer (async barrier write). /// Updates the registry last-seen timestamp for the peer (async barrier write).
let updatePeerLastSeen: (PeerID) -> Void let updatePeerLastSeen: (PeerID) -> Void
/// Decrypts an encrypted payload from the peer (crypto). /// 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). /// Clears the peer's Noise session after an unrecoverable decrypt failure (crypto).
let clearSession: (PeerID) -> Void 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. /// Delivers `.noisePayloadReceived` to the UI as one main-actor hop.
let deliverNoisePayload: ( let deliverNoisePayload: (
_ peerID: PeerID, _ peerID: PeerID,
@@ -49,7 +61,11 @@ final class BLENoisePacketHandler {
self.environment = environment 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 replacement completion
/// from a rejected candidate while an older session remains established.
@discardableResult
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
let env = environment let env = environment
// Use NoiseEncryptionService for handshake processing // Use NoiseEncryptionService for handshake processing
if PeerID(hexData: packet.recipientID) == env.localPeerID() { if PeerID(hexData: packet.recipientID) == env.localPeerID() {
@@ -72,14 +88,26 @@ final class BLENoisePacketHandler {
// Session establishment will trigger onPeerAuthenticated callback // Session establishment will trigger onPeerAuthenticated callback
// which will send any pending messages at the right time // which will send any pending messages at the right time
return true
} catch NoiseSessionError.peerIdentityMismatch {
// The candidate 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 { } catch {
SecureLogger.error("Failed to process handshake: \(error)") SecureLogger.error("Failed to process handshake: \(error)")
// Try initiating a new handshake // Try initiating a new handshake
if !env.hasNoiseSession(peerID) { if !env.hasNoiseSession(peerID) {
env.initiateHandshake(peerID) env.initiateHandshake(peerID)
} }
return false
} }
} }
return false
} }
func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) { func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
@@ -98,20 +126,30 @@ final class BLENoisePacketHandler {
env.updatePeerLastSeen(peerID) env.updatePeerLastSeen(peerID)
do { 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 } guard decrypted.count > 0 else { return }
// First byte indicates the payload type // First byte indicates the payload type
let payloadType = decrypted[0] let payloadType = decrypted[0]
let payloadData = decrypted.dropFirst() 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)") SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)")
return return
} }
SecureLogger.debug("🔐 Decrypted noise payload type \(noisePayloadType.description) from \(peerID.id.prefix(8))", category: .session) 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) let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
env.deliverNoisePayload(peerID, noisePayloadType, Data(payloadData), ts) env.deliverNoisePayload(peerID, noisePayloadType, Data(payloadData), ts)
} catch NoiseEncryptionError.sessionNotEstablished { } catch NoiseEncryptionError.sessionNotEstablished {
@@ -17,6 +17,16 @@ enum BLENoisePayloadFactory {
typedPayload(.delivered, payload: Data(messageID.utf8)) 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 { static func typedPayload(_ type: NoisePayloadType, payload: Data) -> Data {
var typed = Data([type.rawValue]) var typed = Data([type.rawValue])
typed.append(payload) typed.append(payload)
@@ -6,9 +6,16 @@ struct BLEPendingPrivateMessage: Equatable {
let messageID: String 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 { struct BLENoiseSessionQueues {
private var privateMessagesByPeerID: [PeerID: [BLEPendingPrivateMessage]] = [:] private var privateMessagesByPeerID: [PeerID: [BLEPendingPrivateMessage]] = [:]
private var typedPayloadsByPeerID: [PeerID: [Data]] = [:] private var typedPayloadsByPeerID: [PeerID: [BLEPendingTypedPayload]] = [:]
var isEmpty: Bool { var isEmpty: Bool {
privateMessagesByPeerID.isEmpty && typedPayloadsByPeerID.isEmpty privateMessagesByPeerID.isEmpty && typedPayloadsByPeerID.isEmpty
@@ -34,13 +41,35 @@ struct BLENoiseSessionQueues {
privateMessagesByPeerID[peerID, default: []].insert(contentsOf: messages, at: 0) privateMessagesByPeerID[peerID, default: []].insert(contentsOf: messages, at: 0)
} }
mutating func appendTypedPayload(_ payload: Data, for peerID: PeerID) { mutating func appendTypedPayload(_ payload: Data, transferId: String? = nil, for peerID: PeerID) {
typedPayloadsByPeerID[peerID, default: []].append(payload) 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] ?? [] let payloads = typedPayloadsByPeerID[peerID] ?? []
typedPayloadsByPeerID.removeValue(forKey: peerID) typedPayloadsByPeerID.removeValue(forKey: peerID)
return payloads 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 { 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 minimumChunkSize = 64
private static let fragmentIDLength = 8 private static let fragmentIDLength = 8
@@ -71,6 +74,10 @@ enum BLEOutboundFragmentPlanner {
) )
} }
static func isPrivateMediaV1Compatible(_ plan: BLEOutboundFragmentPlan) -> Bool {
plan.totalFragments <= privateMediaV1MaxFragments
}
private static func sizingPolicy( private static func sizingPolicy(
for packet: BitchatPacket, for packet: BitchatPacket,
requestedMaxChunk: Int?, requestedMaxChunk: Int?,
@@ -7,10 +7,54 @@ struct BLEOutboundFragmentTransferRequest {
let maxChunk: Int? let maxChunk: Int?
let directedPeer: PeerID? let directedPeer: PeerID?
let transferId: String? 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? { var resolvedTransferId: String? {
if let transferId { return transferId }
guard packet.type == MessageType.fileTransfer.rawValue else { return nil } 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 { enum SubmitResult {
case start(request: BLEOutboundFragmentTransferRequest, reservedTransferId: String?) case start(request: BLEOutboundFragmentTransferRequest, reservedTransferId: String?)
case queued(request: BLEOutboundFragmentTransferRequest, transferId: String?, position: QueuePosition) 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 { enum CancelResult {
@@ -38,14 +92,34 @@ struct BLEOutboundFragmentTransferScheduler {
} }
private struct ActiveTransferState { private struct ActiveTransferState {
let totalFragments: Int var totalFragments: Int
var sentFragments: Int var sentFragments: Int
var workItems: [DispatchWorkItem] var workItems: [DispatchWorkItem]
var contentKey: String?
var directedPeer: PeerID?
} }
private var activeTransfers: [String: ActiveTransferState] = [:] private var activeTransfers: [String: ActiveTransferState] = [:]
private var pendingTransfers: [BLEOutboundFragmentTransferRequest] = [] 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 { var activeCount: Int {
activeTransfers.count activeTransfers.count
} }
@@ -69,17 +143,39 @@ struct BLEOutboundFragmentTransferScheduler {
return .start(request: request, reservedTransferId: nil) 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 { guard activeTransfers.count < maxConcurrentTransfers else {
if request.requireDirectPeerLink {
return .rejectedStrict(request: request, transferId: transferId)
}
pendingTransfers.append(request) pendingTransfers.append(request)
return .queued(request: request, transferId: transferId, position: .back) return .queued(request: request, transferId: transferId, position: .back)
} }
guard activeTransfers[transferId] == nil else { guard activeTransfers[transferId] == nil else {
if request.requireDirectPeerLink {
return .rejectedStrict(request: request, transferId: transferId)
}
pendingTransfers.insert(request, at: 0) pendingTransfers.insert(request, at: 0)
return .queued(request: request, transferId: transferId, position: .front) 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) return .start(request: request, reservedTransferId: transferId)
} }
@@ -88,12 +184,11 @@ struct BLEOutboundFragmentTransferScheduler {
totalFragments: Int, totalFragments: Int,
workItems: [DispatchWorkItem] workItems: [DispatchWorkItem]
) -> Bool { ) -> Bool {
guard activeTransfers[transferId] != nil else { return false } guard var state = activeTransfers[transferId] else { return false }
activeTransfers[transferId] = ActiveTransferState( state.totalFragments = totalFragments
totalFragments: totalFragments, state.sentFragments = 0
sentFragments: 0, state.workItems = workItems
workItems: workItems activeTransfers[transferId] = state
)
return true return true
} }
@@ -149,13 +244,25 @@ struct BLEOutboundFragmentTransferScheduler {
while availableSlots > 0, !pendingTransfers.isEmpty { while availableSlots > 0, !pendingTransfers.isEmpty {
let request = pendingTransfers.removeFirst() let request = pendingTransfers.removeFirst()
availableSlots -= 1
guard let transferId = request.resolvedTransferId else { guard let transferId = request.resolvedTransferId else {
availableSlots -= 1
results.append(.start(request: request, reservedTransferId: nil)) results.append(.start(request: request, reservedTransferId: nil))
continue 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 { guard activeTransfers.count < maxConcurrentTransfers else {
pendingTransfers.insert(request, at: 0) pendingTransfers.insert(request, at: 0)
results.append(.queued(request: request, transferId: transferId, position: .front)) results.append(.queued(request: request, transferId: transferId, position: .front))
@@ -168,7 +275,13 @@ struct BLEOutboundFragmentTransferScheduler {
continue 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)) results.append(.start(request: request, reservedTransferId: transferId))
} }
@@ -20,22 +20,15 @@ enum BLEOutboundLinkPlanner {
excludedLinks: Set<BLEIngressLinkID>, excludedLinks: Set<BLEIngressLinkID>,
peripheralPeerBindings: [String: PeerID] = [:], peripheralPeerBindings: [String: PeerID] = [:],
centralPeerBindings: [String: PeerID] = [:], centralPeerBindings: [String: PeerID] = [:],
directedOnlyPeer: PeerID? preferredPeripheralPerPeer: [PeerID: String] = [:],
directAnnounceTTL: UInt8 = TransportConfig.messageTTLDefault,
directedOnlyPeer: PeerID?,
requireDirectPeerLink: Bool = false
) -> BLEOutboundLinkPlan { ) -> 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) 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( let selectedLinks = BLEFanoutSelector.selectLinks(
peripheralIDs: peripheralIDs, peripheralIDs: peripheralIDs,
centralIDs: centralIDs, centralIDs: centralIDs,
@@ -43,11 +36,37 @@ enum BLEOutboundLinkPlanner {
excludedLinks: excludedLinks, excludedLinks: excludedLinks,
peripheralPeerBindings: peripheralPeerBindings, peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings, centralPeerBindings: centralPeerBindings,
preferredPeripheralPerPeer: preferredPeripheralPerPeer,
collapseDuplicatePeerLinks: !isDirectAnnounce,
directedPeerHint: directedPeerHint, directedPeerHint: directedPeerHint,
requireDirectPeerLink: requireDirectPeerLink,
packetType: packet.type, packetType: packet.type,
messageID: BLEOutboundPacketPolicy.messageID(for: packet) 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( return BLEOutboundLinkPlan(
directedPeerHint: directedPeerHint, directedPeerHint: directedPeerHint,
fragmentChunkSize: nil, fragmentChunkSize: nil,
@@ -44,4 +44,16 @@ struct BLEOutboundNotificationBuffer<Target> {
guard !pending.isEmpty else { return } guard !pending.isEmpty else { return }
notifications.insert(contentsOf: pending, at: 0) 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, priority: BLEOutboundWritePriority,
capBytes: Int capBytes: Int
) -> EnqueueResult { ) -> 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 { guard data.count <= capBytes else {
return .oversized(bytes: data.count) return (.oversized(bytes: data.count), false)
} }
var queue = writesByPeripheralID[peripheralID] ?? [] var queue = writesByPeripheralID[peripheralID] ?? []
@@ -65,7 +82,8 @@ struct BLEOutboundWriteBuffer {
} }
writesByPeripheralID[peripheralID] = queue.isEmpty ? nil : queue 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] { mutating func takeAll(for peripheralID: String) -> [BLEPendingWrite] {
@@ -74,6 +92,13 @@ struct BLEOutboundWriteBuffer {
return items 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) { mutating func prepend(_ items: [BLEPendingWrite], for peripheralID: String) {
guard !items.isEmpty else { return } guard !items.isEmpty else { return }
var existing = writesByPeripheralID[peripheralID] ?? [] var existing = writesByPeripheralID[peripheralID] ?? []
+28 -2
View File
@@ -10,6 +10,9 @@ struct BLEPeerInfo: Equatable {
var isVerifiedNickname: Bool var isVerifiedNickname: Bool
var lastSeen: Date var lastSeen: Date
var capabilities: PeerCapabilities = [] 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`. /// Rendezvous cell from the peer's announce when it advertises `.bridge`.
var bridgeGeohash: String? var bridgeGeohash: String?
} }
@@ -114,6 +117,10 @@ struct BLEPeerRegistry {
peers[peerID.toShort()]?.capabilities ?? [] peers[peerID.toShort()]?.capabilities ?? []
} }
func capabilitiesWereExplicitlyAdvertised(for peerID: PeerID) -> Bool {
peers[peerID.toShort()]?.capabilitiesWereExplicitlyAdvertised == true
}
/// Peers whose last verified announce advertised the given capability. /// Peers whose last verified announce advertised the given capability.
func peers(advertising capability: PeerCapabilities) -> [PeerID] { func peers(advertising capability: PeerCapabilities) -> [PeerID] {
peers.values.filter { $0.capabilities.contains(capability) }.map(\.peerID) peers.values.filter { $0.capabilities.contains(capability) }.map(\.peerID)
@@ -158,12 +165,30 @@ struct BLEPeerRegistry {
peers[peerID] = info 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) { mutating func updateLastSeen(_ peerID: PeerID, at date: Date) {
guard var peer = peers[peerID] else { return } guard var peer = peers[peerID] else { return }
peer.lastSeen = date peer.lastSeen = date
peers[peerID] = peer 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( mutating func upsertVerifiedAnnounce(
peerID: PeerID, peerID: PeerID,
nickname: String, nickname: String,
@@ -171,7 +196,7 @@ struct BLEPeerRegistry {
signingPublicKey: Data?, signingPublicKey: Data?,
isConnected: Bool, isConnected: Bool,
now: Date, now: Date,
capabilities: PeerCapabilities = [], capabilities: PeerCapabilities? = nil,
bridgeGeohash: String? = nil bridgeGeohash: String? = nil
) -> BLEPeerAnnounceUpdate { ) -> BLEPeerAnnounceUpdate {
let existing = peers[peerID] let existing = peers[peerID]
@@ -189,7 +214,8 @@ struct BLEPeerRegistry {
signingPublicKey: signingPublicKey, signingPublicKey: signingPublicKey,
isVerifiedNickname: true, isVerifiedNickname: true,
lastSeen: now, lastSeen: now,
capabilities: capabilities, capabilities: capabilities ?? [],
capabilitiesWereExplicitlyAdvertised: capabilities != nil,
bridgeGeohash: bridgeGeohash bridgeGeohash: bridgeGeohash
) )
@@ -122,10 +122,18 @@ final class BLEPublicMessageHandler {
SecureLogger.debug("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)) chars=\(content.count) bytes=\(packet.payload.count)", category: .session) SecureLogger.debug("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)) chars=\(content.count) bytes=\(packet.payload.count)", category: .session)
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
var resolvedSelfMessageID: String? = nil let messageID: String?
if peerID == env.localPeerID() { 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 { LocationNotesManager.postDrop(content: content, nickname: nickname, geohash: geohash) else {
return .error(message: "no geo relays reachable — note not left") 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") return .success(message: "📍 note left here — it fades in 24h")
} }
@@ -365,6 +369,9 @@ final class CommandProcessor {
) )
identityManager.updateSocialIdentity(blockedIdentity) 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") return .success(message: "blocked \(nickname). you will no longer receive messages from them")
} }
// Mesh lookup failed; try geohash (Nostr) participant by display name // Mesh lookup failed; try geohash (Nostr) participant by display name
+290 -40
View File
@@ -10,6 +10,9 @@ import BitFoundation
import BitLogger import BitLogger
import Combine import Combine
import Foundation import Foundation
#if os(iOS)
import UIKit
#endif
/// Trust level of a courier deposit, decided by the caller's policy. /// 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 /// 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 queue = DispatchQueue(label: "chat.bitchat.courier.store")
private let fileURL: URL? private let fileURL: URL?
private let now: () -> Date 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 /// - Parameter fileURL: Overrides the on-disk location (tests). Ignored
/// when `persistsToDisk` is false. /// 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.now = now
self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
self.readData = readData
loadFromDisk() 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) // MARK: - Depositing (courier side)
@@ -154,10 +189,16 @@ final class CourierStore {
return queue.sync { return queue.sync {
pruneExpiredLocked(at: date) pruneExpiredLocked(at: date)
// Identical ciphertext is the same envelope; accept idempotently, // Identical ciphertext is the same envelope. Before any spray,
// keeping the larger spray budget (bounded by maxCopies either way). // 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 }) { if let existing = envelopes.firstIndex(where: { $0.ciphertext == envelope.ciphertext }) {
if envelopes[existing].sprayedTo.isEmpty {
envelopes[existing].copies = max(envelopes[existing].copies, envelope.copies) envelopes[existing].copies = max(envelopes[existing].copies, envelope.copies)
}
persistLocked() persistLocked()
return true return true
} }
@@ -207,20 +248,52 @@ final class CourierStore {
// MARK: - Handover (on encountering a peer) // MARK: - Handover (on encountering a peer)
/// Remove and return all envelopes addressed to the given peer, matching /// Remove and return all envelopes addressed to the given peer, matching
/// the rotating recipient tag across adjacent days. Envelopes are removed /// the rotating recipient tag across adjacent days. This compatibility
/// optimistically: handover happens over a live link, and the depositor's /// helper accepts every offer; transport callers should use
/// outbox still retains the original for direct delivery. /// `handoverEnvelopes(for:accepting:)` so failed sends remain durable.
func takeEnvelopes(for noiseStaticKey: Data) -> [CourierEnvelope] { 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 date = now()
let candidates = CourierEnvelope.candidateTags(noiseStaticKey: noiseStaticKey, around: date) let candidates = CourierEnvelope.candidateTags(noiseStaticKey: noiseStaticKey, around: date)
return queue.sync { let offered = queue.sync {
pruneExpiredLocked(at: date) pruneExpiredLocked(at: date)
let matched = envelopes.filter { candidates.contains($0.recipientTag) } return envelopes
guard !matched.isEmpty else { return [] } .filter { candidates.contains($0.recipientTag) }
envelopes.removeAll { stored in matched.contains(stored) } .map(\.envelope)
persistLocked()
return matched.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* /// 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 /// Envelopes eligible to park on relays as bridge courier drops. Merely
/// like remote handover the relay copy is speculative, so the carried /// offering one does not start its cooldown: the caller commits that only
/// copy stays until direct handover or expiry. The per-envelope cooldown /// after a relay explicitly accepts the event via NIP-20 OK.
/// keeps relay churn from republishing the same mail; publishing relays
/// dedup identical events by ID anyway.
func envelopesForBridgePublish(cooldown: TimeInterval) -> [CourierEnvelope] { func envelopesForBridgePublish(cooldown: TimeInterval) -> [CourierEnvelope] {
let date = now() let date = now()
return queue.sync { return queue.sync {
pruneExpiredLocked(at: date) pruneExpiredLocked(at: date)
var matched: [CourierEnvelope] = [] return envelopes.compactMap { stored in
for index in envelopes.indices { if let last = stored.lastBridgePublishAt,
if let last = envelopes[index].lastBridgePublishAt,
date.timeIntervalSince(last) < cooldown { date.timeIntervalSince(last) < cooldown {
continue return nil
}
// The relay copy carries no spray budget.
return stored.envelope.withCopies(1)
}
}
}
/// 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 envelopes[index].lastBridgePublishAt = date
// The relay copy carries no spray budget. persistLocked()
matched.append(envelopes[index].envelope.withCopies(1))
}
if !matched.isEmpty { persistLocked() }
return matched
} }
} }
@@ -278,25 +357,60 @@ final class CourierStore {
/// deposited, envelopes addressed to them (those ride the handover path), /// deposited, envelopes addressed to them (those ride the handover path),
/// carry-only envelopes, and couriers already sprayed. /// carry-only envelopes, and couriers already sprayed.
func takeSprayCopies(for courierNoiseKey: Data) -> [CourierEnvelope] { 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 date = now()
let courierTags = CourierEnvelope.candidateTags(noiseStaticKey: courierNoiseKey, around: date) let courierTags = CourierEnvelope.candidateTags(noiseStaticKey: courierNoiseKey, around: date)
return queue.sync { let offered = queue.sync {
pruneExpiredLocked(at: date) pruneExpiredLocked(at: date)
var sprayed: [CourierEnvelope] = [] return envelopes.compactMap { stored -> CourierEnvelope? in
for index in envelopes.indices {
let stored = envelopes[index]
guard stored.copies > 1, guard stored.copies > 1,
stored.depositorNoiseKey != courierNoiseKey, stored.depositorNoiseKey != courierNoiseKey,
!stored.sprayedTo.contains(courierNoiseKey), !stored.sprayedTo.contains(courierNoiseKey),
!courierTags.contains(stored.recipientTag) else { continue } !courierTags.contains(stored.recipientTag) else { return nil }
let given = stored.copies / 2 return stored.envelope.withCopies(stored.copies / 2)
envelopes[index].copies = stored.copies - given }
}
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) envelopes[index].sprayedTo.insert(courierNoiseKey)
sprayed.append(stored.envelope.withCopies(given)) persistLocked()
return true
} }
if !sprayed.isEmpty { persistLocked() } if committed { acceptedCount += 1 }
return sprayed
} }
return acceptedCount
} }
// MARK: - Maintenance // MARK: - Maintenance
@@ -305,6 +419,7 @@ final class CourierStore {
func wipe() { func wipe() {
queue.sync { queue.sync {
envelopes.removeAll() envelopes.removeAll()
diskLoadDeferred = false
if let fileURL { if let fileURL {
try? FileManager.default.removeItem(at: 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`) // MARK: - Internals (call only on `queue`)
private func pruneExpiredLocked(at date: Date) { private func pruneExpiredLocked(at date: Date) {
@@ -332,6 +457,12 @@ final class CourierStore {
private func persistLocked() { private func persistLocked() {
publishCountLocked() publishCountLocked()
guard let fileURL else { return } 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 { do {
if envelopes.isEmpty { if envelopes.isEmpty {
try? FileManager.default.removeItem(at: fileURL) try? FileManager.default.removeItem(at: fileURL)
@@ -344,7 +475,7 @@ final class CourierStore {
let data = try JSONEncoder().encode(envelopes) let data = try JSONEncoder().encode(envelopes)
var options: Data.WritingOptions = [.atomic] var options: Data.WritingOptions = [.atomic]
#if os(iOS) #if os(iOS)
options.insert(.completeFileProtection) options.insert(.completeFileProtectionUntilFirstUserAuthentication)
#endif #endif
try data.write(to: fileURL, options: options) try data.write(to: fileURL, options: options)
} catch { } catch {
@@ -355,14 +486,133 @@ final class CourierStore {
private func loadFromDisk() { private func loadFromDisk() {
guard let fileURL else { return } guard let fileURL else { return }
queue.sync { queue.sync {
guard let data = try? Data(contentsOf: fileURL), guard FileManager.default.fileExists(atPath: fileURL.path) else {
let stored = try? JSONDecoder().decode([StoredEnvelope].self, from: data) else { diskLoadDeferred = false
return return
} }
envelopes = stored do {
let data = try readData(fileURL)
do {
envelopes = try JSONDecoder().decode([StoredEnvelope].self, from: data)
diskLoadDeferred = false
pruneExpiredLocked(at: now()) pruneExpiredLocked(at: now())
publishCountLocked() 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? { private static func defaultFileURL() -> URL? {
+729 -39
View File
@@ -11,6 +11,9 @@ import BitLogger
import CryptoKit import CryptoKit
import Foundation import Foundation
import Security import Security
#if os(iOS)
import UIKit
#endif
/// Disk persistence for the MessageRouter outbox, so private messages queued /// Disk persistence for the MessageRouter outbox, so private messages queued
/// for an offline peer survive an app kill instead of silently evaporating. /// 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 keychainService = "chat.bitchat.outbox"
private static let keychainKey = "outbox-encryption-key" 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 fileURL: URL?
private let keychain: KeychainManagerProtocol 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.keychain = keychain
self.fileURL = fileURL ?? Self.defaultFileURL() 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) // MARK: - API (call from the router's actor; IO is small and atomic)
func load() -> [PeerID: [QueuedMessage]] { func load() -> Snapshot {
guard let fileURL, lock.lock()
let sealed = try? Data(contentsOf: fileURL), defer { lock.unlock() }
let key = encryptionKey(createIfMissing: false),
let box = try? ChaChaPoly.SealedBox(combined: sealed), if case .loaded = diskState {
let plaintext = try? ChaChaPoly.open(box, using: key), return cachedSnapshot
let decoded = try? JSONDecoder().decode([String: [QueuedMessage]].self, from: plaintext) else {
return [:]
} }
var outbox: [PeerID: [QueuedMessage]] = [:] // Once a deferred recovery has classified the durable baseline,
for (peerID, queue) in decoded where !queue.isEmpty { // `cachedSnapshot` is the only safe synchronous view. Re-reading via
outbox[PeerID(str: peerID)] = queue // the generic load path would confuse its durable+router union with a
} // fully router-known authoritative snapshot.
return outbox if unseenRecoveryPendingPersistence || recoveryDeliveryPending {
return cachedSnapshot
} }
func save(_ outbox: [PeerID: [QueuedMessage]]) { let wasDeferred = diskState == .deferred
guard let fileURL else { return } switch readSnapshotLocked() {
let flattened = outbox.filter { !$0.value.isEmpty } case .loaded(let durable):
guard !flattened.isEmpty else { cachedSnapshot = applyingPendingRemovalsLocked(pendingSnapshotIsAuthoritative
try? FileManager.default.removeItem(at: fileURL) ? (pendingSnapshot ?? [:])
return : 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
} }
guard let key = encryptionKey(createIfMissing: true) else {
SecureLogger.error("Outbox not persisted: no encryption key available", category: .session)
return
} }
do { // The recovery callback is driven by `retryDeferredLoad`; `load`
let keyed = Dictionary(uniqueKeysWithValues: flattened.map { ($0.key.id, $0.value) }) // itself returns the recovered value synchronously to its caller.
let plaintext = try JSONEncoder().encode(keyed) return cachedSnapshot
let sealed = try ChaChaPoly.seal(plaintext, using: key).combined
try FileManager.default.createDirectory( case .missing:
at: fileURL.deletingLastPathComponent(), cachedSnapshot = applyingPendingRemovalsLocked(pendingSnapshot ?? [:])
withIntermediateDirectories: true 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
}
}
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
}
switch diskState {
case .loaded:
cachedSnapshot = applyingPendingRemovalsLocked(
recoveryStateWasPending
? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot)
: flattened
) )
var options: Data.WritingOptions = [.atomic] if !persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
#if os(iOS) // Retain the latest complete router snapshot. Because its
options.insert(.completeFileProtection) // durable baseline was already loaded, it replaces the older
#endif // disk file after protected data returns (preserving removals).
try sealed.write(to: fileURL, options: options) pendingSnapshot = cachedSnapshot
} catch { pendingSnapshotIsAuthoritative = true
SecureLogger.error("Failed to persist outbox: \(error)", category: .session) 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. /// Panic wipe: drop the queued mail and the key that could ever read it.
func wipe() { 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 { if let fileURL {
try? FileManager.default.removeItem(at: fileURL) try? FileManager.default.removeItem(at: fileURL)
} }
keychain.delete(key: Self.keychainKey, service: Self.keychainService) keychain.delete(key: Self.keychainKey, service: Self.keychainService)
lock.unlock()
} }
// MARK: - Internals // MARK: - Internals
private func encryptionKey(createIfMissing: Bool) -> SymmetricKey? { private func encryptionKey(createIfMissing: Bool) -> SymmetricKey? {
if let data = keychain.load(key: Self.keychainKey, service: Self.keychainService), data.count == 32 { switch readEncryptionKey() {
return SymmetricKey(data: data) 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 } guard createIfMissing else { return nil }
let key = SymmetricKey(size: .bits256) let key = SymmetricKey(size: .bits256)
let data = key.withUnsafeBytes { Data($0) } let data = key.withUnsafeBytes { Data($0) }
// After-first-unlock so queued mail can flush from background BLE wakes. // After-first-unlock so queued mail can flush from background BLE wakes.
@@ -139,9 +616,222 @@ final class MessageOutboxStore {
service: Self.keychainService, service: Self.keychainService,
accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly 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 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? { private static func defaultFileURL() -> URL? {
guard let base = try? FileManager.default.url( guard let base = try? FileManager.default.url(
for: .applicationSupportDirectory, for: .applicationSupportDirectory,
@@ -9,7 +9,11 @@
import BitFoundation import BitFoundation
import BitLogger import BitLogger
import Foundation 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 /// Courier delivery over the internet bridge: sealed courier envelopes are
/// parked on relays as kind-1401 "drops" tagged with their rotating /// 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). /// Encoded envelope cap for a drop (16 KiB ciphertext + TLV slack).
static let maxDropEnvelopeBytes = 20 * 1024 static let maxDropEnvelopeBytes = 20 * 1024
static let maxTrackedIDs = 512 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() static let shared = BridgeCourierService()
@@ -57,8 +65,11 @@ final class BridgeCourierService: ObservableObject {
var bridgeEnabled: (@MainActor () -> Bool)? var bridgeEnabled: (@MainActor () -> Bool)?
var relaysConnected: (@MainActor () -> Bool)? var relaysConnected: (@MainActor () -> Bool)?
/// Publishes a signed drop event to the default (DM) relays. /// Publishes a signed drop event directly to connected default (DM)
var publishEvent: (@MainActor (NostrEvent) -> Void)? /// relays. Completion is true only after at least one relay explicitly
/// accepts the event via NIP-20 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. /// (Re)opens the drop subscription for the given hex tags.
var openSubscription: (@MainActor ([String]) -> Void)? var openSubscription: (@MainActor ([String]) -> Void)?
var closeSubscription: (@MainActor () -> Void)? var closeSubscription: (@MainActor () -> Void)?
@@ -68,12 +79,21 @@ final class BridgeCourierService: ObservableObject {
var localVerifiedPeers: (@MainActor () -> [(peerID: PeerID, noiseKey: Data)])? var localVerifiedPeers: (@MainActor () -> [(peerID: PeerID, noiseKey: Data)])?
/// Seals content into a carry-only envelope for a recipient key. /// Seals content into a carry-only envelope for a recipient key.
var sealEnvelope: (@MainActor (String, String, Data) -> CourierEnvelope?)? var sealEnvelope: (@MainActor (String, String, Data) -> CourierEnvelope?)?
/// Opens a drop addressed to us (tag verified inside). /// Opens a drop addressed to us. True means the inner envelope was
var openEnvelope: (@MainActor (CourierEnvelope) -> Void)? /// 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. /// 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. /// Held envelopes eligible for (re)publish, honoring the cooldown.
var heldEnvelopes: (@MainActor (TimeInterval) -> [CourierEnvelope])? 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`. /// Timer injection for tests; nil arms a real `Task`.
var scheduleTimer: (@MainActor (TimeInterval, @escaping @MainActor () -> Void) -> Void)? 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 myTagsHex: Set<String> = []
private(set) var watchedPeerTags: [(peerID: PeerID, tagsHex: Set<String>)] = [] private(set) var watchedPeerTags: [(peerID: PeerID, tagsHex: Set<String>)] = []
private(set) var pendingDrops: [(envelope: CourierEnvelope, dedupKey: String?)] = [] private(set) var pendingDrops: [(
/// Message IDs already published as drops (sender-side dedup). envelope: CourierEnvelope,
private var publishedDropKeys: BoundedIDSet dedupKey: String?,
/// Drop event IDs already handled (multi-relay dedup). operationID: UUID?
private var seenDropEventIDs: BoundedIDSet )] = []
/// 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 subscriptionOpen = false
private var lastSubscribedTags: Set<String> = [] private var lastSubscribedTags: Set<String> = []
private var refreshTimerArmed = false private var refreshTimerArmed = false
private var announceRefreshTimerArmed = false
private var lastAnnounceRefresh = Date.distantPast 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 NIP-20 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 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.now = now
self.publishedDropKeys = BoundedIDSet(capacity: Limits.maxTrackedIDs) self.dedupStore = dedupStore ?? BridgeDropDedupStore(persistsToDisk: !TestEnvironment.isRunningTests)
self.seenDropEventIDs = BoundedIDSet(capacity: Limits.maxTrackedIDs) 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 // MARK: - Sender role
/// Parallel-deposit a sealed copy of an outbound private message as a /// Parallel-deposit a sealed copy of an outbound private message as a
/// relay drop. Called by the message router alongside physical courier /// relay drop. Called by the message router alongside physical courier
/// deposits; idempotent per message ID. Returns true when a fresh drop /// deposits; idempotent per message ID. Completion becomes true only
/// was sealed (published now or queued for the next relay connection) /// after a real relay acceptance arrives, which is when the router may
/// the router marks the message "carried" so the sender sees progress. /// show "carried".
@discardableResult func depositDrop(
func depositDrop(content: String, messageID: String, recipientNoiseKey: Data) -> Bool { content: String,
guard bridgeEnabled?() ?? false else { return false } messageID: String,
guard !publishedDropKeys.contains(messageID) else { return false } recipientNoiseKey: Data,
guard let envelope = sealEnvelope?(content, messageID, recipientNoiseKey) else { return false } completion: @escaping @MainActor (Bool) -> Void = { _ in }
publishedDropKeys.insert(messageID) ) {
publishDrop(envelope) guard bridgeEnabled?() ?? false else {
return true 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, /// Publishes held envelopes (mail we carry for others) as drops,
@@ -121,22 +258,61 @@ final class BridgeCourierService: ObservableObject {
func publishHeldEnvelopes() { func publishHeldEnvelopes() {
guard bridgeEnabled?() ?? false, relaysConnected?() ?? false else { return } guard bridgeEnabled?() ?? false, relaysConnected?() ?? false else { return }
for envelope in heldEnvelopes?(Limits.heldEnvelopePublishCooldown) ?? [] { 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-20 relay acceptance.
private func publishDrop(
_ envelope: CourierEnvelope,
messageID: String? = nil,
operationID: UUID? = nil,
untrackedCompletion: (@MainActor (Bool) -> Void)? = nil
) {
guard let encoded = envelope.encode(), guard let encoded = envelope.encode(),
encoded.count <= Limits.maxDropEnvelopeBytes, 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 { guard relaysConnected?() ?? false else {
pendingDrops.append((envelope, nil)) // Held mail remains in CourierStore and has no sender operation to
if pendingDrops.count > Limits.maxPendingDrops { // recover after an in-memory queue loss. Leave its cooldown unset
pendingDrops.removeFirst(pendingDrops.count - Limits.maxPendingDrops) // 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 return
} }
guard let identity = Self.makeThrowawayIdentity(), guard let identity = try? NostrIdentity.generate(),
let event = try? NostrProtocol.createCourierDropEvent( let event = try? NostrProtocol.createCourierDropEvent(
envelope: encoded, envelope: encoded,
recipientTagHex: envelope.recipientTag.hexEncodedString(), recipientTagHex: envelope.recipientTag.hexEncodedString(),
@@ -144,10 +320,63 @@ final class BridgeCourierService: ObservableObject {
senderIdentity: identity senderIdentity: identity
) else { ) else {
SecureLogger.error("📦🌉 Failed to compose courier drop", category: .encryption) SecureLogger.error("📦🌉 Failed to compose courier drop", category: .encryption)
finishPublish(
messageID: messageID,
operationID: operationID,
succeeded: false,
untrackedCompletion: untrackedCompletion
)
return return
} }
publishEvent?(event) 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) 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. /// Drops queued while relays were unreachable publish on reconnect.
@@ -156,7 +385,11 @@ final class BridgeCourierService: ObservableObject {
let queued = pendingDrops let queued = pendingDrops
pendingDrops.removeAll() pendingDrops.removeAll()
for item in queued { 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. /// (tags rotate daily); idempotent.
func refresh() { func refresh() {
armRefreshTimerIfNeeded() armRefreshTimerIfNeeded()
guard bridgeEnabled?() ?? false, relaysConnected?() ?? false else { guard bridgeEnabled?() ?? false else {
cancelActivePublishes()
if subscriptionOpen {
closeSubscription?()
subscriptionOpen = false
}
return
}
guard relaysConnected?() ?? false else {
if subscriptionOpen { if subscriptionOpen {
closeSubscription?() closeSubscription?()
subscriptionOpen = false subscriptionOpen = false
@@ -209,11 +450,37 @@ final class BridgeCourierService: ObservableObject {
/// Announce-driven refresh, debounced a newly verified peer should be /// Announce-driven refresh, debounced a newly verified peer should be
/// watched promptly, but announce storms must not thrash subscriptions. /// 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() { func refreshAfterVerifiedAnnounce() {
guard bridgeEnabled?() ?? false else { return } guard bridgeEnabled?() ?? false else { return }
guard now().timeIntervalSince(lastAnnounceRefresh) >= Limits.announceRefreshDebounceSeconds else { return } let date = now()
lastAnnounceRefresh = now() let elapsed = date.timeIntervalSince(lastAnnounceRefresh)
if elapsed >= Limits.announceRefreshDebounceSeconds {
lastAnnounceRefresh = date
refresh() 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() { private func armRefreshTimerIfNeeded() {
@@ -241,7 +508,10 @@ final class BridgeCourierService: ObservableObject {
func handleDropEvent(_ event: NostrEvent) { func handleDropEvent(_ event: NostrEvent) {
guard bridgeEnabled?() ?? false else { return } guard bridgeEnabled?() ?? false else { return }
guard event.kind == NostrProtocol.EventKind.courierDrop.rawValue 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), guard let data = Data(base64Encoded: event.content),
data.count <= Limits.maxDropEnvelopeBytes, data.count <= Limits.maxDropEnvelopeBytes,
let envelope = CourierEnvelope.decode(data), let envelope = CourierEnvelope.decode(data),
@@ -256,29 +526,40 @@ final class BridgeCourierService: ObservableObject {
if myTagsHex.contains(tagHex) { if myTagsHex.contains(tagHex) {
SecureLogger.info("📦🌉 Courier drop for us arrived via bridge", category: .session) 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 return
} }
if let match = watchedPeerTags.first(where: { $0.tagsHex.contains(tagHex) }) { 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) 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 // 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? { static func makeThrowawayIdentity() -> NostrIdentity? {
for _ in 0..<10 { try? NostrIdentity.generate()
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
} }
} }
@@ -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 /// already holds the radio copy (`isMessageSeenLocally` on the event's
/// mesh message ID) remote islands' traffic is the only thing worth /// mesh message ID) remote islands' traffic is the only thing worth
/// airtime. /// airtime.
/// Receivers additionally dedup by timeline message ID: a bridged copy /// Receivers key bridge rows by the signed Nostr event ID. The event's `m` tag
/// carries the original mesh message ID in its `m` tag, so the store's /// is only a radio-copy hint: its mesh sender/timestamp fields are public and
/// insert-by-ID absorbs radio/bridge duplicates in either arrival order. /// 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 /// All dependencies are closure-injected (repo convention) so the policy
/// layer is unit-testable without relays, radios, or CoreLocation. /// layer is unit-testable without relays, radios, or CoreLocation.
@@ -69,11 +75,25 @@ final class BridgeService: ObservableObject {
static let maxEventAgeSeconds: TimeInterval = 15 * 60 static let maxEventAgeSeconds: TimeInterval = 15 * 60
/// Bounded loop-prevention ID caches (oldest evicted). /// Bounded loop-prevention ID caches (oldest evicted).
static let maxTrackedEventIDs = 512 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. /// Presence heartbeat cadence while the bridge is active.
static let presenceIntervalSeconds: TimeInterval = 4 * 60 static let presenceIntervalSeconds: TimeInterval = 4 * 60
/// A rendezvous participant counts toward "via bridge" for this long /// A rendezvous participant counts toward "via bridge" for this long
/// after their last event. /// after their last event.
static let participantFreshnessSeconds: TimeInterval = 10 * 60 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. /// Content cap, matching the public-message pipeline's own limit.
static let maxContentBytes = 16_000 static let maxContentBytes = 16_000
/// Geohash-cell precision of the rendezvous (neighborhood, ~1.2 km). /// 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. /// A validated rendezvous message ready for the timeline.
struct InboundBridgeMessage { struct InboundBridgeMessage {
let messageID: String 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 senderNickname: String
let participantNickname: String?
let senderPubkey: String let senderPubkey: String
let content: String let content: String
let timestamp: Date let timestamp: Date
@@ -114,10 +138,9 @@ final class BridgeService: ObservableObject {
/// The user toggle. While true this device publishes its own public mesh /// The user toggle. While true this device publishes its own public mesh
/// messages to the rendezvous and subscribes to it when online. /// messages to the rendezvous and subscribes to it when online.
@Published private(set) var isEnabled: Bool @Published private(set) var isEnabled: Bool
/// Distinct remote rendezvous participants seen within the freshness /// Distinct rendezvous participants seen within the freshness window.
/// window. Approximate by design: local participants are subtracted by /// Radio-copy hints never alter signer locality: their public coordinates
/// matching their events' mesh message IDs against the local timeline, /// can suppress a duplicate row but cannot authenticate a Nostr signer.
/// which cannot attribute silent (presence-only) local peers.
@Published private(set) var bridgedPeerCount: Int = 0 @Published private(set) var bridgedPeerCount: Int = 0
/// The people behind the count, newest activity first. /// The people behind the count, newest activity first.
@Published private(set) var bridgedParticipants: [BridgedParticipant] = [] @Published private(set) var bridgedParticipants: [BridgedParticipant] = []
@@ -157,6 +180,13 @@ final class BridgeService: ObservableObject {
var broadcastToMesh: (@MainActor (Data) -> Void)? var broadcastToMesh: (@MainActor (Data) -> Void)?
/// Delivers a validated inbound bridge message to the mesh timeline. /// Delivers a validated inbound bridge message to the mesh timeline.
var injectInbound: (@MainActor (InboundBridgeMessage) -> Void)? 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 /// True when the mesh timeline already holds this message ID (the radio
/// copy) used to skip pointless downlink airtime. /// copy) used to skip pointless downlink airtime.
var isMessageSeenLocally: (@MainActor (String) -> Bool)? var isMessageSeenLocally: (@MainActor (String) -> Bool)?
@@ -183,32 +213,56 @@ final class BridgeService: ObservableObject {
private var rebroadcastEventIDs: BoundedIDSet private var rebroadcastEventIDs: BoundedIDSet
/// Timeline message IDs already injected (either arrival path). /// Timeline message IDs already injected (either arrival path).
private var injectedMessageIDs: BoundedIDSet 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). /// Cells the rendezvous subscription covers (own + neighbor ring).
private(set) var subscribedCells: Set<String> = [] private(set) var subscribedCells: Set<String> = []
private(set) var queuedUplinks: [QueuedUplink] = [] private(set) var queuedUplinks: [QueuedUplink] = []
private var uplinkDepositTimes: [PeerID: [Date]] = [:] private var uplinkDepositTimes: [PeerID: [Date]] = [:]
private var downlinkSendTimes: [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 pendingDownlinks: [(event: NostrEvent, cell: String)] = []
private var downlinkDrainScheduled = false private var downlinkDrainScheduled = false
private var presenceTimerArmed = false private var presenceTimerArmed = false
private var lastPresenceAt = Date.distantPast private var lastPresenceAt = Date.distantPast
/// pubkey -> (lastSeen, attributed-to-local-island, last known nickname). /// pubkey -> (lastSeen, last known nickname).
private var participants: [String: (lastSeen: Date, isLocal: Bool, nickname: String?)] = [:] private var participants: [String: (lastSeen: Date, nickname: String?)] = [:]
private let defaults: UserDefaults private let defaults: UserDefaults
private let now: () -> Date private let now: () -> Date
private let verifyEventSignature: (NostrEvent) -> Bool
private static let enabledKey = "bridge.userEnabled" 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.defaults = defaults
self.now = now self.now = now
self.verifyEventSignature = verifyEventSignature
self.isEnabled = defaults.bool(forKey: Self.enabledKey) self.isEnabled = defaults.bool(forKey: Self.enabledKey)
self.meshBroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs) self.meshBroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
self.publishedEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs) self.publishedEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
self.rebroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs) self.rebroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
self.injectedMessageIDs = 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 // MARK: - Toggle & lifecycle
@@ -221,6 +275,8 @@ final class BridgeService: ObservableObject {
queuedUplinks.removeAll() queuedUplinks.removeAll()
pendingDownlinks.removeAll() pendingDownlinks.removeAll()
uplinkDepositTimes.removeAll() uplinkDepositTimes.removeAll()
inboundEventTimes.removeAll()
inboundEventTimesBySigner.removeAll()
participants.removeAll() participants.removeAll()
bridgedPeerCount = 0 bridgedPeerCount = 0
bridgedParticipants = [] bridgedParticipants = []
@@ -292,22 +348,27 @@ final class BridgeService: ObservableObject {
/// Composes and ships the bridged copy of an outgoing public mesh /// Composes and ships the bridged copy of an outgoing public mesh
/// message. Call after the radio send; no-op when the bridge is off, /// 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. /// 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 isEnabled, !nearbyOnly, let cell = activeCell ?? currentCell() else { return }
guard content.utf8.count <= Limits.maxContentBytes else { return } guard content.utf8.count <= Limits.maxContentBytes else { return }
let timestampMs = MeshMessageIdentity.millisecondTimestamp(timestamp)
guard let identity = try? deriveIdentity?(cell), guard let identity = try? deriveIdentity?(cell),
let event = try? NostrProtocol.createBridgeMeshEvent( let event = try? NostrProtocol.createBridgeMeshEvent(
content: content, content: content,
cell: cell, cell: cell,
senderIdentity: identity, senderIdentity: identity,
nickname: myNickname?(), nickname: myNickname?(),
meshMessageID: messageID meshSenderID: senderPeerID.id,
meshTimestampMs: timestampMs
) else { ) else {
SecureLogger.error("🌉 Bridge: failed to compose rendezvous event", category: .session) SecureLogger.error("🌉 Bridge: failed to compose rendezvous event", category: .session)
return return
} }
publishedEventIDs.insert(event.id) 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 { if relaysConnected?() ?? false {
publishToRelays?(event, cell) publishToRelays?(event, cell)
} else if let carrier = NostrCarrierPacket(direction: .toBridge, geohash: cell, event: event), } else if let carrier = NostrCarrierPacket(direction: .toBridge, geohash: cell, event: event),
@@ -376,7 +437,6 @@ final class BridgeService: ObservableObject {
subscribedCells.contains(cell) else { subscribedCells.contains(cell) else {
return return
} }
guard let kind = classify(event, cell: cell) else { return }
// Events we published come back from our own subscription; they are // Events we published come back from our own subscription; they are
// presence-neutral (we never count ourselves) and never re-injected // presence-neutral (we never count ourselves) and never re-injected
// or rebroadcast. Two layers: the published-ID cache (this session) // 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 publishedEventIDs.insert(event.id) // never downlink it either
return 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 { switch kind {
case .presence: case .presence:
recordParticipant(event.pubkey, isLocal: false, nickname: nil) recordParticipant(event.pubkey, nickname: nil)
case .message(let message): case .message(let message):
let isLocalRadioCopy = isMessageSeenLocally?(message.messageID) ?? false let isLocalRadioCopy = message.radioMessageIDHint.map(radioCopyAlreadyPresent) ?? false
if isLocalRadioCopy { 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 // Serving duty: carry remote islands' messages onto the radio for
// mesh-only peers. Local-origin events are skipped the island // mesh-only peers. Local-origin events are skipped the island
// already heard them (loop rule 3). The drain is jitter-delayed: // 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) // MARK: - Mesh carrier ingress (both roles)
/// Entry point for received `nostrCarrier` packets with bridge /// 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) SecureLogger.debug("🌉 Bridge: rate-limited deposit from \(depositor.id.prefix(8))", category: .session)
return return
} }
guard event.isValidSignature() else { guard allowSignatureVerificationAttempt(), verifyEventSignature(event) else {
SecureLogger.debug("🌉 Bridge: rejected deposit from \(depositor.id.prefix(8))… (bad signature)", category: .security) SecureLogger.debug("🌉 Bridge: rejected deposit from \(depositor.id.prefix(8))… (bad signature)", category: .security)
return return
} }
@@ -511,6 +603,52 @@ final class BridgeService: ObservableObject {
return true 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) // MARK: - Downlink (gateway role: internet -> mesh)
private func drainPendingDownlinks() { private func drainPendingDownlinks() {
@@ -521,9 +659,15 @@ final class BridgeService: ObservableObject {
let (event, cell) = pendingDownlinks.removeFirst() let (event, cell) = pendingDownlinks.removeFirst()
guard isFresh(event) else { continue } guard isFresh(event) else { continue }
// Suppression recheck at send time: another gateway may have // 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), guard !meshBroadcastEventIDs.contains(event.id),
!rebroadcastEventIDs.contains(event.id) else { continue } !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), guard let carrier = NostrCarrierPacket(direction: .fromBridge, geohash: cell, event: event),
let payload = carrier.encode() else { continue } let payload = carrier.encode() else { continue }
broadcastToMesh?(payload) broadcastToMesh?(payload)
@@ -572,36 +716,90 @@ final class BridgeService: ObservableObject {
guard let event = structurallyValidEvent(from: carrier), guard let event = structurallyValidEvent(from: carrier),
!publishedEventIDs.contains(event.id), !publishedEventIDs.contains(event.id),
!isOwnRendezvousEvent(event, cell: carrier.geohash), !isOwnRendezvousEvent(event, cell: carrier.geohash),
event.isValidSignature() else { allowSignatureVerificationAttempt(),
verifyEventSignature(event) else {
return return
} }
// Mark after verification (a forged copy must not poison the cache), // Mark after verification (a forged copy must not poison the cache),
// and use the marking as multi-path dedup. // even when the relay copy won the injection race: pending gateway
guard meshBroadcastEventIDs.insert(event.id) else { return } // 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 { guard case .message(let message)? = classify(event, cell: carrier.geohash) else {
return return
} }
recordParticipant(event.pubkey, isLocal: false, nickname: message.senderNickname) if inject(message) {
inject(message) recordParticipant(event.pubkey, nickname: message.participantNickname)
}
} }
// MARK: - Injection & participants // MARK: - Injection & participants
private func inject(_ message: InboundBridgeMessage) { @discardableResult
guard injectedMessageIDs.insert(message.messageID) else { return } private func inject(_ message: InboundBridgeMessage) -> Bool {
guard !(isMessageSeenLocally?(message.messageID) ?? false) else { return } 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) SecureLogger.info("🌉 Bridge: injected bridged message \(message.messageID.prefix(8))… from \(message.senderNickname)", category: .session)
injectInbound?(message) 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] let previous = participants[pubkey]
// Local attribution is sticky: one radio-confirmed message marks the // Presence events carry no nickname, so a known name is never
// pubkey as an islander for as long as they stay fresh. Presence // forgotten. Radio hints deliberately do not mutate this record.
// events carry no nickname, so a known name is never forgotten.
participants[pubkey] = ( participants[pubkey] = (
lastSeen: now(), lastSeen: now(),
isLocal: (previous?.isLocal ?? false) || isLocal,
nickname: nickname?.trimmedOrNilIfEmpty ?? previous?.nickname nickname: nickname?.trimmedOrNilIfEmpty ?? previous?.nickname
) )
recomputeBridgedCount() recomputeBridgedCount()
@@ -616,7 +814,7 @@ final class BridgeService: ObservableObject {
private func recomputeBridgedCount() { private func recomputeBridgedCount() {
let cutoff = now().addingTimeInterval(-Limits.participantFreshnessSeconds) let cutoff = now().addingTimeInterval(-Limits.participantFreshnessSeconds)
let visible = participants 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) } .map { BridgedParticipant(pubkey: $0.key, nickname: $0.value.nickname, lastSeen: $0.value.lastSeen) }
.sorted { $0.lastSeen > $1.lastSeen } .sorted { $0.lastSeen > $1.lastSeen }
if visible.count != bridgedPeerCount { if visible.count != bridgedPeerCount {
@@ -648,11 +846,30 @@ final class BridgeService: ObservableObject {
let content = event.content let content = event.content
guard !content.trimmed.isEmpty, content.utf8.count <= Limits.maxContentBytes else { return nil } 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 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] // The `m` tag is `[stable ID, sender ID, wire timestamp ms]` for
let messageID = (meshID?.trimmedOrNilIfEmpty) ?? event.id // 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( return .message(InboundBridgeMessage(
messageID: messageID, messageID: event.id,
senderNickname: nickname?.trimmedOrNilIfEmpty ?? "anon#\(event.pubkey.prefix(4))", radioMessageIDHint: radioMessageIDHint,
senderNickname: baseNickname + "#" + String(event.pubkey.suffix(4)),
participantNickname: nickname?.trimmedOrNilIfEmpty,
senderPubkey: event.pubkey, senderPubkey: event.pubkey,
content: content, content: content,
timestamp: Date(timeIntervalSince1970: TimeInterval(event.created_at)) timestamp: Date(timeIntervalSince1970: TimeInterval(event.created_at))
@@ -15,12 +15,6 @@ struct GeohashChatPreview: Equatable, Sendable {
let senderName: String let senderName: String
let content: String let content: String
let timestamp: Date 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 /// The liveliest nearby conversation, resolved against the user's regional
+10 -3
View File
@@ -542,6 +542,15 @@ final class KeychainManager: KeychainManagerProtocol {
/// Load data from a custom service /// Load data from a custom service
func load(key: String, service customService: String) -> Data? { 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] = [ let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword, kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: customService, kSecAttrService as String: customService,
@@ -551,9 +560,7 @@ final class KeychainManager: KeychainManagerProtocol {
var result: AnyObject? var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result) let status = SecItemCopyMatching(query as CFDictionary, &result)
return classifyReadStatus(status, data: result as? Data)
guard status == errSecSuccess else { return nil }
return result as? Data
} }
/// Delete data from a custom service /// Delete data from a custom service
+13 -21
View File
@@ -173,6 +173,15 @@ final class LocationNotesManager: ObservableObject {
deinit { deinit {
expiryPruneTimer?.invalidate() expiryPruneTimer?.invalidate()
connectivityRetryTimer?.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 /// Drops notes whose NIP-40 expiry has passed. Their ids stay in
@@ -190,27 +199,10 @@ final class LocationNotesManager: ObservableObject {
} }
} }
func setGeohash(_ newGeohash: String) { // A manager's geohash is fixed for its lifetime: instances are pooled
let norm = newGeohash.lowercased() // per geohash (`LocationNotesPool`), so retargeting one in place would
guard norm != geohash else { return } // corrupt the pool's keying and refcounts. Release the manager and
guard Geohash.isValidGeohash(norm) else { // acquire the new cell instead.
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()
}
func refresh() { func refresh() {
if let sub = subscriptionID { 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 teleportedStoreKey = "locationChannel.teleportedSet"
private let bookmarksKey = "locationChannel.bookmarks" private let bookmarksKey = "locationChannel.bookmarks"
private let bookmarkNamesKey = "locationChannel.bookmarkNames" private let bookmarkNamesKey = "locationChannel.bookmarkNames"
private let bookmarkNamesSchemaVersionKey = "locationChannel.bookmarkNamesSchemaVersion"
private let bookmarkNamesCurrentSchemaVersion = 1
// MARK: - Published State (Channel) // MARK: - Published State (Channel)
@@ -222,6 +224,26 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
let dict = try? JSONDecoder().decode([String: String].self, from: data) { let dict = try? JSONDecoder().decode([String: String].self, from: data) {
bookmarkNames = dict 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() { private func initializePermissionState() {
@@ -367,16 +389,16 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
} }
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
updatePermissionState(from: status) let newState = updatePermissionState(from: status)
if case .authorized = permissionState { if newState == .authorized {
requestOneShotLocation() requestOneShotLocation()
} }
} }
@available(iOS 14.0, macOS 11.0, *) @available(iOS 14.0, macOS 11.0, *)
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
updatePermissionState(from: manager.authorizationStatus) let newState = updatePermissionState(from: manager.authorizationStatus)
if case .authorized = permissionState { if newState == .authorized {
requestOneShotLocation() requestOneShotLocation()
} }
} }
@@ -393,7 +415,8 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
// MARK: - Private Helpers (Permission) // MARK: - Private Helpers (Permission)
private func updatePermissionState(from status: CLAuthorizationStatus) { @discardableResult
private func updatePermissionState(from status: CLAuthorizationStatus) -> PermissionState {
let newState: PermissionState let newState: PermissionState
switch status { switch status {
case .notDetermined: newState = .notDetermined case .notDetermined: newState = .notDetermined
@@ -402,7 +425,15 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
case .authorizedAlways, .authorizedWhenInUse, .authorized: newState = .authorized case .authorizedAlways, .authorizedWhenInUse, .authorized: newState = .authorized
@unknown default: newState = .restricted @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 } Task { @MainActor in self.permissionState = newState }
return newState
} }
// MARK: - Private Helpers (Channel Computation) // MARK: - Private Helpers (Channel Computation)
@@ -516,15 +547,15 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
} }
private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) { private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) {
var uniqueAdmins: [String] = [] var uniqueRegions: [String] = []
var seenAdmins = Set<String>() var seenRegions = Set<String>()
var idx = 0 var idx = 0
func step() { func step() {
if idx >= points.count { if idx >= points.count {
let finalName: String? = { let finalName: String? = {
if uniqueAdmins.count >= 2 { return uniqueAdmins[0] + " and " + uniqueAdmins[1] } if uniqueRegions.count >= 2 { return uniqueRegions[0] + " and " + uniqueRegions[1] }
return uniqueAdmins.first return uniqueRegions.first
}() }()
if let finalName = finalName, !finalName.isEmpty { if let finalName = finalName, !finalName.isEmpty {
DispatchQueue.main.async { DispatchQueue.main.async {
@@ -540,12 +571,16 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
guard self != nil else { return } guard self != nil else { return }
if let pm = placemarks?.first { if let pm = placemarks?.first {
if let admin = pm.administrativeArea, !admin.isEmpty, !seenAdmins.contains(admin) { if let country = pm.country, !country.isEmpty {
seenAdmins.insert(admin) if !seenRegions.contains(country) {
uniqueAdmins.append(admin) seenRegions.insert(country)
} else if let country = pm.country, !country.isEmpty, !seenAdmins.contains(country) { uniqueRegions.append(country)
seenAdmins.insert(country) }
uniqueAdmins.append(country) } else if let admin = pm.administrativeArea,
!admin.isEmpty,
!seenRegions.contains(admin) {
seenRegions.insert(admin)
uniqueRegions.append(admin)
} }
} }
step() step()
@@ -557,7 +592,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
private static func nameForGeohashLength(_ len: Int, from pm: CLPlacemark) -> String? { private static func nameForGeohashLength(_ len: Int, from pm: CLPlacemark) -> String? {
switch len { switch len {
case 0...2: case 0...2:
return pm.administrativeArea ?? pm.country return pm.country ?? pm.administrativeArea
case 3...4: case 3...4:
return pm.administrativeArea ?? pm.subAdministrativeArea ?? pm.country return pm.administrativeArea ?? pm.subAdministrativeArea ?? pm.country
case 5: case 5:
+16 -2
View File
@@ -51,6 +51,14 @@ final class MeshSightingsTracker: ObservableObject {
defaults.set(Array(seenHashes), forKey: Keys.hashes) 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() { func clear() {
seenHashes.removeAll() seenHashes.removeAll()
todayCount = 0 todayCount = 0
@@ -76,8 +84,10 @@ final class MeshSightingsTracker: ObservableObject {
defaults.set(today, forKey: Keys.dayKey) defaults.set(today, forKey: Keys.dayKey)
defaults.set(Self.randomSalt(), forKey: Keys.salt) defaults.set(Self.randomSalt(), forKey: Keys.salt)
defaults.removeObject(forKey: Keys.hashes) defaults.removeObject(forKey: Keys.hashes)
defaults.removeObject(forKey: Keys.lastSeenAt)
seenHashes.removeAll() seenHashes.removeAll()
todayCount = 0 todayCount = 0
lastSightingAt = nil
} }
private func saltedHash(_ value: String) -> String { private func saltedHash(_ value: String) -> String {
@@ -92,12 +102,16 @@ final class MeshSightingsTracker: ObservableObject {
return digest.finalize().map { String(format: "%02x", $0) }.joined() 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() let formatter = DateFormatter()
formatter.calendar = Calendar.current formatter.calendar = Calendar.current
formatter.timeZone = TimeZone.current formatter.timeZone = TimeZone.current
formatter.dateFormat = "yyyy-MM-dd" 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 { private static func randomSalt() -> Data {
@@ -246,6 +246,16 @@ final class MessageDeduplicationService {
ContentNormalizer.normalizedKey(content) 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 // MARK: - Nostr Event Deduplication
/// Checks if a Nostr event has already been processed. /// Checks if a Nostr event has already been processed.
+137 -22
View File
@@ -56,11 +56,15 @@ final class MessageRouter {
/// relays as a courier drop, so delivery stops requiring a physical /// relays as a courier drop, so delivery stops requiring a physical
/// courier encounter. No-op unless the bridge is enabled. Runs alongside /// courier encounter. No-op unless the bridge is enabled. Runs alongside
/// (not instead of) mesh couriers; receivers dedup by message ID. /// (not instead of) mesh couriers; receivers dedup by message ID.
/// Returns true when a fresh drop was sealed, so the sender's message /// Completion is true only after at least one default relay explicitly
/// can show the "carried" state instead of sitting on "sending" forever /// accepts the event, so a socket write followed by rejection cannot
/// (the delivery ack has no radio route back until the peers next share /// falsely show the sender's message as "carried".
/// a transport). var bridgeCourierDeposit: ((
var bridgeCourierDeposit: ((_ content: String, _ messageID: String, _ recipientNoiseKey: Data) -> Bool)? _ content: String,
_ messageID: String,
_ recipientNoiseKey: Data,
_ completion: @escaping @MainActor (Bool) -> Void
) -> Void)?
/// Re-attempts bridge drops for retained messages whose recipient no /// Re-attempts bridge drops for retained messages whose recipient no
/// transport can promptly reach anymore. Covers sends that raced the BLE /// transport can promptly reach anymore. Covers sends that raced the BLE
@@ -77,9 +81,7 @@ final class MessageRouter {
} }
guard !promptlyDeliverable else { continue } guard !promptlyDeliverable else { continue }
for message in queue where now().timeIntervalSince(message.timestamp) <= Self.messageTTLSeconds { for message in queue where now().timeIntervalSince(message.timestamp) <= Self.messageTTLSeconds {
if bridgeCourierDeposit?(message.content, message.messageID, recipientKey) == true { requestBridgeCourierDeposit(message, for: peerID, recipientKey: recipientKey)
onMessageCarried?(message.messageID, peerID)
}
} }
} }
} }
@@ -92,12 +94,17 @@ final class MessageRouter {
bridgeSweepTask = Task { @MainActor [weak self] in bridgeSweepTask = Task { @MainActor [weak self] in
while !Task.isCancelled { while !Task.isCancelled {
try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) 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() self?.retryBridgeCourierDeposits()
} }
} }
} }
private var bridgeSweepTask: Task<Void, Never>? private var bridgeSweepTask: Task<Void, Never>?
private var bridgeDepositsInFlight = Set<String>()
private var outbox: [PeerID: [QueuedMessage]] = [:] private var outbox: [PeerID: [QueuedMessage]] = [:]
@@ -123,6 +130,9 @@ final class MessageRouter {
self.outboxStore = outboxStore self.outboxStore = outboxStore
self.metrics = metrics self.metrics = metrics
self.outbox = outboxStore?.load() ?? [:] self.outbox = outboxStore?.load() ?? [:]
outboxStore?.setRecoveryHandler { [weak self] recovered in
self?.mergeRecoveredOutbox(recovered)
}
// Observe favorites changes to learn Nostr mapping and flush queued messages // Observe favorites changes to learn Nostr mapping and flush queued messages
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
@@ -161,14 +171,38 @@ final class MessageRouter {
// MARK: - Message Sending // MARK: - Message Sending
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
if let transport = connectedTransport(for: peerID) { if let transport = connectedTransport(for: peerID), transport.canDeliverSecurely(to: peerID) {
// A live link is a strong delivery signal; trust it outright. // 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) 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) transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
return return
} }
let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: now(), sendAttempts: 1) 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) { if let transport = reachableTransport(for: peerID) {
// Reachability without a connection is a freshness heuristic (e.g. // Reachability without a connection is a freshness heuristic (e.g.
// the mesh retention window), so the send can silently go nowhere. // the mesh retention window), so the send can silently go nowhere.
@@ -209,9 +243,7 @@ final class MessageRouter {
let entry = queuedMessage(messageID, for: peerID) else { return } let entry = queuedMessage(messageID, for: peerID) else { return }
// The bridge drop needs no connected courier only the recipient // The bridge drop needs no connected courier only the recipient
// key so it runs before the courier-slot bookkeeping. // key so it runs before the courier-slot bookkeeping.
if bridgeCourierDeposit?(entry.content, messageID, recipientKey) == true { requestBridgeCourierDeposit(entry, for: peerID, recipientKey: recipientKey)
onMessageCarried?(messageID, peerID)
}
let remainingSlots = Self.maxCouriersPerMessage - entry.depositedCourierKeys.count let remainingSlots = Self.maxCouriersPerMessage - entry.depositedCourierKeys.count
guard remainingSlots > 0 else { return } guard remainingSlots > 0 else { return }
@@ -296,6 +328,23 @@ final class MessageRouter {
outbox[peerID]?.first { $0.messageID == messageID } 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]) { private func recordCourierDeposit(messageID: String, for peerID: PeerID, courierKeys: [Data]) {
metrics?.record(.courierDeposited) metrics?.record(.courierDeposited)
guard var queue = outbox[peerID], guard var queue = outbox[peerID],
@@ -316,16 +365,26 @@ final class MessageRouter {
outbox[peerID] = filtered.isEmpty ? nil : filtered outbox[peerID] = filtered.isEmpty ? nil : filtered
cleared = true 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 { if cleared {
metrics?.record(.outboxDelivered) metrics?.record(.outboxDelivered)
persistOutbox()
} }
persistOutbox()
} }
private func enqueue(_ message: QueuedMessage, for peerID: PeerID) { private func enqueue(_ message: QueuedMessage, for peerID: PeerID) {
var message = message
var queue = outbox[peerID] ?? [] var queue = outbox[peerID] ?? []
// Re-sending an already-queued ID replaces the entry (keeps attempt count fresh) // Re-sending an already-queued ID replaces the entry (keeps attempt
queue.removeAll { $0.messageID == message.messageID } // 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) queue.append(message)
// Enforce per-peer size limit with FIFO eviction // Enforce per-peer size limit with FIFO eviction
@@ -348,19 +407,58 @@ final class MessageRouter {
outboxStore?.save(outbox) 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. /// Panic wipe: forget queued mail on disk and in memory.
func wipeOutbox() { func wipeOutbox() {
outbox.removeAll() outbox.removeAll()
outboxStore?.wipe() 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) { 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) 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) transport.sendReadReceipt(receipt, to: peerID)
return true
} else if !transports.isEmpty { } 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) { func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
@@ -386,14 +484,31 @@ final class MessageRouter {
continue continue
} }
if let transport = connectedTransport(for: peerID) { if let transport = connectedTransport(for: peerID), transport.canDeliverSecurely(to: peerID) {
// Live link: send and stop retaining. // 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) 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) transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
metrics?.record(.outboxResent) 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) { } else if let transport = reachableTransport(for: peerID) {
// Weak signal: send but keep retaining until an ack clears it, // Reachability without a connection is a freshness heuristic,
// bounded by attempt count for peers that never ack. // 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 { 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) 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) dropMessage(message.messageID, for: peerID)
+133 -22
View File
@@ -165,7 +165,6 @@ final class NoiseEncryptionService {
// Peer fingerprints (SHA256 hash of static public key) // Peer fingerprints (SHA256 hash of static public key)
private var peerFingerprints: [PeerID: String] = [:] private var peerFingerprints: [PeerID: String] = [:]
private var fingerprintToPeerID: [String: PeerID] = [:] private var fingerprintToPeerID: [String: PeerID] = [:]
// Thread safety // Thread safety
private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent) private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent)
@@ -183,12 +182,18 @@ final class NoiseEncryptionService {
// Callbacks // Callbacks
private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication 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 var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
/// Automatic rekey removed the old session and produced XX message 1.
/// The transport must clear session-scoped state and put these exact bytes
/// on the wire; merely reporting "handshake required" strands the partial
/// initiator session because a second initiate call sees it already exists.
var onRekeyHandshakeReady: ((_ peerID: PeerID, _ message: Data) -> Void)?
// Add a handler for peer authentication // Add a handler for peer authentication
func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) { func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) {
serviceQueue.async(flags: .barrier) { [weak self] in serviceQueue.sync(flags: .barrier) {
self?.onPeerAuthenticatedHandlers.append(handler) onPeerAuthenticatedHandlers.append(handler)
} }
} }
@@ -202,6 +207,18 @@ final class NoiseEncryptionService {
} }
} }
/// 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) { init(keychain: KeychainManagerProtocol) {
self.keychain = keychain self.keychain = keychain
self.localPrekeys = LocalPrekeyStore(keychain: keychain) self.localPrekeys = LocalPrekeyStore(keychain: keychain)
@@ -295,8 +312,12 @@ final class NoiseEncryptionService {
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain) self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain)
// Set up session callbacks // Set up session callbacks
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey, generation in
self?.handleSessionEstablished(peerID: peerID, remoteStaticKey: remoteStaticKey) self?.handleSessionEstablished(
peerID: peerID,
remoteStaticKey: remoteStaticKey,
sessionGeneration: generation
)
} }
// Start session maintenance timer // Start session maintenance timer
@@ -726,10 +747,55 @@ final class NoiseEncryptionService {
return try sessionManager.encrypt(data, for: peerID) return try sessionManager.encrypt(data, for: peerID)
} }
/// 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
}
guard rateLimiter.allowMessage(from: peerID) else {
throw NoiseSecurityError.rateLimitExceeded
}
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 /// Decrypt data from a specific peer
func decrypt(_ data: Data, from peerID: PeerID) throws -> Data { func decrypt(_ data: Data, from peerID: PeerID) throws -> Data {
// Validate message size try decryptWithSessionGeneration(data, from: peerID).plaintext
guard NoiseSecurityValidator.validateMessageSize(data) else { }
func decryptWithSessionGeneration(
_ data: Data,
from peerID: PeerID
) throws -> (plaintext: Data, sessionGeneration: UUID) {
// Standard transport ciphertext has 20 bytes of nonce/tag overhead.
// A larger candidate is admitted only up to the framed-file ceiling;
// after authenticated decryption it must prove it is `.privateFile`.
let isStandardCiphertext = NoiseSecurityValidator.validateCiphertextSize(data)
guard isStandardCiphertext || NoiseSecurityValidator.validatePrivateFileCiphertextSize(data) else {
throw NoiseSecurityError.messageTooLarge throw NoiseSecurityError.messageTooLarge
} }
@@ -743,7 +809,14 @@ final class NoiseEncryptionService {
throw NoiseEncryptionError.sessionNotEstablished throw NoiseEncryptionError.sessionNotEstablished
} }
return try sessionManager.decrypt(data, from: peerID) let result = try sessionManager.decryptWithSessionGeneration(data, from: peerID)
if !isStandardCiphertext {
guard NoisePayloadType.isPrivateFile(rawValue: result.plaintext.first),
NoiseSecurityValidator.validatePrivateFileMessageSize(result.plaintext) else {
throw NoiseSecurityError.messageTooLarge
}
}
return result
} }
// MARK: - Peer Management // MARK: - Peer Management
@@ -755,6 +828,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() { func clearEphemeralStateForPanic() {
sessionManager.removeAllSessions() sessionManager.removeAllSessions()
serviceQueue.sync(flags: .barrier) { serviceQueue.sync(flags: .barrier) {
@@ -777,24 +869,36 @@ final class NoiseEncryptionService {
// MARK: - Private Helpers // 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 // Calculate fingerprint
let fingerprint = remoteStaticKey.rawRepresentation.sha256Fingerprint() let fingerprint = remoteStaticKey.rawRepresentation.sha256Fingerprint()
// Store fingerprint mapping // Registering handlers is synchronous, and this barrier snapshots them
serviceQueue.sync(flags: .barrier) { // 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 peerFingerprints[peerID] = fingerprint
fingerprintToPeerID[fingerprint] = peerID fingerprintToPeerID[fingerprint] = peerID
return (onPeerAuthenticatedWithGenerationHandlers, onPeerAuthenticatedHandlers)
} }
// Log security event // Log security event
SecureLogger.info(.handshakeCompleted(peerID: peerID.id)) SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
// Notify all handlers about authentication // Notify all handlers about authentication.
serviceQueue.async { [weak self] in handlers.generationAware.forEach { handler in
self?.onPeerAuthenticatedHandlers.forEach { handler in handler(peerID, fingerprint, sessionGeneration)
handler(peerID, fingerprint)
} }
handlers.legacy.forEach { handler in
handler(peerID, fingerprint)
} }
} }
@@ -815,20 +919,27 @@ final class NoiseEncryptionService {
let sessionsNeedingRekey = sessionManager.getSessionsNeedingRekey() let sessionsNeedingRekey = sessionManager.getSessionsNeedingRekey()
for (peerID, needsRekey) in sessionsNeedingRekey where needsRekey { for (peerID, needsRekey) in sessionsNeedingRekey where needsRekey {
// Attempt to rekey the session
do { do {
try sessionManager.initiateRekey(for: peerID) try initiateAutomaticRekey(for: peerID)
SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security)
// Signal that handshake is needed
onHandshakeRequired?(peerID)
} catch { } catch {
SecureLogger.error(error, context: "Failed to initiate rekey for peer: \(peerID)", category: .session) SecureLogger.error(error, context: "Failed to initiate rekey for peer: \(peerID)", category: .session)
} }
} }
} }
private func initiateAutomaticRekey(for peerID: PeerID) throws {
let handshakeMessage = try sessionManager.initiateRekey(for: peerID)
SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security)
onRekeyHandshakeReady?(peerID, handshakeMessage)
onHandshakeRequired?(peerID)
}
#if DEBUG
func _test_initiateAutomaticRekey(for peerID: PeerID) throws {
try initiateAutomaticRekey(for: peerID)
}
#endif
deinit { deinit {
stopRekeyTimer() stopRekeyTimer()
} }
+7
View File
@@ -231,6 +231,13 @@ final class NostrTransport: Transport, @unchecked Sendable {
isPeerReachable(peerID) && queue.sync { relaysConnected } 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 peerNickname(peerID: PeerID) -> String? { nil }
func getPeerNicknames() -> [PeerID: String] { [:] } func getPeerNicknames() -> [PeerID: String] { [:] }
+1 -1
View File
@@ -153,7 +153,7 @@ final class NotificationService {
private func registerCategories() { private func registerCategories() {
let wave = UNNotificationAction( let wave = UNNotificationAction(
identifier: Self.waveActionID, 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: [] options: []
) )
let nearby = UNNotificationCategory( let nearby = UNNotificationCategory(
@@ -23,10 +23,11 @@ import Foundation
final class PrekeyBundleStore { final class PrekeyBundleStore {
struct StoredBundle: Codable { struct StoredBundle: Codable {
// noiseKey is read in loadFromDisk (dictionary keying), but the // noiseKey is read in loadFromDisk (dictionary keying), but the
// Periphery indexer intermittently misses that read and flakes CI // Periphery indexer intermittently misses that read and flaked CI
// with "assign-only" its USR is baselined (an in-source ignore // with "assign-only" even past its baselined USR. Covered
// can't work: strict mode flags it as superfluous on the runs where // deterministically by retain_codable_properties in .periphery.yml
// the indexer gets it right). // (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 let noiseKey: Data
var generatedAt: UInt64 var generatedAt: UInt64
var prekeyIDs: [UInt32] var prekeyIDs: [UInt32]
+13 -5
View File
@@ -256,8 +256,6 @@ final class PrivateChatManager: ObservableObject {
return return
} }
sentReadReceipts.insert(message.id)
// Create read receipt using the simplified method // Create read receipt using the simplified method
let receipt = ReadReceipt( let receipt = ReadReceipt(
originalMessageID: message.id, originalMessageID: message.id,
@@ -268,11 +266,21 @@ final class PrivateChatManager: ObservableObject {
// Route via MessageRouter to avoid handshakeRequired spam when session isn't established // Route via MessageRouter to avoid handshakeRequired spam when session isn't established
if let router = messageRouter { if let router = messageRouter {
SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.id.prefix(8))… via router", category: .session) SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.id.prefix(8))… via router", category: .session)
Task { @MainActor in let messageID = message.id
router.sendReadReceipt(receipt, to: senderPeerID) // 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 { } else {
// Fallback: preserve previous behavior // Fallback: preserve previous behavior (best-effort mesh send).
sentReadReceipts.insert(message.id)
meshService?.sendReadReceipt(receipt, to: senderPeerID) meshService?.sendReadReceipt(receipt, to: senderPeerID)
} }
} }
@@ -11,6 +11,7 @@ final class TransferProgressManager {
case updated(id: String, sentFragments: Int, totalFragments: Int) case updated(id: String, sentFragments: Int, totalFragments: Int)
case completed(id: String, totalFragments: Int) case completed(id: String, totalFragments: Int)
case cancelled(id: String, sentFragments: Int, totalFragments: Int) case cancelled(id: String, sentFragments: Int, totalFragments: Int)
case rejected(id: String, reason: String)
} }
private let subject = PassthroughSubject<Event, Never>() 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)? { func snapshot(id: String) -> (sent: Int, total: Int)? {
var result: (sent: Int, total: Int)? var result: (sent: Int, total: Int)?
queue.sync { queue.sync {
+61
View File
@@ -83,6 +83,20 @@ enum TransportEvent: @unchecked Sendable {
case bluetoothStateUpdated(CBManagerState) 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
}
protocol TransportEventDelegate: AnyObject { protocol TransportEventDelegate: AnyObject {
@MainActor func didReceiveTransportEvent(_ event: TransportEvent) @MainActor func didReceiveTransportEvent(_ event: TransportEvent)
} }
@@ -116,6 +130,14 @@ protocol Transport: AnyObject {
/// npub as reachable even with no relay connection, where a send only /// npub as reachable even with no relay connection, where a send only
/// joins a queue waiting for internet that may never come. /// joins a queue waiting for internet that may never come.
func canDeliverPromptly(to peerID: PeerID) -> Bool 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 peerNickname(peerID: PeerID) -> String?
func getPeerNicknames() -> [PeerID: String] func getPeerNicknames() -> [PeerID: String]
@@ -155,6 +177,12 @@ protocol Transport: AnyObject {
func sendDeliveryAck(for messageID: String, to peerID: PeerID) func sendDeliveryAck(for messageID: String, to peerID: PeerID)
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
func sendFilePrivate(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String,
allowLegacyFallback: Bool
)
func cancelTransfer(_ transferId: String) func cancelTransfer(_ transferId: String)
// Live voice / push-to-talk (mesh transports only): one encoded // Live voice / push-to-talk (mesh transports only): one encoded
@@ -200,6 +228,11 @@ protocol Transport: AnyObject {
/// Capabilities the peer advertised in its last verified announce; /// Capabilities the peer advertised in its last verified announce;
/// empty for peers that predate the capabilities TLV. /// empty for peers that predate the capabilities TLV.
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
func resolvePrivateMediaSendPolicy(
to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
)
/// Sends an encoded vouch-attestation batch inside the Noise session. /// Sends an encoded vouch-attestation batch inside the Noise session.
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) func sendVouchAttestations(_ payload: Data, to peerID: PeerID)
/// Appends a peer-authenticated observer. Unlike /// Appends a peer-authenticated observer. Unlike
@@ -216,6 +249,9 @@ protocol Transport: AnyObject {
// this device is carrying for gossip sync, decoded for display as // this device is carrying for gossip sync, decoded for display as
// "heard here earlier" timeline echoes. // "heard here earlier" timeline echoes.
func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) 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 /// A carried public mesh message from the store-and-forward window, decoded
@@ -244,6 +280,10 @@ extension Transport {
// straight to the radio; queue-backed transports override this. // straight to the radio; queue-backed transports override this.
func canDeliverPromptly(to peerID: PeerID) -> Bool { isPeerReachable(peerID) } 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 identity hooks default to inert for transports that do not carry
// Noise sessions (e.g. NostrTransport). // Noise sessions (e.g. NostrTransport).
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { nil } func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { nil }
@@ -263,6 +303,16 @@ extension Transport {
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {} func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {}
func broadcastGroupMessage(_ envelope: Data) {} func broadcastGroupMessage(_ envelope: Data) {}
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] } func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { .blockedDowngrade }
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 sendVouchAttestations(_ payload: Data, to peerID: PeerID) {}
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {} func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {}
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false } func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
@@ -279,6 +329,15 @@ extension Transport {
func currentMeshTopology() -> MeshTopologySnapshot? { nil } func currentMeshTopology() -> MeshTopologySnapshot? { nil }
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {} func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, 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 cancelTransfer(_ transferId: String) {} func cancelTransfer(_ transferId: String) {}
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) { func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
@@ -291,6 +350,8 @@ extension Transport {
func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) { func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) {
Task { @MainActor in completion([]) } Task { @MainActor in completion([]) }
} }
func purgeArchivedPublicMessages(from peerID: PeerID) {}
} }
protocol TransportPeerEventsDelegate: AnyObject { protocol TransportPeerEventsDelegate: AnyObject {
+12
View File
@@ -9,6 +9,12 @@ enum TransportConfig {
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends 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
static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
// Fragment relay TTL in sparse graphs; matches messageTTLDefault so media // Fragment relay TTL in sparse graphs; matches messageTTLDefault so media
@@ -215,6 +221,9 @@ enum TransportConfig {
// Fallback deadline for treating a subscription's initial fetch as complete // Fallback deadline for treating a subscription's initial fetch as complete
// when a relay never sends EOSE (generous to cover Tor circuit setup). // when a relay never sends EOSE (generous to cover Tor circuit setup).
static let nostrSubscriptionEOSEFallbackSeconds: TimeInterval = 10.0 static let nostrSubscriptionEOSEFallbackSeconds: TimeInterval = 10.0
// A bridge drop is durable only after NIP-20 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. // After this long, a relay marked permanently failed gets another chance.
static let nostrRelayFailureCooldownSeconds: TimeInterval = 600.0 static let nostrRelayFailureCooldownSeconds: TimeInterval = 600.0
@@ -345,6 +354,9 @@ enum TransportConfig {
// Cooldown between speculative multi-hop handovers of the same envelope // Cooldown between speculative multi-hop handovers of the same envelope
// toward a recipient heard only via relayed announces. // toward a recipient heard only via relayed announces.
static let courierRemoteHandoverCooldownSeconds: TimeInterval = 10 * 60 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) // One-time prekey bundles (forward-secret courier sealing)
// Own gossip-sync round for bundles: modest cadence, bounded peer count, // Own gossip-sync round for bundles: modest cadence, bounded peer count,
@@ -275,6 +275,12 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
return nil return nil
} }
identityManager.setBlocked(fingerprint, isBlocked: blocked) 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() updatePeers()
return fingerprint 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) { private func removeState(for peerID: PeerID) {
// Deliberately keeps the peer's prekey bundle: bundles exist to reach // Deliberately keeps the peer's prekey bundle: bundles exist to reach
// owners who left the mesh, and they age out on their own schedule. // owners who left the mesh, and they age out on their own schedule.
@@ -53,7 +53,8 @@ protocol ChatLifecycleContext: AnyObject {
// MARK: Routing & receipts // MARK: Routing & receipts
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) 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 sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date)
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
@@ -248,10 +249,14 @@ final class ChatLifecycleCoordinator {
? peerID ? peerID
: (context.unifiedPeer(for: peerID)?.peerID ?? peerID) : (context.unifiedPeer(for: peerID)?.peerID ?? peerID)
context.routeReadReceipt(receipt, to: recipientPeerID) // 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) context.markReadReceiptSent(message.id)
} }
} }
}
func getMessages(for peerID: PeerID?) -> [BitchatMessage] { func getMessages(for peerID: PeerID?) -> [BitchatMessage] {
guard let peerID else { return context.messages } guard let peerID else { return context.messages }
+168 -52
View File
@@ -59,7 +59,7 @@ extension ChatViewModel: ChatLiveVoiceContext {
} }
/// Where a live voice burst lives: a Noise DM or the public mesh timeline. /// Where a live voice burst lives: a Noise DM or the public mesh timeline.
enum VoiceBurstScope: Equatable { enum VoiceBurstScope: Hashable {
case directMessage case directMessage
case publicMesh case publicMesh
} }
@@ -72,6 +72,16 @@ enum VoiceBurstScope: Equatable {
/// bubble so nobody sees a duplicate. /// bubble so nobody sees a duplicate.
@MainActor @MainActor
final class ChatLiveVoiceCoordinator { 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 { private final class Assembly {
let burstID: Data let burstID: Data
let peerID: PeerID let peerID: PeerID
@@ -97,6 +107,8 @@ final class ChatLiveVoiceCoordinator {
var idleTimeout: Task<Void, Never>? var idleTimeout: Task<Void, Never>?
var gapRedrain: 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) { init(burstID: Data, peerID: PeerID, scope: VoiceBurstScope, nickname: String, message: BitchatMessage, fileURL: URL, fileHandle: FileHandle) {
self.burstID = burstID self.burstID = burstID
self.peerID = peerID self.peerID = peerID
@@ -123,11 +135,32 @@ final class ChatLiveVoiceCoordinator {
private static let finishedBurstsCap = 32 private static let finishedBurstsCap = 32
private unowned let context: any ChatLiveVoiceContext private unowned let context: any ChatLiveVoiceContext
private var assemblies: [Data: Assembly] = [:] private let fileStore: BLEIncomingFileStore
private var finishedBursts: [Data: FinishedBurst] = [:] /// 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.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 // MARK: - Inbound frames
@@ -160,11 +193,12 @@ final class ChatLiveVoiceCoordinator {
return return
} }
if let assembly = assemblies[packet.burstID] { // The sender is authenticated (Noise session or packet signature),
// The sender is authenticated (Noise session or packet // and the key binds the burst ID to that (peer, scope): a colliding
// signature); a different peer or scope reusing the same burst // START from another peer opens its own assembly instead of
// ID is a collision or a replay drop it. // capturing this one's frames.
guard assembly.peerID == peerID, assembly.scope == scope else { return } let key = AssemblyKey(peerID: peerID, scope: scope, burstID: packet.burstID)
if let assembly = assemblies[key] {
apply(packet, to: assembly) apply(packet, to: assembly)
return return
} }
@@ -178,7 +212,7 @@ final class ChatLiveVoiceCoordinator {
return return
} }
guard let assembly = makeAssembly(burstID: packet.burstID, peerID: peerID, scope: scope, nickname: nickname, timestamp: timestamp) else { 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() updatePublicTalkerIndicator()
apply(packet, to: assembly) apply(packet, to: assembly)
case .end, .canceled: case .end, .canceled:
@@ -203,17 +237,25 @@ final class ChatLiveVoiceCoordinator {
let burstID = Self.burstID(fromVoiceFileName: String(message.content.dropFirst(prefix.count))) let burstID = Self.burstID(fromVoiceFileName: String(message.content.dropFirst(prefix.count)))
else { return false } 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 // The note usually lands after END, but a lost END or a fast transfer
// can beat it close out the live assembly first. // 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) finalize(assembly)
} }
pruneFinishedBursts() pruneFinishedBursts()
guard let finished = finishedBursts[burstID] else { return false } guard let entry = finishedBursts.first(where: { matches($0.key) }) else { return false }
// Bind the note to the burst's authenticated sender and scope. let finished = entry.value
guard message.senderPeerID == nil || message.senderPeerID == finished.peerID else { return false }
guard message.isPrivate == (finished.scope == .directMessage) else { return false }
let replacement = BitchatMessage( let replacement = BitchatMessage(
id: finished.messageID, id: finished.messageID,
@@ -237,8 +279,8 @@ final class ChatLiveVoiceCoordinator {
// The complete .m4a replaces the partial live capture. // The complete .m4a replaces the partial live capture.
WaveformCache.shared.purge(url: finished.fileURL) WaveformCache.shared.purge(url: finished.fileURL)
try? FileManager.default.removeItem(at: finished.fileURL) try? fileManager.removeItem(at: finished.fileURL)
finishedBursts.removeValue(forKey: burstID) finishedBursts.removeValue(forKey: entry.key)
context.notifyUIChanged() context.notifyUIChanged()
SecureLogger.debug("PTT: absorbed finalized note for burst \(burstID.hexEncodedString())", category: .session) SecureLogger.debug("PTT: absorbed finalized note for burst \(burstID.hexEncodedString())", category: .session)
@@ -248,14 +290,19 @@ final class ChatLiveVoiceCoordinator {
// MARK: - Assembly lifecycle // MARK: - Assembly lifecycle
private func makeAssembly(burstID: Data, peerID: PeerID, scope: VoiceBurstScope, nickname: String, timestamp: Date) -> Assembly? { 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) SecureLogger.error("PTT: cannot resolve incoming media directory for burst \(burstID.hexEncodedString())", category: .session)
return nil 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 { guard let handle = try? FileHandle(forWritingTo: fileURL) else {
SecureLogger.error("PTT: cannot open capture file for burst \(burstID.hexEncodedString())", category: .session) 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 return nil
} }
@@ -413,30 +460,47 @@ final class ChatLiveVoiceCoordinator {
} }
try? assembly.fileHandle?.close() try? assembly.fileHandle?.close()
assembly.fileHandle = nil assembly.fileHandle = nil
assemblies.removeValue(forKey: assembly.burstID) assemblies.removeValue(forKey: assembly.key)
updatePublicTalkerIndicator() updatePublicTalkerIndicator()
guard assembly.deliveredFrames > 0 else { guard assembly.deliveredFrames > 0 else {
// Nothing audible ever arrived drop the empty bubble. // Nothing audible ever arrived drop the empty bubble.
removeBubble(of: assembly) removeBubble(of: assembly)
try? FileManager.default.removeItem(at: assembly.fileURL) try? fileManager.removeItem(at: assembly.fileURL)
context.notifyUIChanged() context.notifyUIChanged()
return 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. // The bubble's waveform may have been computed from a partial file.
WaveformCache.shared.purge(url: assembly.fileURL) WaveformCache.shared.purge(url: assembly.fileURL)
// Republish so the row re-renders without its LIVE treatment even if // The capture is the bubble's replayable audio from here on (unless a
// no finalized note ever arrives to swap in. // finalized note arrives to swap in): move it off its voice_live_
republishBubble(of: assembly) // 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() pruneFinishedBursts()
finishedBursts[assembly.burstID] = FinishedBurst( finishedBursts[assembly.key] = FinishedBurst(
messageID: assembly.messageID, messageID: assembly.messageID,
peerID: assembly.peerID, peerID: assembly.peerID,
scope: assembly.scope, scope: assembly.scope,
fileURL: assembly.fileURL, fileURL: fileURL,
messageTimestamp: assembly.messageTimestamp, messageTimestamp: assembly.messageTimestamp,
expiresAt: Date().addingTimeInterval(TransportConfig.pttFinishedBurstRegistrySeconds) expiresAt: Date().addingTimeInterval(TransportConfig.pttFinishedBurstRegistrySeconds)
) )
@@ -453,12 +517,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 { switch assembly.scope {
case .directMessage: case .directMessage:
context.upsertPrivateMessage(assembly.message, in: assembly.peerID) context.upsertPrivateMessage(message, in: assembly.peerID)
case .publicMesh: 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 +568,11 @@ final class ChatLiveVoiceCoordinator {
assembly.player?.stop() assembly.player?.stop()
try? assembly.fileHandle?.close() try? assembly.fileHandle?.close()
assembly.fileHandle = nil assembly.fileHandle = nil
assemblies.removeValue(forKey: assembly.burstID) assemblies.removeValue(forKey: assembly.key)
updatePublicTalkerIndicator() updatePublicTalkerIndicator()
removeBubble(of: assembly) removeBubble(of: assembly)
WaveformCache.shared.purge(url: assembly.fileURL) WaveformCache.shared.purge(url: assembly.fileURL)
try? FileManager.default.removeItem(at: assembly.fileURL) try? fileManager.removeItem(at: assembly.fileURL)
context.notifyUIChanged() context.notifyUIChanged()
} }
@@ -480,10 +580,10 @@ final class ChatLiveVoiceCoordinator {
private func rescheduleIdleTimeout(for assembly: Assembly) { private func rescheduleIdleTimeout(for assembly: Assembly) {
assembly.idleTimeout?.cancel() assembly.idleTimeout?.cancel()
let burstID = assembly.burstID let key = assembly.key
assembly.idleTimeout = Task { @MainActor [weak self] in assembly.idleTimeout = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: UInt64(TransportConfig.pttBurstEndTimeoutSeconds * 1_000_000_000)) 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. // Talker went silent/out of range without an END.
self.finalize(assembly) self.finalize(assembly)
} }
@@ -491,10 +591,10 @@ final class ChatLiveVoiceCoordinator {
private func scheduleGapRedrain(for assembly: Assembly) { private func scheduleGapRedrain(for assembly: Assembly) {
assembly.gapRedrain?.cancel() assembly.gapRedrain?.cancel()
let burstID = assembly.burstID let key = assembly.key
assembly.gapRedrain = Task { @MainActor [weak self] in assembly.gapRedrain = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: UInt64((Self.gapSkipSeconds + 0.05) * 1_000_000_000)) 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.drainInOrder(assembly)
self.finalizeIfComplete(assembly) self.finalizeIfComplete(assembly)
} }
@@ -521,21 +621,37 @@ final class ChatLiveVoiceCoordinator {
return Data(hexString: hex) return Data(hexString: hex)
} }
private static func makeIncomingURL(burstID: Data) -> URL? { private static let incomingSubdirectory = "\(MimeType.Category.audio.mediaDir)/incoming"
guard let base = try? FileManager.default.url(
for: .applicationSupportDirectory, /// The peer ID and scope in the name mirror the assembly key: colliding
in: .userDomainMask, /// burst IDs from different senders or from the same sender across DM
appropriateFor: nil, /// and public land on distinct files instead of truncating each other.
create: true /// `burstID(fromVoiceFileName:)` still rejects every `voice_live_*` name,
) else { return nil } /// so live captures can never absorb a note.
let directory = base private func makeIncomingURL(burstID: Data, peerID: PeerID, scope: VoiceBurstScope) -> URL? {
.appendingPathComponent("files", isDirectory: true) guard let directory = try? fileStore.incomingDirectory(subdirectory: Self.incomingSubdirectory) else { return nil }
.appendingPathComponent("\(MimeType.Category.audio.mediaDir)/incoming", isDirectory: true) let scopeTag = scope == .directMessage ? "dm" : "mesh"
do { return directory.appendingPathComponent("\(BLEIncomingFileStore.liveCapturePrefix)\(burstID.hexEncodedString())_\(peerID.id)_\(scopeTag).aac")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) }
} catch {
return nil /// 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")
} }
} }
@@ -6,6 +6,19 @@ import Foundation
import UIKit import UIKit
#endif #endif
struct LegacyPrivateMediaConsentRequest: Identifiable, Equatable {
let id: UUID
let peerID: PeerID
let peerName: String
let transferId: String
let messageID: String
}
struct PendingLegacyPrivateMediaConsent {
let request: LegacyPrivateMediaConsentRequest
let completion: @MainActor (Bool) -> Void
}
/// The narrow surface `ChatMediaTransferCoordinator` needs from its owner. /// The narrow surface `ChatMediaTransferCoordinator` needs from its owner.
/// ///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the /// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
@@ -43,7 +56,24 @@ protocol ChatMediaTransferContext: AnyObject {
func recordContentKey(_ key: String, timestamp: Date) func recordContentKey(_ key: String, timestamp: Date)
// MARK: Mesh file transfer // MARK: Mesh file transfer
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
func resolvePrivateMediaSendPolicy(
to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
)
func requestLegacyPrivateMediaConsent(
for peerID: PeerID,
transferId: String,
messageID: String,
completion: @escaping @MainActor (Bool) -> Void
)
func cancelLegacyPrivateMediaConsent(transferId: String, messageID: String)
func sendFilePrivate(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String,
allowLegacyFallback: Bool
)
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
func cancelTransfer(_ transferId: String) func cancelTransfer(_ transferId: String)
} }
@@ -59,8 +89,50 @@ extension ChatViewModel: ChatMediaTransferContext {
// other contexts or satisfied by existing `ChatViewModel` members. The // other contexts or satisfied by existing `ChatViewModel` members. The
// members below flatten mesh service accesses. // members below flatten mesh service accesses.
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) { func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
meshService.sendFilePrivate(packet, to: peerID, transferId: transferId) meshService.privateMediaSendPolicy(to: peerID)
}
func resolvePrivateMediaSendPolicy(
to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
) {
meshService.resolvePrivateMediaSendPolicy(to: peerID, completion: completion)
}
func requestLegacyPrivateMediaConsent(
for peerID: PeerID,
transferId: String,
messageID: String,
completion: @escaping @MainActor (Bool) -> Void
) {
enqueueLegacyPrivateMediaConsent(
for: peerID,
transferId: transferId,
messageID: messageID,
completion: completion
)
}
func cancelLegacyPrivateMediaConsent(transferId: String, messageID: String) {
invalidateLegacyPrivateMediaConsent(
transferId: transferId,
messageID: messageID
)
}
func sendFilePrivate(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String,
allowLegacyFallback: Bool
) {
meshService.sendFilePrivate(
packet,
to: peerID,
transferId: transferId,
allowLegacyFallback: allowLegacyFallback
)
} }
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) { func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
@@ -75,12 +147,19 @@ extension ChatViewModel: ChatMediaTransferContext {
@MainActor @MainActor
final class ChatMediaTransferCoordinator { final class ChatMediaTransferCoordinator {
private unowned let context: any ChatMediaTransferContext private unowned let context: any ChatMediaTransferContext
private let prepareVoiceNotePacket: @Sendable (URL) throws -> BitchatFilePacket
private(set) var transferIdToMessageIDs: [String: [String]] = [:] private(set) var transferIdToMessageIDs: [String: [String]] = [:]
private(set) var messageIDToTransferId: [String: String] = [:] private(set) var messageIDToTransferId: [String: String] = [:]
init(context: any ChatMediaTransferContext) { init(
context: any ChatMediaTransferContext,
prepareVoiceNotePacket: @escaping @Sendable (URL) throws -> BitchatFilePacket = {
try ChatMediaPreparation.prepareVoiceNotePacket(at: $0)
}
) {
self.context = context self.context = context
self.prepareVoiceNotePacket = prepareVoiceNotePacket
} }
func sendVoiceNote(at url: URL) { func sendVoiceNote(at url: URL) {
@@ -98,16 +177,28 @@ final class ChatMediaTransferCoordinator {
) )
let messageID = message.id let messageID = message.id
let transferId = makeTransferID(messageID: messageID) let transferId = makeTransferID(messageID: messageID)
// Own the transfer before detached preparation begins. Cancel/delete
// must be able to invalidate this exact invocation even while file I/O
// is still running off the main actor.
registerTransfer(transferId: transferId, messageID: messageID)
let prepareVoiceNotePacket = self.prepareVoiceNotePacket
Task.detached(priority: .userInitiated) { [weak self] in Task.detached(priority: .userInitiated) { [weak self] in
do { do {
let packet = try ChatMediaPreparation.prepareVoiceNotePacket(at: url) let packet = try prepareVoiceNotePacket(url)
await MainActor.run { [weak self] in await MainActor.run { [weak self] in
guard let self else { return } guard let self,
self.registerTransfer(transferId: transferId, messageID: messageID) self.isRegisteredTransfer(transferId, messageID: messageID) else {
return
}
if let peerID = targetPeer { if let peerID = targetPeer {
self.context.sendFilePrivate(packet, to: peerID, transferId: transferId) self.beginPrivateMediaSend(
packet,
to: peerID,
transferId: transferId,
messageID: messageID
)
} else { } else {
self.context.sendFileBroadcast(packet, transferId: transferId) self.context.sendFileBroadcast(packet, transferId: transferId)
} }
@@ -116,13 +207,19 @@ final class ChatMediaTransferCoordinator {
SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session) SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session)
try? FileManager.default.removeItem(at: url) try? FileManager.default.removeItem(at: url)
await MainActor.run { [weak self] in await MainActor.run { [weak self] in
guard let self else { return } guard let self,
self.isRegisteredTransfer(transferId, messageID: messageID) else {
return
}
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", comment: "Failure reason shown when a voice note exceeds the size limit")) self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", comment: "Failure reason shown when a voice note exceeds the size limit"))
} }
} catch { } catch {
SecureLogger.error("Voice note send failed: \(error)", category: .session) SecureLogger.error("Voice note send failed: \(error)", category: .session)
await MainActor.run { [weak self] in await MainActor.run { [weak self] in
guard let self else { return } guard let self,
self.isRegisteredTransfer(transferId, messageID: messageID) else {
return
}
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", comment: "Failure reason shown when a voice note could not be sent")) self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", comment: "Failure reason shown when a voice note could not be sent"))
} }
} }
@@ -193,7 +290,12 @@ final class ChatMediaTransferCoordinator {
let transferId = self.makeTransferID(messageID: messageID) let transferId = self.makeTransferID(messageID: messageID)
self.registerTransfer(transferId: transferId, messageID: messageID) self.registerTransfer(transferId: transferId, messageID: messageID)
if let peerID = targetPeer { if let peerID = targetPeer {
self.context.sendFilePrivate(prepared.packet, to: peerID, transferId: transferId) self.beginPrivateMediaSend(
prepared.packet,
to: peerID,
transferId: transferId,
messageID: messageID
)
} else { } else {
self.context.sendFileBroadcast(prepared.packet, transferId: transferId) self.context.sendFileBroadcast(prepared.packet, transferId: transferId)
} }
@@ -253,17 +355,127 @@ final class ChatMediaTransferCoordinator {
return message return message
} }
private func beginPrivateMediaSend(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String,
messageID: String
) {
continuePrivateMediaSend(
packet,
to: peerID,
transferId: transferId,
messageID: messageID,
policy: context.privateMediaSendPolicy(to: peerID)
)
}
private func continuePrivateMediaSend(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String,
messageID: String,
policy: PrivateMediaSendPolicy
) {
switch policy {
case .encrypted:
context.sendFilePrivate(
packet,
to: peerID,
transferId: transferId,
allowLegacyFallback: false
)
case .awaitingCapabilityProof:
context.resolvePrivateMediaSendPolicy(to: peerID) { [weak self] resolvedPolicy in
guard let self,
self.isRegisteredTransfer(transferId, messageID: messageID) else {
return
}
guard resolvedPolicy != .awaitingCapabilityProof else {
self.handleMediaSendFailure(
messageID: messageID,
reason: String(
localized: "content.delivery.reason.private_media_capability_unresolved",
defaultValue: "Could not confirm encrypted media support",
comment: "Failure reason when private-media capability negotiation did not resolve"
)
)
return
}
self.continuePrivateMediaSend(
packet,
to: peerID,
transferId: transferId,
messageID: messageID,
policy: resolvedPolicy
)
}
case .legacyRequiresConsent:
context.requestLegacyPrivateMediaConsent(
for: peerID,
transferId: transferId,
messageID: messageID
) { [weak self] approved in
guard let self else { return }
// Consent belongs to this exact placeholder/transfer. A late
// dialog callback after cancel/delete must never resurrect it.
guard self.messageIDToTransferId[messageID] == transferId,
self.transferIdToMessageIDs[transferId]?.contains(messageID) == true else {
return
}
guard approved else {
self.handleMediaSendFailure(
messageID: messageID,
reason: String(
localized: "content.delivery.reason.legacy_media_declined",
defaultValue: "Not sent without end-to-end encryption",
comment: "Failure reason after declining the warning for a legacy clear private-media send"
)
)
return
}
self.context.sendFilePrivate(
packet,
to: peerID,
transferId: transferId,
allowLegacyFallback: true
)
}
case .blockedDowngrade:
handleMediaSendFailure(
messageID: messageID,
reason: String(
localized: "content.delivery.reason.private_media_downgrade_blocked",
defaultValue: "Encrypted media required; ask this contact to upgrade",
comment: "Failure reason when a peer that previously supported encrypted media appears to downgrade"
)
)
}
}
func registerTransfer(transferId: String, messageID: String) { func registerTransfer(transferId: String, messageID: String) {
transferIdToMessageIDs[transferId, default: []].append(messageID) transferIdToMessageIDs[transferId, default: []].append(messageID)
messageIDToTransferId[messageID] = transferId messageIDToTransferId[messageID] = transferId
} }
private func isRegisteredTransfer(_ transferId: String, messageID: String) -> Bool {
messageIDToTransferId[messageID] == transferId
&& transferIdToMessageIDs[transferId]?.contains(messageID) == true
}
func makeTransferID(messageID: String) -> String { func makeTransferID(messageID: String) -> String {
"\(messageID)-\(UUID().uuidString)" "\(messageID)-\(UUID().uuidString)"
} }
func clearTransferMapping(for messageID: String) { func clearTransferMapping(for messageID: String) {
guard let transferId = messageIDToTransferId.removeValue(forKey: messageID) else { return } guard let transferId = messageIDToTransferId.removeValue(forKey: messageID) else { return }
context.cancelLegacyPrivateMediaConsent(
transferId: transferId,
messageID: messageID
)
guard var queue = transferIdToMessageIDs[transferId] else { return } guard var queue = transferIdToMessageIDs[transferId] else { return }
if !queue.isEmpty { if !queue.isEmpty {
@@ -298,6 +510,9 @@ final class ChatMediaTransferCoordinator {
guard let messageID = transferIdToMessageIDs[id]?.first else { return } guard let messageID = transferIdToMessageIDs[id]?.first else { return }
clearTransferMapping(for: messageID) clearTransferMapping(for: messageID)
context.removeMessage(withID: messageID, cleanupFile: true) context.removeMessage(withID: messageID, cleanupFile: true)
case .rejected(let id, let reason):
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
handleMediaSendFailure(messageID: messageID, reason: reason)
} }
} }
@@ -338,6 +553,13 @@ final class ChatMediaTransferCoordinator {
} }
func deleteMediaMessage(messageID: String) { func deleteMediaMessage(messageID: String) {
// Delete is also a send cancellation. In particular, an approved
// legacy-clear send may still be waiting on BLEService.messageQueue;
// removing only the UI mapping would let that deferred work transmit.
if let transferId = messageIDToTransferId[messageID],
transferIdToMessageIDs[transferId]?.first == messageID {
context.cancelTransfer(transferId)
}
clearTransferMapping(for: messageID) clearTransferMapping(for: messageID)
context.removeMessage(withID: messageID, cleanupFile: true) context.removeMessage(withID: messageID, cleanupFile: true)
} }
@@ -44,7 +44,10 @@ protocol ChatOutgoingContext: AnyObject {
func sendGeohash(context: ChatViewModel.GeoOutgoingContext) func sendGeohash(context: ChatViewModel.GeoOutgoingContext)
/// Ships the bridged (rendezvous) copy of a just-sent public mesh /// 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. /// 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) // MARK: Geohash identity (shared with the other contexts)
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
@@ -66,8 +69,8 @@ extension ChatViewModel: ChatOutgoingContext {
lastPublicActivityAt[key] = Date() lastPublicActivityAt[key] = Date()
} }
func bridgeOutgoingPublicMessage(_ content: String, messageID: String) { func bridgeOutgoingPublicMessage(_ content: String, senderPeerID: PeerID, timestamp: Date) {
BridgeService.shared.bridgeOutgoing(content: content, messageID: messageID) BridgeService.shared.bridgeOutgoing(content: content, senderPeerID: senderPeerID, timestamp: timestamp)
} }
} }
@@ -147,7 +150,7 @@ private extension ChatOutgoingCoordinator {
messageID: message.id, messageID: message.id,
timestamp: message.timestamp 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 /// Geohash sends mine a NIP-13 nonce tag first (off the main actor, see
@@ -83,14 +83,14 @@ protocol ChatPrivateConversationContext: AnyObject {
// MARK: Routing & acknowledgements // MARK: Routing & acknowledgements
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) 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 sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String)
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
// MARK: System messages // MARK: System messages
func addSystemMessage(_ content: String)
func addMeshOnlySystemMessage(_ content: String) func addMeshOnlySystemMessage(_ content: String)
/// Appends a local-only system line into a specific private thread /// Appends a local-only system line into a specific private thread
/// errors about a DM belong in that DM, not on the active timeline. /// errors about a DM belong in that DM, not on the active timeline.
@@ -161,7 +161,8 @@ extension ChatViewModel: ChatPrivateConversationContext {
messageRouter.sendPrivate(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) messageRouter.sendPrivate(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
} }
func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { @discardableResult
func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool {
messageRouter.sendReadReceipt(receipt, to: peerID) messageRouter.sendReadReceipt(receipt, to: peerID)
} }
@@ -329,8 +330,12 @@ final class ChatPrivateConversationCoordinator {
func sendGeohashDM(_ content: String, to peerID: PeerID) { func sendGeohashDM(_ content: String, to peerID: PeerID) {
guard case .location(let channel) = context.activeChannel else { guard case .location(let channel) = context.activeChannel else {
context.addSystemMessage( // The failure happened inside a geoDM thread surface it there,
String(localized: "system.location.not_in_channel", comment: "System message when attempting to send without being in a location channel") // not on the public timeline (matches the sibling blocked/unknown
// errors routed into the thread by #1415).
context.addLocalPrivateSystemMessage(
String(localized: "system.location.not_in_channel", comment: "System message when attempting to send without being in a location channel"),
to: peerID
) )
return return
} }
@@ -417,9 +417,12 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
/// rate limit; low/no-PoW events keep the strict limits so old clients /// rate limit; low/no-PoW events keep the strict limits so old clients
/// still get through at normal rates. /// still get through at normal rates.
/// Identity keys of the archived echoes seeded into the mesh timeline at /// Identity keys of the archived echoes seeded into the mesh timeline at
/// launch. The mesh wire format carries no stable message ID, so a /// launch. Re-synced copies of others' messages now arrive with the same
/// re-synced copy of an already-rendered echo arrives with a fresh UUID /// derived stable ID (`MeshMessageIdentity`), so the store's insert-by-ID
/// this content identity is the only way to recognize it. /// catches those but the archive-restored rows themselves carry
/// `echo-`-prefixed IDs, and self echoes get fresh UUIDs, so this content
/// identity remains the way to recognize a live copy of an
/// already-rendered echo.
private var archivedEchoKeys = Set<String>() private var archivedEchoKeys = Set<String>()
func registerArchivedEcho(senderPeerID: PeerID?, timestamp: Date, content: String) { func registerArchivedEcho(senderPeerID: PeerID?, timestamp: Date, content: String) {
@@ -407,6 +407,13 @@ private extension ChatTransportEventCoordinator {
case .voiceFrame: case .voiceFrame:
context.handleVoiceFramePayload(from: peerID, payload: payload, timestamp: timestamp) context.handleVoiceFramePayload(from: peerID, payload: payload, timestamp: timestamp)
case .privateFile, .authenticatedPeerState:
// BLEService validates and persists decrypted private files before
// emitting a normal `.messageReceived` event, and consumes peer
// state inside the transport. Neither payload crosses this
// UI-facing typed-payload fallback.
break
} }
} }
+121 -1
View File
@@ -347,6 +347,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
@Published var showBluetoothAlert = false @Published var showBluetoothAlert = false
@Published var bluetoothAlertMessage = "" @Published var bluetoothAlertMessage = ""
@Published var bluetoothState: CBManagerState = .unknown @Published var bluetoothState: CBManagerState = .unknown
@Published private(set) var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest?
private var pendingLegacyPrivateMediaConsents: [PendingLegacyPrivateMediaConsent] = []
private func performDeliveryUpdate(_ update: @escaping @MainActor (ChatDeliveryCoordinator) -> Void) { private func performDeliveryUpdate(_ update: @escaping @MainActor (ChatDeliveryCoordinator) -> Void) {
if Thread.isMainThread { if Thread.isMainThread {
@@ -698,12 +700,30 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
conversations.conversationsByID[conversationID]?.containsMessage(withID: messageID) ?? false conversations.conversationsByID[conversationID]?.containsMessage(withID: messageID) ?? false
} }
@MainActor
func bridgeInjectedPublicMessageIsPresent(withID messageID: String) -> Bool {
publicMessagePipeline.containsMessage(withID: messageID) ||
publicConversationContainsMessage(withID: messageID, in: .mesh)
}
/// Removes a message by ID from whichever public conversation contains /// Removes a message by ID from whichever public conversation contains
/// it. Returns the removed message, if any. /// it. Returns the removed message, if any.
@MainActor @MainActor
@discardableResult @discardableResult
func removePublicMessage(withID messageID: String) -> BitchatMessage? { func removePublicMessage(withID messageID: String) -> BitchatMessage? {
conversations.removePublicMessage(withID: messageID) publicMessagePipeline.removeMessage(withID: messageID)
return conversations.removePublicMessage(withID: messageID)
}
/// Replaces an unauthenticated bridge alias with a later authenticated
/// radio row. In addition to both storage layers, clear the content-window
/// marker written by an already-flushed alias or it would suppress the
/// genuine row during the next public-message batch.
@MainActor
func removeBridgeInjectedPublicMessage(withID messageID: String) {
publicMessagePipeline.removeMessage(withID: messageID)
guard let removed = conversations.removePublicMessage(withID: messageID) else { return }
deduplicationService.forgetContent(removed.content, ifRecordedAt: removed.timestamp)
} }
/// Removes every message matching `predicate` from a geohash /// Removes every message matching `predicate` from a geohash
@@ -1138,6 +1158,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
func panicClearAllData() { func panicClearAllData() {
// Messages are processed immediately - nothing to flush // Messages are processed immediately - nothing to flush
// Deny and release any clear-media confirmations before identities,
// message state, and local files are wiped.
cancelAllLegacyPrivateMediaConsents()
// Clear all messages (public timelines and private chats live in the // Clear all messages (public timelines and private chats live in the
// single-writer ConversationStore; the derived `messages` view and // single-writer ConversationStore; the derived `messages` view and
// the legacy mirror empty with it) // the legacy mirror empty with it)
@@ -1173,6 +1197,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// our own queued outbox, the carried public history, and the // our own queued outbox, the carried public history, and the
// counters describing all of it // counters describing all of it
CourierStore.shared.wipe() CourierStore.shared.wipe()
BridgeCourierService.shared.wipe()
messageRouter.wipeOutbox() messageRouter.wipeOutbox()
GossipMessageArchive.wipeDefault() GossipMessageArchive.wipeDefault()
StoreAndForwardMetrics.shared.reset() StoreAndForwardMetrics.shared.reset()
@@ -1754,6 +1779,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
/// Handle incoming public message /// Handle incoming public message
@MainActor @MainActor
func handlePublicMessage(_ message: BitchatMessage) { func handlePublicMessage(_ message: BitchatMessage) {
// Bridge hints are unauthenticated and may never suppress a genuine
// BLE sender. Once the radio packet has passed BLE signature checks,
// replace any earlier bridge alias before this row is enqueued.
if !message.isBridged,
let senderPeerID = message.senderPeerID,
!senderPeerID.isGeoChat {
BridgeService.shared.handleAuthenticatedRadioMessage(messageID: message.id)
}
// A finalized voice note whose burst already streamed in live swaps // A finalized voice note whose burst already streamed in live swaps
// into the existing bubble instead of appearing twice. // into the existing bubble instead of appearing twice.
if liveVoiceCoordinator.absorbFinalizedVoiceNote(message) { return } if liveVoiceCoordinator.absorbFinalizedVoiceNote(message) { return }
@@ -1777,4 +1810,91 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
publicConversationCoordinator.sendHapticFeedback(for: message) publicConversationCoordinator.sendHapticFeedback(for: message)
} }
} }
@MainActor
extension ChatViewModel {
func enqueueLegacyPrivateMediaConsent(
for peerID: PeerID,
transferId: String,
messageID: String,
completion: @escaping @MainActor (Bool) -> Void
) {
let request = LegacyPrivateMediaConsentRequest(
id: UUID(),
peerID: peerID,
peerName: nicknameForPeer(peerID),
transferId: transferId,
messageID: messageID
)
pendingLegacyPrivateMediaConsents.append(PendingLegacyPrivateMediaConsent(
request: request,
completion: completion
))
if legacyPrivateMediaConsentRequest == nil {
legacyPrivateMediaConsentRequest = request
}
}
func resolveLegacyPrivateMediaConsent(requestID: UUID, approved: Bool) {
// SwiftUI may report both the selected button and the presentation
// binding's dismissal. Resolve only the exact request that was shown;
// a duplicate callback for it must not consume the next queued send.
guard legacyPrivateMediaConsentRequest?.id == requestID,
pendingLegacyPrivateMediaConsents.first?.request.id == requestID else {
return
}
let resolved = pendingLegacyPrivateMediaConsents.removeFirst()
// Drive the boolean presentation state through false before showing
// the next queued per-send warning. Otherwise SwiftUI sees truetrue,
// closes the first dialog, and never presents the second.
legacyPrivateMediaConsentRequest = nil
resolved.completion(approved)
presentNextLegacyPrivateMediaConsentDeferred()
}
func invalidateLegacyPrivateMediaConsent(transferId: String, messageID: String) {
let invalidatedIDs = Set(
pendingLegacyPrivateMediaConsents.compactMap { pending -> UUID? in
let request = pending.request
return request.transferId == transferId && request.messageID == messageID
? request.id
: nil
}
)
guard !invalidatedIDs.isEmpty else { return }
pendingLegacyPrivateMediaConsents.removeAll {
invalidatedIDs.contains($0.request.id)
}
if let currentID = legacyPrivateMediaConsentRequest?.id,
invalidatedIDs.contains(currentID) {
legacyPrivateMediaConsentRequest = nil
presentNextLegacyPrivateMediaConsentDeferred()
}
}
func cancelAllLegacyPrivateMediaConsents() {
let pending = pendingLegacyPrivateMediaConsents
pendingLegacyPrivateMediaConsents.removeAll()
legacyPrivateMediaConsentRequest = nil
for item in pending {
item.completion(false)
}
}
private func presentNextLegacyPrivateMediaConsentDeferred() {
guard legacyPrivateMediaConsentRequest == nil,
let nextRequestID = pendingLegacyPrivateMediaConsents.first?.request.id else {
return
}
DispatchQueue.main.async { [weak self] in
guard let self,
self.legacyPrivateMediaConsentRequest == nil,
self.pendingLegacyPrivateMediaConsents.first?.request.id == nextRequestID else {
return
}
self.legacyPrivateMediaConsentRequest = self.pendingLegacyPrivateMediaConsents[0].request
}
}
}
// End of ChatViewModel class // End of ChatViewModel class
@@ -210,11 +210,19 @@ private extension ChatViewModelBootstrapper {
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiArchivedEchoLoadDelaySeconds) { [weak viewModel] in DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiArchivedEchoLoadDelaySeconds) { [weak viewModel] in
guard let viewModel else { return } guard let viewModel else { return }
viewModel.meshService.collectArchivedPublicMessages { [weak viewModel] allArchived in viewModel.meshService.collectArchivedPublicMessages { [weak viewModel] allArchived in
guard let viewModel else { return }
// A previous /clear dismissed everything heard up to its // A previous /clear dismissed everything heard up to its
// watermark; only newer archive entries come back. // watermark; only newer archive entries come back. Blocking a
// peer purges their carried messages from the archive at
// block time (when the fingerprintpeerID mapping is known);
// the filter here is defense-in-depth for entries that slip
// past the purge (e.g. re-synced from a nearby peer), and it
// only resolves connected peers or favorites.
let clearedThrough = MeshEchoSettings.clearedThrough ?? .distantPast let clearedThrough = MeshEchoSettings.clearedThrough ?? .distantPast
let archived = allArchived.filter { $0.timestamp > clearedThrough } let archived = allArchived.filter {
guard let viewModel, !archived.isEmpty else { return } $0.timestamp > clearedThrough && !viewModel.isPeerBlocked($0.senderPeerID)
}
guard !archived.isEmpty else { return }
// Seed only an untouched timeline: with live rows already // Seed only an untouched timeline: with live rows already
// present (or after /clear) splicing history back in would // present (or after /clear) splicing history back in would
// be wrong. // be wrong.
@@ -456,6 +464,12 @@ private extension ChatViewModelBootstrapper {
isBridged: true isBridged: true
)) ))
} }
bridge.removeInjectedInbound = { [weak viewModel] messageID in
viewModel?.removeBridgeInjectedPublicMessage(withID: messageID)
}
bridge.isInjectedInboundPresent = { [weak viewModel] messageID in
viewModel?.bridgeInjectedPublicMessageIsPresent(withID: messageID) ?? false
}
bridge.isMessageSeenLocally = { [weak viewModel] messageID in bridge.isMessageSeenLocally = { [weak viewModel] messageID in
viewModel?.publicConversationContainsMessage(withID: messageID, in: .mesh) ?? false viewModel?.publicConversationContainsMessage(withID: messageID, in: .mesh) ?? false
} }
@@ -524,11 +538,17 @@ private extension ChatViewModelBootstrapper {
let courier = BridgeCourierService.shared let courier = BridgeCourierService.shared
courier.bridgeEnabled = { BridgeService.shared.isEnabled } courier.bridgeEnabled = { BridgeService.shared.isEnabled }
courier.relaysConnected = { NostrRelayManager.shared.isConnected } // A geo/custom relay does not make a global courier drop durable.
courier.publishEvent = { event in // Require an actually connected default (DM) relay so `sendEvent`
// writes to at least one intended relay instead of only entering its
// process-local pending queue.
courier.relaysConnected = { NostrRelayManager.shared.isDMRelayConnected }
courier.publishEvent = { event, completion in
// Default (DM) relays: drops need the standing global relay set, // Default (DM) relays: drops need the standing global relay set,
// not geo relays sender and recipient share no cell. // not geo relays sender and recipient share no cell.
NostrRelayManager.shared.sendEvent(event) // This confirmed path never falls back to the volatile relay
// queue; bridge dedup is committed only after NIP-20 OK.
NostrRelayManager.shared.sendEventImmediately(event, completion: completion)
} }
courier.openSubscription = { tagsHex in courier.openSubscription = { tagsHex in
NostrRelayManager.shared.unsubscribe(id: Self.courierDropSubscriptionID) NostrRelayManager.shared.unsubscribe(id: Self.courierDropSubscriptionID)
@@ -556,20 +576,28 @@ private extension ChatViewModelBootstrapper {
bleService?.sealBridgeCourierEnvelope(content, messageID: messageID, recipientNoiseKey: recipientKey) bleService?.sealBridgeCourierEnvelope(content, messageID: messageID, recipientNoiseKey: recipientKey)
} }
courier.openEnvelope = { [weak bleService] envelope in courier.openEnvelope = { [weak bleService] envelope in
bleService?.openBridgedCourierEnvelope(envelope) bleService?.openBridgedCourierEnvelope(envelope) ?? false
} }
courier.deliverToPeer = { [weak bleService] envelope, peerID in courier.deliverToPeer = { [weak bleService] envelope, peerID in
bleService?.deliverBridgedEnvelope(envelope, to: peerID) bleService?.deliverBridgedEnvelope(envelope, to: peerID) ?? false
} }
courier.heldEnvelopes = { cooldown in courier.heldEnvelopes = { cooldown in
CourierStore.shared.envelopesForBridgePublish(cooldown: cooldown) CourierStore.shared.envelopesForBridgePublish(cooldown: cooldown)
} }
courier.markHeldEnvelopePublished = { envelope in
viewModel.messageRouter.bridgeCourierDeposit = { content, messageID, recipientKey in CourierStore.shared.markBridgePublished(envelope)
BridgeCourierService.shared.depositDrop(content: content, messageID: messageID, recipientNoiseKey: recipientKey)
} }
// (depositDrop's Bool flows back so a dropped message shows the
// "carried" state a drop's delivery ack can't return over radio.) viewModel.messageRouter.bridgeCourierDeposit = { content, messageID, recipientKey, completion in
BridgeCourierService.shared.depositDrop(
content: content,
messageID: messageID,
recipientNoiseKey: recipientKey,
completion: completion
)
}
// The completion flows back only after a default relay accepts the
// event, so a rejected or unacknowledged write never becomes carried.
viewModel.messageRouter.startBridgeDepositSweep() viewModel.messageRouter.startBridgeDepositSweep()
bleService.onVerifiedPeerAnnounce = { _ in bleService.onVerifiedPeerAnnounce = { _ in
Task { @MainActor in Task { @MainActor in
@@ -580,7 +608,7 @@ private extension ChatViewModelBootstrapper {
// Relay connectivity gates everything; refresh (re)opens or closes. // Relay connectivity gates everything; refresh (re)opens or closes.
// Deduped: refresh() resubscribes, and the raw publisher re-emits on // Deduped: refresh() resubscribes, and the raw publisher re-emits on
// every relay state recompute (6x in 300ms in field logs). // every relay state recompute (6x in 300ms in field logs).
NostrRelayManager.shared.$isConnected NostrRelayManager.shared.$isDMRelayConnected
.removeDuplicates() .removeDuplicates()
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { _ in BridgeCourierService.shared.refresh() } .sink { _ in BridgeCourierService.shared.refresh() }
+72 -10
View File
@@ -37,25 +37,47 @@ final class GeoChannelCoordinator {
private weak var context: (any GeoChannelContext)? private weak var context: (any GeoChannelContext)?
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var regionalGeohashes: [String] = [] private var regionalChannels: [GeohashChannel] = []
private var bookmarkedGeohashes: [String] = [] private var bookmarkedGeohashes: [String] = []
private var permissionState: LocationChannelManager.PermissionState
private var locationNotesEnabled: Bool
/// Mirrors `NearbyNotesCounter.revealed` (injectable for tests): the
/// session's one explicit notes act. Until it happens, background
/// sampling must not include the building-precision cell see
/// `sampledRegionalGeohashes`.
private var notesRevealed = false
private let notesRevealedPublisher: AnyPublisher<Bool, Never>
private let locationNotesSettingsPublisher: AnyPublisher<Bool, Never>
init( init(
locationManager: LocationChannelManager? = nil, locationManager: LocationChannelManager? = nil,
bookmarksStore: GeohashBookmarksStore? = nil, bookmarksStore: GeohashBookmarksStore? = nil,
torManager: TorManager? = nil, torManager: TorManager? = nil,
notesRevealed: AnyPublisher<Bool, Never>? = nil,
locationNotesEnabled: Bool? = nil,
locationNotesSettings: AnyPublisher<Bool, Never>? = nil,
context: any GeoChannelContext context: any GeoChannelContext
) { ) {
self.locationManager = locationManager ?? Self.defaultLocationManager() let resolvedLocationManager = locationManager ?? Self.defaultLocationManager()
self.locationManager = resolvedLocationManager
self.bookmarksStore = bookmarksStore ?? GeohashBookmarksStore.shared self.bookmarksStore = bookmarksStore ?? GeohashBookmarksStore.shared
self.torManager = torManager ?? Self.defaultTorManager() self.torManager = torManager ?? Self.defaultTorManager()
self.permissionState = resolvedLocationManager.permissionState
self.locationNotesEnabled = locationNotesEnabled ?? LocationNotesSettings.enabled
self.notesRevealedPublisher = notesRevealed
?? NearbyNotesCounter.shared.$revealed.eraseToAnyPublisher()
self.locationNotesSettingsPublisher = locationNotesSettings
?? NotificationCenter.default
.publisher(for: LocationNotesSettings.didChangeNotification)
.map { _ in LocationNotesSettings.enabled }
.eraseToAnyPublisher()
self.context = context self.context = context
start() start()
} }
func start() { func start() {
regionalGeohashes = locationManager.availableChannels.map { $0.geohash } regionalChannels = locationManager.availableChannels
bookmarkedGeohashes = bookmarksStore.bookmarks bookmarkedGeohashes = bookmarksStore.bookmarks
locationManager.$selectedChannel locationManager.$selectedChannel
@@ -72,7 +94,30 @@ final class GeoChannelCoordinator {
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] channels in .sink { [weak self] channels in
guard let self else { return } guard let self else { return }
self.regionalGeohashes = channels.map { $0.geohash } self.regionalChannels = channels
self.updateSampling()
}
.store(in: &cancellables)
// Revealing the nearby-notes counter is the session's explicit notes
// act; it widens sampling to include the building cell (below).
notesRevealedPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] revealed in
guard let self, self.notesRevealed != revealed else { return }
self.notesRevealed = revealed
self.updateSampling()
}
.store(in: &cancellables)
// The location-notes preference is a live privacy kill switch. It
// removes the device-derived building cell even if the session was
// previously revealed. Explicit bookmarks remain eligible below.
locationNotesSettingsPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] enabled in
guard let self, self.locationNotesEnabled != enabled else { return }
self.locationNotesEnabled = enabled
self.updateSampling() self.updateSampling()
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -89,10 +134,15 @@ final class GeoChannelCoordinator {
locationManager.$permissionState locationManager.$permissionState
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] state in .sink { [weak self] state in
guard let self, state == .authorized else { return } guard let self else { return }
Task { @MainActor [weak self] in self.permissionState = state
self?.locationManager.refreshChannels() if state == .authorized {
self.locationManager.refreshChannels()
} }
// Cached channels outlive authorization by design. Recompute
// regardless of direction so revocation tears down regional
// sampling instead of continuing from stale coordinates.
self.updateSampling()
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -102,9 +152,22 @@ final class GeoChannelCoordinator {
updateSampling() updateSampling()
} }
/// Regional geohashes eligible for background sampling. The
/// building-precision (precision-8) cell identifies a single address, so
/// sampling it passively would leak the same location signal the
/// nearby-notes tap-to-reveal exists to gate it joins only after the
/// session's explicit notes act. The coarser levels (block and up) keep
/// the nearby-conversation hint and channel participant counts working.
/// Bookmarks are exempt: bookmarking a geohash is itself explicit.
private var sampledRegionalGeohashes: [String] {
guard permissionState == .authorized else { return [] }
return regionalChannels
.filter { (notesRevealed && locationNotesEnabled) || $0.level != .building }
.map { $0.geohash }
}
private func updateSampling() { private func updateSampling() {
let union = Array(Set(regionalGeohashes).union(bookmarkedGeohashes)) let union = Array(Set(sampledRegionalGeohashes).union(bookmarkedGeohashes))
Task { @MainActor in
guard !union.isEmpty else { guard !union.isEmpty else {
context?.endGeohashSampling() context?.endGeohashSampling()
return return
@@ -115,7 +178,6 @@ final class GeoChannelCoordinator {
context?.endGeohashSampling() context?.endGeohashSampling()
} }
} }
}
func refreshSampling() { func refreshSampling() {
updateSampling() updateSampling()
@@ -307,7 +307,7 @@ final class NostrInboundPipeline {
// claiming to be group traffic over Nostr is ignored. // claiming to be group traffic over Nostr is ignored.
// Live voice is mesh-only: latency and relay cost make it // Live voice is mesh-only: latency and relay cost make it
// meaningless over Nostr. // meaningless over Nostr.
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame: case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState:
break break
} }
} }
@@ -363,7 +363,7 @@ final class NostrInboundPipeline {
// claiming to be group traffic over Nostr is ignored. // claiming to be group traffic over Nostr is ignored.
// Live voice is mesh-only: latency and relay cost make it // Live voice is mesh-only: latency and relay cost make it
// meaningless over Nostr. // meaningless over Nostr.
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame: case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState:
break break
} }
} }
@@ -446,7 +446,7 @@ final class NostrInboundPipeline {
// in v1; group traffic over Nostr is ignored. // in v1; group traffic over Nostr is ignored.
// Live voice is mesh-only: latency and relay cost make it // Live voice is mesh-only: latency and relay cost make it
// meaningless over Nostr. // meaningless over Nostr.
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame: case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState:
break break
} }
} }
@@ -60,6 +60,21 @@ final class PublicMessagePipeline {
scheduleFlush() scheduleFlush()
} }
/// Discards an uncommitted row by ID. Bridge-first/radio-second dedup uses
/// this before inserting the authenticated radio copy, so the ~80 ms UI
/// batch cannot resurrect the replaced bridge alias after store removal.
func removeMessage(withID messageID: String) {
buffer.removeAll { $0.message.id == messageID }
if buffer.isEmpty {
timer?.invalidate()
timer = nil
}
}
func containsMessage(withID messageID: String) -> Bool {
buffer.contains { $0.message.id == messageID }
}
func flushIfNeeded() { func flushIfNeeded() {
flushBuffer() flushBuffer()
} }
@@ -63,6 +63,10 @@ final class VoiceRecordingViewModel: ObservableObject {
/// live push-to-talk session when the current DM peer can hear it now. /// live push-to-talk session when the current DM peer can hear it now.
var sessionProvider: () -> VoiceCaptureSession = { VoiceNoteCaptureSession() } var sessionProvider: () -> VoiceCaptureSession = { VoiceNoteCaptureSession() }
private var activeSession: VoiceCaptureSession? private var activeSession: VoiceCaptureSession?
/// Monotonic press identity. A slow permission/start/finalize task from an
/// older hold may still deliver its file, but it must never mutate the UI
/// state of a newer hold.
private var holdGeneration: UInt64 = 0
func formattedDuration(for date: Date) -> String { func formattedDuration(for date: Date) -> String {
let clamped = max(0, state.duration(for: date)) let clamped = max(0, state.duration(for: date))
@@ -75,13 +79,18 @@ final class VoiceRecordingViewModel: ObservableObject {
func start(shouldShow: Bool) { func start(shouldShow: Bool) {
guard shouldShow, state == .idle else { return } guard shouldShow, state == .idle else { return }
holdGeneration &+= 1
let generation = holdGeneration
let session = sessionProvider() let session = sessionProvider()
SecureLogger.info("PTT: mic hold began (backend: \(session.isLive ? "live" : "classic"))", category: .session) SecureLogger.info("PTT: mic hold began (backend: \(session.isLive ? "live" : "classic"))", category: .session)
activeSession = session activeSession = session
state = .requestingPermission state = .requestingPermission
Task { Task {
let granted = await session.requestPermission() let granted = await session.requestPermission()
guard state == .requestingPermission, activeSession === session else { return } guard generation == holdGeneration,
state == .requestingPermission,
activeSession === session
else { return }
guard granted else { guard granted else {
state = .permissionDenied state = .permissionDenied
activeSession = nil activeSession = nil
@@ -90,16 +99,36 @@ final class VoiceRecordingViewModel: ObservableObject {
state = .preparing state = .preparing
do { do {
try await session.start() try await session.start()
guard state == .preparing, activeSession === session else { guard generation == holdGeneration,
state == .preparing,
activeSession === session
else {
await session.cancel() await session.cancel()
return return
} }
state = .recording(startDate: Date()) state = .recording(startDate: Date())
isLiveStreaming = session.isLive isLiveStreaming = session.isLive
} catch VoiceRecorder.RecorderError.recordingInProgress {
// The previous classic hold may still be in its intentional
// finalize-padding window. This press owns no recorder, so
// its owner-scoped cancel is harmless; return to idle instead
// of surfacing a false capture failure while the prior note
// finishes and delivers normally.
SecureLogger.info("Voice recording start deferred while the previous hold finalizes", category: .session)
await session.cancel()
guard generation == holdGeneration,
state == .preparing,
activeSession === session
else { return }
activeSession = nil
state = .idle
} catch { } catch {
SecureLogger.error("Voice recording failed to start: \(error)", category: .session) SecureLogger.error("Voice recording failed to start: \(error)", category: .session)
await session.cancel() await session.cancel()
guard state == .preparing, activeSession === session else { return } guard generation == holdGeneration,
state == .preparing,
activeSession === session
else { return }
// The live engine and the classic recorder are separate // The live engine and the classic recorder are separate
// capture stacks: when the live one hits an audio-route // capture stacks: when the live one hits an audio-route
// glitch, fall back within the same hold so the user still // glitch, fall back within the same hold so the user still
@@ -109,7 +138,10 @@ final class VoiceRecordingViewModel: ObservableObject {
activeSession = fallback activeSession = fallback
do { do {
try await fallback.start() try await fallback.start()
guard state == .preparing, activeSession === fallback else { guard generation == holdGeneration,
state == .preparing,
activeSession === fallback
else {
await fallback.cancel() await fallback.cancel()
return return
} }
@@ -120,7 +152,7 @@ final class VoiceRecordingViewModel: ObservableObject {
} catch { } catch {
SecureLogger.error("Voice recording fallback failed to start: \(error)", category: .session) SecureLogger.error("Voice recording fallback failed to start: \(error)", category: .session)
await fallback.cancel() await fallback.cancel()
guard state == .preparing else { return } guard generation == holdGeneration, state == .preparing else { return }
} }
} }
activeSession = nil activeSession = nil
@@ -142,6 +174,7 @@ final class VoiceRecordingViewModel: ObservableObject {
state = .idle state = .idle
isLiveStreaming = false isLiveStreaming = false
let session = activeSession let session = activeSession
let generation = holdGeneration
activeSession = nil activeSession = nil
guard case .recording(let startDate) = previousState, let completion, let session else { guard case .recording(let startDate) = previousState, let completion, let session else {
@@ -159,7 +192,7 @@ final class VoiceRecordingViewModel: ObservableObject {
isValidRecording(at: url, duration: finalDuration) { isValidRecording(at: url, duration: finalDuration) {
completion(url) completion(url)
} else { } else {
guard state == .idle else { return } guard generation == holdGeneration, state == .idle else { return }
state = .error( state = .error(
message: finalDuration < VoiceRecorder.minRecordingDuration message: finalDuration < VoiceRecorder.minRecordingDuration
? "Recording is too short." ? "Recording is too short."
@@ -24,6 +24,7 @@ struct MeshEmptyStateView: View {
@EnvironmentObject private var peerListModel: PeerListModel @EnvironmentObject private var peerListModel: PeerListModel
@ObservedObject private var activityTracker = GeohashChatActivityTracker.shared @ObservedObject private var activityTracker = GeohashChatActivityTracker.shared
@ObservedObject private var sightingsTracker = MeshSightingsTracker.shared @ObservedObject private var sightingsTracker = MeshSightingsTracker.shared
@ObservedObject private var nearbyNotes = NearbyNotesCounter.shared
@ThemedPalette private var palette @ThemedPalette private var palette
@@ -38,6 +39,7 @@ struct MeshEmptyStateView: View {
static let meshIntro = String(localized: "content.empty.mesh_intro", comment: "First line of the empty mesh timeline explaining what the mesh channel is") static let meshIntro = String(localized: "content.empty.mesh_intro", comment: "First line of the empty mesh timeline explaining what the mesh channel is")
static let switchHint = String(localized: "content.empty.switch_hint", comment: "Empty timeline hint pointing at the channel switcher and the help screen") static let switchHint = String(localized: "content.empty.switch_hint", comment: "Empty timeline hint pointing at the channel switcher and the help screen")
static let sightingsOne = String(localized: "content.empty.sightings_one", comment: "Empty mesh timeline stat when exactly one device came within range today") static let sightingsOne = String(localized: "content.empty.sightings_one", comment: "Empty mesh timeline stat when exactly one device came within range today")
static let checkNotes = String(localized: "content.empty.check_notes", comment: "Empty mesh timeline action that starts looking for notes left at this place; before tapping, no lookup runs")
static func sightingsMany(_ count: Int) -> String { static func sightingsMany(_ count: Int) -> String {
String( String(
@@ -80,6 +82,9 @@ struct MeshEmptyStateView: View {
if let conversation = nearbyConversation { if let conversation = nearbyConversation {
conversationHint(conversation) conversationHint(conversation)
} }
if showsCheckNotesHint {
checkNotesHint
}
} else { } else {
// The radar + tally already say "scanning, nobody yet", so // The radar + tally already say "scanning, nobody yet", so
// the narration stays to two lines with the live hint after // the narration stays to two lines with the live hint after
@@ -89,6 +94,9 @@ struct MeshEmptyStateView: View {
if let conversation = nearbyConversation { if let conversation = nearbyConversation {
conversationHint(conversation) conversationHint(conversation)
} }
if showsCheckNotesHint {
checkNotesHint
}
// The radar centers in whatever space is left below the // The radar centers in whatever space is left below the
// text the flexible spacers split it evenly. // text the flexible spacers split it evenly.
@@ -100,7 +108,11 @@ struct MeshEmptyStateView: View {
} }
} }
.frame(minHeight: compact ? 0 : fillHeight, alignment: .top) .frame(minHeight: compact ? 0 : fillHeight, alignment: .top)
.onReceive(refreshTimer) { _ in refreshTick += 1 } .onReceive(refreshTimer) { _ in
refreshTick += 1
// Roll the tally over if the local day changed while idle.
sightingsTracker.refreshForDisplay()
}
} }
/// The radar with today's tally as its caption the stat belongs to /// The radar with today's tally as its caption the stat belongs to
@@ -123,6 +135,31 @@ private extension MeshEmptyStateView {
activityTracker.mostActiveConversation(among: locationChannelsModel.availableChannels) activityTracker.mostActiveConversation(among: locationChannelsModel.availableChannels)
} }
/// Tap-to-reveal: the nearby-notes counter never subscribes on its own
/// looking at the mesh timeline must not open a building-precision relay
/// REQ (a passive location side-channel). This static line is the one
/// explicit act that unlocks it; nothing touches the network until the
/// tap. It only renders when location permission is already granted
/// (the tap never prompts, so without permission it would dead-end
/// silently). Once revealed it yields to today's live strip and count,
/// and the app-info setting stays the kill switch.
var showsCheckNotesHint: Bool {
nearbyNotes.offersRevealHint(permissionState: locationChannelsModel.permissionState)
}
var checkNotesHint: some View {
Button {
NearbyNotesCounter.shared.reveal()
} label: {
actionLine("📍 \(Strings.checkNotes)")
.contentShape(Rectangle())
}
.buttonStyle(.plain)
// The visual label carries decorative asterisks and an emoji; expose
// just the localized action text to assistive tech.
.accessibilityLabel(Strings.checkNotes)
}
var sightingsText: String { var sightingsText: String {
sightingsTracker.todayCount == 1 sightingsTracker.todayCount == 1
? Strings.sightingsOne ? Strings.sightingsOne
@@ -131,11 +131,16 @@ struct TextMessageView: View {
} }
// Collapse the revealed caption when the status advances (e.g. // Collapse the revealed caption when the status advances (e.g.
// sending sent delivered) so a detail opened for one state // sending sent delivered) so a detail opened for one state
// doesn't linger and silently morph into another. // doesn't linger and silently morph into another. Guarded write:
// under a message storm many rows change status within one frame,
// and an unconditional state write per change trips SwiftUI's
// "tried to update multiple times per frame" re-entrancy warning.
.onChange(of: deliveryStatus) { _ in .onChange(of: deliveryStatus) { _ in
if showDeliveryDetail {
showDeliveryDetail = false showDeliveryDetail = false
} }
} }
}
} }
// Wrapped in #if DEBUG because the preview depends on _PreviewHelpers // Wrapped in #if DEBUG because the preview depends on _PreviewHelpers
+58
View File
@@ -36,6 +36,7 @@ struct ContentPeopleSheetView: View {
#endif #endif
var body: some View { var body: some View {
let legacyConsentRequest = conversationUIModel.legacyPrivateMediaConsentRequest
NavigationStack { NavigationStack {
Group { Group {
if privateConversationModel.selectedPeerID != nil { if privateConversationModel.selectedPeerID != nil {
@@ -97,6 +98,63 @@ struct ContentPeopleSheetView: View {
} }
.themedSheetBackground() .themedSheetBackground()
.foregroundColor(palette.primary) .foregroundColor(palette.primary)
.confirmationDialog(
String(
localized: "content.private_media.legacy_warning.title",
defaultValue: "Send without end-to-end encryption?",
comment: "Title warning before sending private media to an older client in a clear signed envelope"
),
isPresented: Binding(
get: { legacyConsentRequest != nil },
set: { isPresented in
if !isPresented, let requestID = legacyConsentRequest?.id {
conversationUIModel.resolveLegacyPrivateMediaConsent(
requestID: requestID,
approved: false
)
}
}
),
titleVisibility: .visible
) {
Button(
String(
localized: "content.private_media.legacy_warning.send",
defaultValue: "send visible file",
comment: "Destructive confirmation action for one legacy clear private-media send"
),
role: .destructive
) {
if let requestID = legacyConsentRequest?.id {
conversationUIModel.resolveLegacyPrivateMediaConsent(
requestID: requestID,
approved: true
)
}
}
Button("common.cancel", role: .cancel) {
if let requestID = legacyConsentRequest?.id {
conversationUIModel.resolveLegacyPrivateMediaConsent(
requestID: requestID,
approved: false
)
}
}
} message: {
if let request = legacyConsentRequest {
Text(
String(
format: String(
localized: "content.private_media.legacy_warning.message",
defaultValue: "%@'s client does not advertise encrypted private media. This file will be signed but not end-to-end encrypted, so mesh relays can see it. Send this file anyway?",
comment: "Warning explaining the confidentiality loss for one legacy private-media send; parameter is the peer name"
),
locale: .current,
request.peerName
)
)
}
}
#if os(macOS) #if os(macOS)
.frame(minWidth: 420, minHeight: 520) .frame(minWidth: 420, minHeight: 520)
#endif #endif
+6 -1
View File
@@ -121,11 +121,16 @@ struct MediaMessageView: View {
.padding(.vertical, 4) .padding(.vertical, 4)
// Collapse the revealed caption when the status advances (e.g. // Collapse the revealed caption when the status advances (e.g.
// sending sent delivered) so a detail opened for one state // sending sent delivered) so a detail opened for one state
// doesn't linger and silently morph into another. // doesn't linger and silently morph into another. Guarded write:
// under a message storm many rows change status within one frame,
// and an unconditional state write per change trips SwiftUI's
// "tried to update multiple times per frame" re-entrancy warning.
.onChange(of: deliveryStatus) { _ in .onChange(of: deliveryStatus) { _ in
if showDeliveryDetail {
showDeliveryDetail = false showDeliveryDetail = false
} }
} }
}
private func mediaSendState(for deliveryStatus: DeliveryStatus?, isFromMe: Bool) -> (isSending: Bool, progress: Double?, canCancel: Bool) { private func mediaSendState(for deliveryStatus: DeliveryStatus?, isFromMe: Bool) -> (isSending: Bool, progress: Double?, canCancel: Bool) {
// A received message is never in a send state: BitchatMessage defaults // A received message is never in a send state: BitchatMessage defaults
+3 -1
View File
@@ -334,8 +334,10 @@ private extension MessageListView {
.buttonStyle(.plain) .buttonStyle(.plain)
} }
/// The nearby-notes counter runs whenever the mesh public timeline is /// The nearby-notes counter is held whenever the mesh public timeline is
/// showing the strip needs a live count before it can decide to exist. /// showing the strip needs a live count before it can decide to exist.
/// Holding is not subscribing: nothing hits the relays until an explicit
/// act reveals the counter (tap-to-reveal).
func updateNotesCounterHold() { func updateNotesCounterHold() {
let shouldHold = privatePeer == nil && locationChannelsModel.selectedChannel.isMesh let shouldHold = privatePeer == nil && locationChannelsModel.selectedChannel.isMesh
guard shouldHold != holdsNotesCounter else { return } guard shouldHold != holdsNotesCounter else { return }
+211 -41
View File
@@ -34,15 +34,24 @@ struct NoticesView: View {
/// Days until the notice fades; `permanentExpiry` (geo default) means no /// Days until the notice fades; `permanentExpiry` (geo default) means no
/// NIP-40 tag the note stays until its relay drops it. /// NIP-40 tag the note stays until its relay drops it.
@State private var expiryDays: Int @State private var expiryDays: Int
/// Mirrors the app-info kill switch so its notification produces an
/// immediate presentation update as well as tearing down subscriptions.
@State private var locationNotesEnabled = LocationNotesSettings.enabled
/// Sentinel picker tag for the option (geo tab only). /// Sentinel picker tag for the option (geo tab only).
private static let permanentExpiry = 0 private static let permanentExpiry = 0
/// Injected notes manager for tests; live use derives one per geohash. /// Injected notes manager for tests; live use derives one per geohash.
private let notesManager: LocationNotesManager? private let notesManager: LocationNotesManager?
/// Live manager owned by the sheet so the composer can post pure Nostr /// Pooled manager held by the sheet so the composer can post pure Nostr
/// notes ( expiry has no mesh-board copy) and the list can render them. /// notes ( expiry has no mesh-board copy) and the list can render them.
/// Acquired from `LocationNotesPool` (shared with the nearby-notes
/// counter, one REQ per geohash) and released on dismissal.
@State private var liveGeoManager: LocationNotesManager? @State private var liveGeoManager: LocationNotesManager?
/// Tracks only this sheet's high-accuracy refresh ownership, so repeated
/// Combine/SwiftUI invalidations do not restart CoreLocation and a
/// revocation or kill-switch transition balances the begin call once.
@State private var ownsLiveGeoRefresh = false
init( init(
senderNickname: String, senderNickname: String,
@@ -61,20 +70,137 @@ struct NoticesView: View {
notesManager ?? liveGeoManager notesManager ?? liveGeoManager
} }
/// Creates (or retargets/revives) the sheet-owned notes manager for the /// The one explicit act inside the sheet that unlocks the passive
/// current geo scope. /// nearby-notes counter: the person actively picking the geo segment
private func ensureGeoNotesManager() { /// while the sheet has a geo scope. Landing on the geo tab via the
guard notesManager == nil, tab == .geo, let geohash = geoGeohash else { return } /// sheet's initial selection (auto-derived from the current channel
if let manager = liveGeoManager { /// e.g. browsing a remote geohash) is not an act toward the LOCAL
/// building cell and must not reveal it.
static func revealsNearbyNotes(onSwitchingTo tab: Tab, geoGeohash: String?) -> Bool {
tab == .geo && geoGeohash != nil
}
struct GeoSessionState {
let manager: LocationNotesManager?
let ownsLiveRefresh: Bool
}
enum GeoPresentationState: Equatable {
case disabled
case locationUnavailable
case available(String)
}
static func geoPresentationState(notesEnabled: Bool, geohash: String?) -> GeoPresentationState {
guard notesEnabled else { return .disabled }
guard let geohash else { return .locationUnavailable }
return .available(geohash)
}
static func composerGeohash(tab: Tab, notesEnabled: Bool, geoGeohash: String?) -> String? {
switch tab {
case .geo:
guard case .available(let geohash) = geoPresentationState(
notesEnabled: notesEnabled,
geohash: geoGeohash
) else {
return nil
}
return geohash
case .mesh:
return ""
}
}
/// Reconciles both privacy-sensitive resources owned by the sheet: the
/// high-accuracy CoreLocation refresh and the precise notes REQ. Kept as
/// a callback-driven function so permission and kill-switch transitions
/// can be regression tested without presenting SwiftUI.
@MainActor
static func reconcileGeoSession(
tab: Tab,
needsDeviceLocation: Bool,
permissionState: LocationChannelManager.PermissionState,
notesEnabled: Bool,
geohash: String?,
manager: LocationNotesManager?,
ownsLiveRefresh: Bool,
beginLiveRefresh: () -> Void,
endLiveRefresh: () -> Void,
acquire: (String) -> LocationNotesManager,
release: (LocationNotesManager?) -> Void
) -> GeoSessionState {
let geoTabActive = tab == .geo && notesEnabled
let wantsLiveRefresh = geoTabActive && needsDeviceLocation && permissionState == .authorized
if wantsLiveRefresh != ownsLiveRefresh {
if wantsLiveRefresh {
beginLiveRefresh()
} else {
endLiveRefresh()
}
}
// A selected location channel is an explicit remote/teleported scope
// and remains usable without device permission. Only device-derived
// scope requires current authorization.
let mayUseNotes = geoTabActive &&
(!needsDeviceLocation || permissionState == .authorized)
guard mayUseNotes, let geohash else {
if manager != nil {
release(manager)
}
return GeoSessionState(manager: nil, ownsLiveRefresh: wantsLiveRefresh)
}
if let manager {
if manager.geohash != geohash.lowercased() { if manager.geohash != geohash.lowercased() {
manager.setGeohash(geohash) // Pooled managers are shared; never retarget one in place.
} else if manager.state == .idle { release(manager)
// Cancelled on a tab switch; returning re-subscribes. return GeoSessionState(
manager: acquire(geohash),
ownsLiveRefresh: wantsLiveRefresh
)
}
if manager.state == .idle {
manager.refresh() manager.refresh()
} }
} else { return GeoSessionState(manager: manager, ownsLiveRefresh: wantsLiveRefresh)
liveGeoManager = LocationNotesManager(geohash: geohash)
} }
return GeoSessionState(
manager: acquire(geohash),
ownsLiveRefresh: wantsLiveRefresh
)
}
private func reconcileGeoSession(notesEnabled: Bool? = nil) {
let notesEnabled = notesEnabled ?? locationNotesEnabled
let next = Self.reconcileGeoSession(
tab: tab,
needsDeviceLocation: geoTabNeedsDeviceLocation,
permissionState: locationChannelsModel.permissionState,
notesEnabled: notesEnabled,
geohash: geoGeohash,
manager: liveGeoManager,
ownsLiveRefresh: ownsLiveGeoRefresh,
beginLiveRefresh: {
locationChannelsModel.enableLocationChannels()
locationChannelsModel.beginLiveRefresh()
},
endLiveRefresh: { locationChannelsModel.endLiveRefresh() },
acquire: { geohash in
notesManager ?? LocationNotesPool.shared.acquire(geohash)
},
release: { manager in
guard notesManager == nil else { return }
LocationNotesPool.shared.release(manager)
}
)
// A test-injected manager is owned by the caller, not by the pool or
// this view's lifecycle state.
liveGeoManager = notesManager == nil ? next.manager : nil
ownsLiveGeoRefresh = next.ownsLiveRefresh
} }
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 } private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
@@ -85,6 +211,9 @@ struct NoticesView: View {
if case .location(let channel) = locationChannelsModel.selectedChannel { if case .location(let channel) = locationChannelsModel.selectedChannel {
return channel.geohash return channel.geohash
} }
guard locationChannelsModel.permissionState == .authorized else {
return nil
}
return locationChannelsModel.currentBuildingGeohash return locationChannelsModel.currentBuildingGeohash
} }
@@ -96,10 +225,11 @@ struct NoticesView: View {
} }
private var activeGeohash: String? { private var activeGeohash: String? {
switch tab { Self.composerGeohash(
case .geo: return geoGeohash tab: tab,
case .mesh: return "" notesEnabled: locationNotesEnabled,
} geoGeohash: geoGeohash
)
} }
enum Strings { enum Strings {
@@ -125,6 +255,8 @@ struct NoticesView: View {
static let nostrSource = String(localized: "notices.source.nostr", defaultValue: "net", comment: "Source badge for notices seen on internet relays") static let nostrSource = String(localized: "notices.source.nostr", defaultValue: "net", comment: "Source badge for notices seen on internet relays")
static let locationUnavailable = String(localized: "content.notes.location_unavailable", comment: "Shown when the device location is unavailable for geo notices") static let locationUnavailable = String(localized: "content.notes.location_unavailable", comment: "Shown when the device location is unavailable for geo notices")
static let enableLocation = String(localized: "content.location.enable", comment: "Button enabling location for geo notices") static let enableLocation = String(localized: "content.location.enable", comment: "Button enabling location for geo notices")
static let locationNotesTitle: LocalizedStringKey = "app_info.location.notes.title"
static let locationNotesDescription: LocalizedStringKey = "app_info.location.notes.description"
static let loadingNotes: LocalizedStringKey = "location_notes.loading_notes" static let loadingNotes: LocalizedStringKey = "location_notes.loading_notes"
static let connectingRelays: LocalizedStringKey = "location_notes.connecting_relays" static let connectingRelays: LocalizedStringKey = "location_notes.connecting_relays"
static let noRelaysNearby: LocalizedStringKey = "location_notes.no_relays_nearby" static let noRelaysNearby: LocalizedStringKey = "location_notes.no_relays_nearby"
@@ -174,39 +306,46 @@ struct NoticesView: View {
#endif #endif
.themedSheetBackground() .themedSheetBackground()
.onAppear { .onAppear {
beginGeoLocationIfNeeded() reconcileGeoSession()
ensureGeoNotesManager()
} }
.onChange(of: tab) { newTab in .onChange(of: tab) { newTab in
if newTab == .geo { if newTab == .geo {
beginGeoLocationIfNeeded() if Self.revealsNearbyNotes(onSwitchingTo: newTab, geoGeohash: geoGeohash) {
ensureGeoNotesManager() NearbyNotesCounter.shared.reveal()
} else {
locationChannelsModel.endLiveRefresh()
} }
}
reconcileGeoSession()
// Each tab keeps its natural default: geo notes stay until // Each tab keeps its natural default: geo notes stay until
// deleted (), mesh board posts fade within a week. // deleted (), mesh board posts fade within a week.
expiryDays = newTab == .geo ? Self.permanentExpiry : 7 expiryDays = newTab == .geo ? Self.permanentExpiry : 7
urgent = false urgent = false
} }
// Catches permission granted from the geo tab's enable button. // Catches both grant and revocation. Revocation must balance the live
// refresh and release a device-derived building REQ immediately.
.onChange(of: locationChannelsModel.permissionState) { _ in .onChange(of: locationChannelsModel.permissionState) { _ in
beginGeoLocationIfNeeded() reconcileGeoSession()
} }
.onChange(of: geoGeohash) { _ in .onChange(of: geoGeohash) { _ in
ensureGeoNotesManager() reconcileGeoSession()
} }
.onDisappear { locationChannelsModel.endLiveRefresh() } .onChange(of: geoTabNeedsDeviceLocation) { _ in
reconcileGeoSession()
}
.onReceive(NotificationCenter.default.publisher(for: LocationNotesSettings.didChangeNotification)) { _ in
let enabled = LocationNotesSettings.enabled
locationNotesEnabled = enabled
reconcileGeoSession(notesEnabled: enabled)
}
.onDisappear {
if ownsLiveGeoRefresh {
locationChannelsModel.endLiveRefresh()
ownsLiveGeoRefresh = false
}
if notesManager == nil {
LocationNotesPool.shared.release(liveGeoManager)
}
liveGeoManager = nil
} }
/// The geo tab tracks the device's building geohash while on mesh; keep
/// location fresh only in that case (a selected location channel already
/// fixes the scope).
private func beginGeoLocationIfNeeded() {
guard tab == .geo, geoTabNeedsDeviceLocation,
locationChannelsModel.permissionState == .authorized else { return }
locationChannelsModel.enableLocationChannels()
locationChannelsModel.beginLiveRefresh()
} }
private var headerSection: some View { private var headerSection: some View {
@@ -258,21 +397,56 @@ struct NoticesView: View {
notesManager: nil notesManager: nil
) )
case .geo: case .geo:
if let geohash = geoGeohash { switch Self.geoPresentationState(
notesEnabled: locationNotesEnabled,
geohash: geoGeohash
) {
case .disabled:
locationNotesDisabledSection
case .available(let geohash):
if let manager = activeNotesManager { if let manager = activeNotesManager {
GeoNoticesList(geohash: geohash, board: board, manager: manager) GeoNoticesList(geohash: geohash, board: board, manager: manager)
} else { } else {
// Manager is created on appear; visible for one frame. // Manager is created on appear; visible for one frame.
Color.clear Color.clear
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear { ensureGeoNotesManager() } .onAppear { reconcileGeoSession() }
} }
} else { case .locationUnavailable:
locationUnavailableSection locationUnavailableSection
} }
} }
} }
private var locationNotesDisabledSection: some View {
ScrollView {
Toggle(
isOn: Binding(
get: { locationNotesEnabled },
set: { enabled in
locationNotesEnabled = enabled
LocationNotesSettings.enabled = enabled
}
)
) {
VStack(alignment: .leading, spacing: 4) {
Text(Strings.locationNotesTitle)
.bitchatFont(size: 14)
Text(Strings.locationNotesDescription)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.toggleStyle(.switch)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.themedSurface()
}
private var locationUnavailableSection: some View { private var locationUnavailableSection: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
@@ -417,10 +591,6 @@ private struct GeoNoticesList: View {
board: board, board: board,
notesManager: notesManager notesManager: notesManager
) )
.onChange(of: geohash) { newValue in
notesManager.setGeohash(newValue)
}
.onDisappear { notesManager.cancel() }
} }
} }
-2
View File
@@ -28,8 +28,6 @@
<dict> <dict>
<key>NSExtensionActivationRule</key> <key>NSExtensionActivationRule</key>
<dict> <dict>
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsText</key> <key>NSExtensionActivationSupportsText</key>
<true/> <true/>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key> <key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
@@ -0,0 +1,23 @@
<?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>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>1C8F.1</string>
</array>
</dict>
</array>
</dict>
</plist>
+39 -28
View File
@@ -107,38 +107,46 @@ final class ShareViewController: UIViewController {
private func loadFirstURL(from providers: [NSItemProvider], completion: @escaping (URL?) -> Void) { private func loadFirstURL(from providers: [NSItemProvider], completion: @escaping (URL?) -> Void) {
let identifiers = [UTType.url.identifier, "public.url", "public.file-url"] let identifiers = [UTType.url.identifier, "public.url", "public.file-url"]
let grp = DispatchGroup() for provider in providers {
var found: URL? guard let identifier = identifiers.first(where: { provider.hasItemConformingToTypeIdentifier($0) }) else {
continue
for p in providers where found == nil {
for id in identifiers where p.hasItemConformingToTypeIdentifier(id) {
grp.enter()
p.loadItem(forTypeIdentifier: id, options: nil) { item, _ in
defer { grp.leave() }
if let u = item as? URL { found = u; return }
if let s = item as? String, let u = URL(string: s) { found = u; return }
if let d = item as? Data, let s = String(data: d, encoding: .utf8), let u = URL(string: s) { found = u; return }
} }
break provider.loadItem(forTypeIdentifier: identifier, options: nil) { item, _ in
let result: URL?
if let url = item as? URL {
result = url
} else if let string = item as? String {
result = URL(string: string)
} else if let data = item as? Data,
let string = String(data: data, encoding: .utf8) {
result = URL(string: string)
} else {
result = nil
} }
DispatchQueue.main.async { completion(result) }
} }
grp.notify(queue: .main) { completion(found) } return
}
DispatchQueue.main.async { completion(nil) }
} }
private func loadFirstPlainText(from providers: [NSItemProvider], completion: @escaping (String?) -> Void) { private func loadFirstPlainText(from providers: [NSItemProvider], completion: @escaping (String?) -> Void) {
let id = UTType.plainText.identifier let identifier = UTType.plainText.identifier
let grp = DispatchGroup() guard let provider = providers.first(where: { $0.hasItemConformingToTypeIdentifier(identifier) }) else {
var text: String? DispatchQueue.main.async { completion(nil) }
for p in providers where p.hasItemConformingToTypeIdentifier(id) { return
grp.enter()
p.loadItem(forTypeIdentifier: id, options: nil) { item, _ in
defer { grp.leave() }
if let s = item as? String { text = s }
else if let d = item as? Data, let s = String(data: d, encoding: .utf8) { text = s }
} }
break provider.loadItem(forTypeIdentifier: identifier, options: nil) { item, _ in
let result: String?
if let string = item as? String {
result = string
} else if let data = item as? Data {
result = String(data: data, encoding: .utf8)
} else {
result = nil
}
DispatchQueue.main.async { completion(result) }
} }
grp.notify(queue: .main) { completion(text) }
} }
// MARK: - Save + Finish // MARK: - Save + Finish
@@ -170,10 +178,13 @@ final class ShareViewController: UIViewController {
} }
private func finishWithMessage(_ msg: String) { private func finishWithMessage(_ msg: String) {
statusLabel.text = msg DispatchQueue.main.async { [weak self] in
// Complete shortly after showing status guard let self else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiShareExtensionDismissDelaySeconds) { self.statusLabel.text = msg
self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil) // Complete shortly after showing status.
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiShareExtensionDismissDelaySeconds) { [weak self] in
self?.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
} }
} }
} }
@@ -0,0 +1,510 @@
//
// AudioSessionCoordinatorTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
@testable import bitchat
/// Thread-safe: the coordinator invokes it on its private serial queue (that
/// the calls happen off the main thread is itself under test) while the test
/// reads from the main actor.
private final class MockAudioSession: SessionApplying, @unchecked Sendable {
enum Call: Equatable {
case setCategory(AudioSessionCoordinator.Category)
case setActive(Bool, notifyOthers: Bool)
}
private let lock = NSLock()
private var _calls: [Call] = []
private var _callsOnMainThread: [Bool] = []
private var _nextError: Error?
private var _nextActivationError: Error?
var calls: [Call] { lock.withLock { _calls } }
/// Whether each recorded call ran on the main thread the coordinator's
/// whole point is that none ever does (the real calls block on IPC to the
/// audio server).
var callsOnMainThread: [Bool] { lock.withLock { _callsOnMainThread } }
var nextError: Error? {
get { lock.withLock { _nextError } }
set { lock.withLock { _nextError = newValue } }
}
/// Fails only the next `setActive` (so `setCategory` can succeed first).
var nextActivationError: Error? {
get { lock.withLock { _nextActivationError } }
set { lock.withLock { _nextActivationError = newValue } }
}
func setCategory(_ category: AudioSessionCoordinator.Category) throws {
try lock.withLock {
if let error = _nextError {
_nextError = nil
throw error
}
_calls.append(.setCategory(category))
_callsOnMainThread.append(Thread.isMainThread)
}
}
func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws {
try lock.withLock {
if let error = _nextError {
_nextError = nil
throw error
}
if let error = _nextActivationError {
_nextActivationError = nil
throw error
}
_calls.append(.setActive(active, notifyOthers: notifyOthersOnDeactivation))
_callsOnMainThread.append(Thread.isMainThread)
}
}
var categoryCalls: [AudioSessionCoordinator.Category] {
calls.compactMap { if case .setCategory(let category) = $0 { category } else { nil } }
}
var activationCalls: [Bool] {
calls.compactMap { if case .setActive(let active, _) = $0 { active } else { nil } }
}
}
private struct MockSessionError: Error {}
/// Async suspension gate used to force lifecycle races without sleeps. The
/// production operation announces that it reached the gated boundary, then
/// stays suspended until the test opens it.
private actor AsyncGate {
private var isOpen = false
private var hasWaiter = false
private var arrivalWaiters: [CheckedContinuation<Void, Never>] = []
private var gateWaiters: [CheckedContinuation<Void, Never>] = []
func wait() async {
hasWaiter = true
let arrivals = arrivalWaiters
arrivalWaiters = []
for continuation in arrivals {
continuation.resume()
}
guard !isOpen else { return }
await withCheckedContinuation { continuation in
gateWaiters.append(continuation)
}
}
func waitUntilEntered() async {
guard !hasWaiter else { return }
await withCheckedContinuation { continuation in
arrivalWaiters.append(continuation)
}
}
func open() {
isOpen = true
let waiters = gateWaiters
gateWaiters = []
for continuation in waiters {
continuation.resume()
}
}
}
@MainActor
struct AudioSessionCoordinatorTests {
// MARK: - Reference-counted activation
@Test func activatesOnFirstAcquireAndDeactivatesOnLastRelease() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
let first = try await coordinator.acquire(.playback) {}
let second = try await coordinator.acquire(.playback) {}
#expect(session.activationCalls == [true])
coordinator.release(first)
await coordinator.drain()
#expect(session.activationCalls == [true])
coordinator.release(second)
await coordinator.drain()
#expect(session.activationCalls == [true, false])
#expect(session.calls.last == .setActive(false, notifyOthers: true))
}
@Test func releasingOneOfTwoClientsDoesNotDeactivate() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
let playback = try await coordinator.acquire(.playback) {}
let capture = try await coordinator.acquire(.capture) {}
coordinator.release(capture)
await coordinator.drain()
#expect(session.activationCalls == [true])
coordinator.release(playback)
await coordinator.drain()
#expect(session.activationCalls == [true, false])
}
@Test func doubleReleaseIsIdempotent() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
let first = try await coordinator.acquire(.playback) {}
let second = try await coordinator.acquire(.playback) {}
coordinator.release(first)
coordinator.release(first)
await coordinator.drain()
// The stale second release must not tear the session out from under
// the remaining holder.
#expect(session.activationCalls == [true])
coordinator.release(second)
coordinator.release(second)
await coordinator.drain()
#expect(session.activationCalls == [true, false])
}
// MARK: - Off-main session calls
@Test func sessionCallsNeverRunOnTheMainThread() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
// The real setCategory/setActive block on IPC to the audio server
// (>1 s observed under contention, tripping the system gesture gate
// on PTT press) every call must land on the coordinator's queue.
let token = try await coordinator.acquire(.capture) {}
coordinator.release(token)
await coordinator.drain()
#expect(session.calls.count == 3) // setCategory + activate + deactivate
#expect(session.callsOnMainThread == [false, false, false])
}
@Test func failedActivationDoesNotRegisterAHolder() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
session.nextError = MockSessionError()
await #expect(throws: MockSessionError.self) {
try await coordinator.acquire(.playback) {}
}
// The failed acquire left no holder behind: the next one is 0->1
// again and activates.
let token = try await coordinator.acquire(.playback) {}
#expect(session.activationCalls == [true])
coordinator.release(token)
await coordinator.drain()
#expect(session.activationCalls == [true, false])
}
@Test func failedActivationRollsBackEscalatedCategory() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
// setCategory(.playAndRecord) succeeds, setActive throws (e.g. a
// phone call owns the hardware).
session.nextActivationError = MockSessionError()
await #expect(throws: MockSessionError.self) {
try await coordinator.acquire(.capture) {}
}
// With no holder registered the escalated category must not stick:
// the next playback-only acquire runs under .playback, not the
// leftover .playAndRecord.
let token = try await coordinator.acquire(.playback) {}
#expect(session.categoryCalls == [.playAndRecord, .playback])
// And the failed acquire left no holder behind: this one was 0->1.
#expect(session.activationCalls == [true])
coordinator.release(token)
await coordinator.drain()
#expect(session.activationCalls == [true, false])
}
// MARK: - Category escalation
@Test func captureWhilePlaybackEscalatesExactlyOnceAndNeverDowngrades() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
let playback = try await coordinator.acquire(.playback) {}
#expect(session.categoryCalls == [.playback])
let capture = try await coordinator.acquire(.capture) {}
#expect(session.categoryCalls == [.playback, .playAndRecord])
// More clients of either use don't touch the category again.
let secondCapture = try await coordinator.acquire(.capture) {}
let secondPlayback = try await coordinator.acquire(.playback) {}
#expect(session.categoryCalls == [.playback, .playAndRecord])
// Capture ending must not downgrade the route under live playback.
coordinator.release(capture)
coordinator.release(secondCapture)
await coordinator.drain()
#expect(session.categoryCalls == [.playback, .playAndRecord])
// Even a fresh playback acquire stays on playAndRecord while held.
let thirdPlayback = try await coordinator.acquire(.playback) {}
#expect(session.categoryCalls == [.playback, .playAndRecord])
coordinator.release(playback)
coordinator.release(secondPlayback)
coordinator.release(thirdPlayback)
await coordinator.drain()
}
@Test func categoryResetsAfterAllHoldersRelease() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
let capture = try await coordinator.acquire(.capture) {}
coordinator.release(capture)
await coordinator.drain()
#expect(session.categoryCalls == [.playAndRecord])
// With no holders left the next playback-only session downgrades.
let playback = try await coordinator.acquire(.playback) {}
#expect(session.categoryCalls == [.playAndRecord, .playback])
coordinator.release(playback)
await coordinator.drain()
}
@Test func escalationNotifiesExistingHoldersSoEnginesCanRestart() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
var playbackInterruptions = 0
var captureInterruptions = 0
let playback = try await coordinator.acquire(.playback) { playbackInterruptions += 1 }
let capture = try await coordinator.acquire(.capture) { captureInterruptions += 1 }
// The pre-existing playback holder was reconfigured underneath (the
// fan-out is delivered before acquire returns); the newly acquiring
// capture client was not.
#expect(playbackInterruptions == 1)
#expect(captureInterruptions == 0)
// A second capture doesn't change the category nobody is notified.
let secondCapture = try await coordinator.acquire(.capture) {}
#expect(playbackInterruptions == 1)
#expect(captureInterruptions == 0)
coordinator.release(playback)
coordinator.release(capture)
coordinator.release(secondCapture)
await coordinator.drain()
}
@Test func escalationPrefersCategoryChangeCallbackOverInterruption() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
var escalations = 0
var interruptions = 0
let playback = try await coordinator.acquire(
.playback,
onInterrupted: { interruptions += 1 },
onCategoryEscalated: { escalations += 1 }
)
// Escalation reaches the dedicated callback (the holder restarts and
// keeps playing) not onInterrupted (which would stop it for good).
let capture = try await coordinator.acquire(.capture) {}
#expect(escalations == 1)
#expect(interruptions == 0)
// A real interruption still stops it.
await coordinator.handleInterruptionBegan()
#expect(escalations == 1)
#expect(interruptions == 1)
coordinator.release(playback)
coordinator.release(capture)
await coordinator.drain()
}
// MARK: - Interruptions and route changes
@Test func interruptionDuringAcquireHandoffCancelsAcquire() async throws {
let session = MockAudioSession()
let handoffGate = AsyncGate()
let coordinator = AudioSessionCoordinator(
session: session,
testingHooks: .init(beforeAcquireHandoff: {
await handoffGate.wait()
})
)
var interruptionCount = 0
let acquireTask = Task { @MainActor in
try await coordinator.acquire(.capture) {
interruptionCount += 1
}
}
// The session-queue registration is complete, but the caller has not
// received its token. An interruption here used to invoke the callback
// immediately, when capture clients could not release the token yet.
await handoffGate.waitUntilEntered()
await coordinator.handleInterruptionBegan()
#expect(interruptionCount == 0)
await handoffGate.open()
await #expect(throws: CancellationError.self) {
try await acquireTask.value
}
await coordinator.drain()
#expect(interruptionCount == 0)
// The OS already deactivated the interrupted session; removing the
// provisional token must not issue a redundant setActive(false).
#expect(session.activationCalls == [true])
// The canceled acquire left no holder behind and the now-open test gate
// does not affect a subsequent ownership handoff.
let replacement = try await coordinator.acquire(.playback) {}
#expect(session.activationCalls == [true, true])
coordinator.release(replacement)
await coordinator.drain()
#expect(session.activationCalls == [true, true, false])
}
@Test func releasedSnapshotCannotInterruptReacquiredToken() async throws {
let session = MockAudioSession()
let deliveryGate = AsyncGate()
let coordinator = AudioSessionCoordinator(
session: session,
testingHooks: .init(beforeCallbackDelivery: {
await deliveryGate.wait()
})
)
// Model a single client whose callback acts on whichever token it owns
// now. If the old snapshot is delivered after reacquisition, it would
// incorrectly release the new session.
var activeToken: AudioSessionCoordinator.Token?
var interruptionCount = 0
let onInterrupted: @MainActor () -> Void = {
interruptionCount += 1
activeToken.map(coordinator.release)
}
let first = try await coordinator.acquire(.playback, onInterrupted: onInterrupted)
activeToken = first
let interruptionTask = Task {
await coordinator.handleInterruptionBegan()
}
// The queue snapshot contains `first`, but main-actor delivery is held.
await deliveryGate.waitUntilEntered()
coordinator.release(first)
activeToken = nil
await coordinator.drain()
let second = try await coordinator.acquire(.playback, onInterrupted: onInterrupted)
activeToken = second
#expect(session.activationCalls == [true, true])
await deliveryGate.open()
await interruptionTask.value
await coordinator.drain()
#expect(interruptionCount == 0)
// A stale callback would have released `second` and appended false.
#expect(session.activationCalls == [true, true])
coordinator.release(second)
activeToken = nil
await coordinator.drain()
#expect(session.activationCalls == [true, true, false])
}
@Test func interruptionFansOutToAllHoldersAndResetsActiveState() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
var playbackInterruptions = 0
var captureInterruptions = 0
// Capture first so no escalation fan-out muddies the counters.
let capture = try await coordinator.acquire(.capture) { captureInterruptions += 1 }
let playback = try await coordinator.acquire(.playback) { playbackInterruptions += 1 }
#expect(session.activationCalls == [true])
await coordinator.handleInterruptionBegan()
#expect(playbackInterruptions == 1)
#expect(captureInterruptions == 1)
// The OS deactivated the session; the coordinator must not issue its
// own setActive(false) on top of it.
#expect(session.activationCalls == [true])
// The active state was reset: the next acquire re-activates even
// though holders never released.
let resumed = try await coordinator.acquire(.playback) {}
#expect(session.activationCalls == [true, true])
coordinator.release(playback)
coordinator.release(capture)
coordinator.release(resumed)
await coordinator.drain()
#expect(session.activationCalls == [true, true, false])
}
@Test func interruptedHoldersReleasingDuringFanOutStaySafe() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
// Real clients release from within onInterrupted (stop() paths);
// release is fire-and-forget onto the coordinator's queue, so it is
// safe from inside the main-actor fan-out.
var tokens: [AudioSessionCoordinator.Token] = []
for _ in 0..<2 {
var token: AudioSessionCoordinator.Token?
token = try await coordinator.acquire(.playback) {
token.map(coordinator.release)
}
tokens.append(token!)
}
await coordinator.handleInterruptionBegan()
await coordinator.drain()
// Every holder released mid-fan-out; the session was already
// deactivated by the OS, so no redundant setActive(false).
#expect(session.activationCalls == [true])
// All holders are gone: a fresh acquire is 0->1 again.
let token = try await coordinator.acquire(.playback) {}
#expect(session.activationCalls == [true, true])
coordinator.release(token)
await coordinator.drain()
#expect(session.activationCalls == [true, true, false])
}
@Test func routeDeviceUnavailableNotifiesHoldersButKeepsSessionActive() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
var interruptions = 0
// Capture first so no escalation fan-out muddies the counter.
let capture = try await coordinator.acquire(.capture) { interruptions += 1 }
let playback = try await coordinator.acquire(.playback) { interruptions += 1 }
await coordinator.handleRouteDeviceUnavailable()
#expect(interruptions == 2)
// Unlike an interruption, the session itself is still active the
// last holder's release performs the deactivation.
coordinator.release(playback)
coordinator.release(capture)
await coordinator.drain()
#expect(session.activationCalls == [true, false])
}
}

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