Compare commits

..
Author SHA1 Message Date
jackandClaude Opus 4.8 4d2ade74dd Keep timeout-restore queues parked through the same-generation ready path
The deferral-buffer rework routes quarantine restores through a
same-generation ready transition that no longer rebuilds capability
state — but it drained both outbound queues unconditionally there,
bypassing the pending-convergence gate. Honor the restore reason in
that branch too: retrying deferred inbound ciphertext stays (it is
receive-side and safe under restored keys), while queue drains wait for
the convergence retry's establishment exactly as on the new-generation
path. Terminal restores keep draining immediately.

Also pre-drain the establishment's serialized forced announce in the
terminal-restore test so the tap only observes restore-driven traffic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 13:06:25 +02:00
jackandjack b807b4da65 Discard deferred Noise ciphertext during panic 2026-07-26 13:06:25 +02:00
jackandjack 57c4a6c5fc Preserve early Noise ciphertext across reconnects 2026-07-26 13:06:25 +02:00
jackandGitHub 78a81e5b57 Make ordinary Noise reconnects atomic and race-safe (#1463)
Replaces destroy-then-rebuild Noise re-handshakes with an atomic reconnect protocol: prepared XX message-1 handoff tokens (claim-once, invalidated by crossed inbound initiations), receive-only quarantine of the established transport while an inbound replacement proves identity (promote on success, rollback+cooldown on failure/timeout), deterministic lower-peerID-wins crossed-initiator resolution, and a per-link-epoch BLE revalidation policy that re-proves cached sessions inside the same bleQueue critical section as the link rebind. On main, a fresh msg1 simply destroys an established session and rekey spans two non-atomic barriers.

Includes the review fix: timeout-restores defer outbound queue draining until the convergence retry completes (restore reason plumbed end-to-end), so DMs are never drained under keys a restarted peer already discarded — with a deterministic interleaving test. Identity-mismatch restores drain immediately. Full local suite 1768 tests green.
2026-07-26 13:04:56 +02:00
jackandGitHub e9275cb3d8 Relabel private Nostr envelopes honestly in docs; harden legacy envelope validation (#1480)
Split-out safe half of #1437 (the kind-1402 wire migration stays held for Android coordination). Docs (README/WHITEPAPER/PRIVACY_POLICY/privacy-assessment) now describe the actual proprietary DM construction — kind 1059 gift wrap carrying XChaCha20-Poly1305 with a 24-byte nonce, base64url v2: framing, and an HKDF that borrows the nip44-v2 info label but is not the NIP-44 key schedule — instead of claiming NIP-17/44/59.

Hardens the existing legacy inbound path: 64 KiB ciphertext cap before decode (~46x the largest producible legacy envelope), outer kind/recipient-tag/signature binding, tagless kind-13 seal binding, unsigned kind-14 inner binding, inner tags restricted to the two shapes deployed clients emit (verified against every historical iOS release and a fixture frozen from Android production), SecRandomCopyBytes failure now throws, non-UTF-8 plaintext now throws instead of returning empty. Adds frozen cross-platform fixtures with hash-pinned generators; interop-reviewed with no rejection surface for deployed clients.
2026-07-26 12:50:17 +02:00
jackandGitHub 55f824a11f Make nearby notes tests parallel-safe (#1471)
Injects the location-notes enabled getter and settings-change publisher into NearbyNotesCounter (defaults reproduce the prior wiring exactly), so tests no longer mutate the persistent UserDefaults key shared across parallel test worker processes. No production behavior change.

Un-stacked from the Codex chain (both files were byte-identical between main and the parent branch) and rebased onto the post-#1479 deflaked test suite.
2026-07-26 12:29:12 +02:00
jackandGitHub 2d96fd99a1 Encrypt private media before BLE fragmentation (#1434)
Closes the last cleartext private-content path over BLE: private DM images/voice were sent as plaintext signed fileTransfer packets, TTL-relayed across the mesh, so every relay saw the full bytes. Now the complete BitchatFilePacket is encrypted as a single Noise AEAD message (inner type 0x20, matching Android) and the opaque ciphertext is fragmented. Adds an authenticated in-session capability proof (0x21 TLV: capabilities + Ed25519 key), TOFU-style downgrade pinning, a per-send consent dialog for the signed-cleartext fallback to legacy peers, and a cancellation/admission registry so cancel/delete cannot race a deferred cleartext send.

Android wire constants (0x20 / 0x21 / capability bit 8) confirmed shipping. The 256-fragment preflight cap applies only to the directed fileTransfer migration fallback; encrypted media to capable peers uses the full receiver ceiling.

Rebased over #1428/#1349: identity reads go through BLELocalIdentityStateStore; the session-bound authenticated signing-key check and the announce-path TOFU pin are kept as complementary checks. Full local suite green (1744+197 tests).
2026-07-26 12:17:22 +02:00
jackandGitHub 10886428ca Deflake GeoRelayDirectoryTests under constrained runners (#1479)
Raises the file-local waitUntil default from 1s to 10s (returns early on success, so zero cost on healthy runs) and fixes a latent race in the observers test: it waited on the fetch-probe request count, which increments before handleFetchSuccess settles isFetching/lastFetchAt on the MainActor, so the next trigger notification posted into that gap was silently swallowed by the guard. Now waits on the .geoRelayDirectoryDidRefresh notification posted at the end of the synchronous success handler. Negative checks assert against captured baselines. No assertion weakened; 15/15 x 9 local runs including under 2x-ncpu CPU load.
2026-07-26 12:14:20 +02:00
jackandGitHub bcb21f2116 Require review before importing shared content (#1430)
Replaces the share-extension handoff that auto-sent shared text/URLs into the current channel with a validated, single-slot 24h envelope that shows the destination and a bounded preview and requires an explicit tap to copy into the composer — it never sends. Rejects malformed/oversized/control-character/non-HTTP(S)/expired payloads (including bidi-override U+202E), and clears on consume/cancel/expiry/panic.

Rebased onto main with Persian strings added for all new keys (30-locale coverage verified), plus a panic-recovery clear so an envelope staged during an interrupted wipe cannot survive relaunch.
2026-07-26 11:40:17 +02:00
jackandGitHub b96a41054e Pin announce signing keys to stop mesh identity spoofing (#1349)
TOFU-pins the Ed25519 signing key per noise key so a self-consistent announce that reuses a victim's peerID+noise key with the attacker's signing key/nickname is rejected instead of overwriting the registry and persisted identity. Complements #1432: that PR's signed-LEAVE verification reads the stored signing key this PR protects from announce-path poisoning.

Rebased onto main as a single commit; swift build --build-tests green, 40/40 targeted tests incl. the spoofing e2e cases, and #1432's suites 50/50 unaffected.
2026-07-26 11:38:24 +02:00
jackandGitHub d326cecb63 Fix BLE identity state races (#1428)
Converts BLEAnnounceThrottle to a lock-backed class and moves myPeerID/myPeerIDData/myNickname into a lock-backed BLELocalIdentityStateStore snapshot, so announces can never observe a split peer-ID/wire-ID during panic rotation. Serializes sendAnnounce onto the messageQueue barrier and moves the panic-reset pendingNoiseSessionQueues clear onto collectionsQueue (the queue every other mutation site uses).

Rebased onto main after #1431/#1432; integrates with #1431's panic-suspend structure (guard retained in both the outer sendAnnounce and the deferred worker).
2026-07-26 11:38:16 +02:00
jackandGitHub b59ad97dd6 perf: verify Nostr inbound signatures once, off the main actor (#1352)
Moves inbound Nostr Schnorr verification and NIP-17 gift-wrap decryption off the main actor into per-relay bounded AsyncStream pipelines (parallel across relays, ordered per relay), dedups before crypto, and removes the redundant second re-verify in NostrInboundPipeline/GeoPresenceTracker. Adds a panic-wipe generation counter so in-flight decrypts are discarded after a wipe.

Rebased onto main; #1451's 256 KiB pre-parse byte cap and tag limits are preserved and still enforced before any crypto/allocation.
2026-07-26 11:27:57 +02:00
jackandGitHub 58ccb30575 Route GeoRelay updates through reviewed data (#1436)
Replaces the scheduled workflow that pushed upstream georelay CSV directly to main unreviewed with a validator-backed automation that resolves an immutable upstream commit, validates against the reviewed baseline, and opens a non-auto-merged PR (or tracking issue) instead of writing to main. Adds a mirrored strict client-side validator in GeoRelayDirectory (all-or-nothing, size/row/host/coord caps, streamed 512KiB cap, >=50% baseline overlap) that fails safe to last-known-good, and retargets runtime refresh to bitchat's reviewed copy.
2026-07-26 10:55:07 +02:00
jackandGitHub 400ac4c904 Avoid conversation index rebuilds at retention cap (#1435)
Replaces ConversationStore's message-ID->physical-index map with logical indexes plus a head indexOffset, so cap-eviction advances the offset instead of rebuilding the whole dictionary on every steady-state append (~23.7x measured ingest throughput at cap). Adds a steady-state benchmark floor and a 1,200-op differential stress test against an O(n) reference model.
2026-07-26 10:52:28 +02:00
jackandGitHub 565d2b7773 Make developer cleanup artifact-only (#1433)
Rewrites just clean/nuke to remove only ignored build artifacts (.DerivedData/.build), never git-checkout or rm tracked project files (previously could destroy uncommitted work). Adds a CI guard (check-just-clean-safety.sh) against reintroducing source-mutating cleanup, plus README/Local.xcconfig modernization.
2026-07-26 10:50:25 +02:00
jackandGitHub 6b71ad2a64 Run iOS simulator tests in CI and repair coverage reporting (#1429)
Adds an ios-tests CI job that runs the UIKit/CoreBluetooth-conditional suite on the first available iPhone simulator (serial), and fixes the coverage-summary step ordering so the profile isn't invalidated by the serial benchmark rebuild.
2026-07-26 10:50:09 +02:00
jackandGitHub 16324c819f Bind Noise sessions to claimed peer identities; require signed leaves (#1432)
Adds remote-static-key->peerID binding at Noise handshake completion (closes a mesh impersonation/MITM hole where a peer could complete a handshake under another peer's ID). Also hardens LEAVE handling to require a verified signature and suppresses relay of unverifiable leaves.
2026-07-26 10:30:55 +02:00
cd727c6867 Make panic wipe deterministic and device-bound (#1431)
* Make panic wipe deterministic and device-bound

* Scope install markers to iOS

* Harden panic recovery and service shutdown

* Invalidate queued BLE ingress during panic

* Harden panic keychain and media cleanup

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: jack <jack@deck.local>
2026-07-26 10:28:50 +02:00
GitHub Action fb8fe39713 Automated update of relay data - Sun Jul 26 06:54:16 UTC 2026 2026-07-26 06:54:16 +00:00
ca18843bb0 Add Persian (fa) localization and an in-app language picker (#1443)
* Add Persian (fa) localization and an in-app language picker

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

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

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

* Remove unused AppLanguageSettings.currentOverride (Periphery)

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

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

---------

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

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

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

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

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

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

* Cap teleported geohash participant markers.

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

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

* Bound untrusted Nostr relay frames and event tags.

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

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

* Fail soft when Noise handshake state is unexpectedly missing.

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

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

* Cap geohash nickname cache from remote Nostr events.

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

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

* Avoid overlapping exclusive access in the rate limiter.

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

* Fix low-precision geohash country names

---------

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

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

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

Root causes and fixes:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

* Remove BoundedIDSet.remove, orphaned by the ExpiringIDSet migration

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

Review follow-ups on the canDeliverSecurely gate:

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

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

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

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

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

Codex review follow-ups on 5017c232:

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

* Promote kept live captures off the voice_live_ name at finalize

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 16:44:02 +02:00
021af3a22d Remove write-only peerNostrPubkey left by the read-receipt gate removal (#1417)
The variable only fed the deleted guard (#1415); the favorites lookup in
the unified-peer branch existed solely to populate it.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 23:04:30 +02:00
8c9c1cc6ac Bump version to 1.7.0 (#1416)
Feature release: mesh bridging, push-to-talk voice, empty-mesh
liveliness, store-and-forward DM routing.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 22:55:45 +02:00
55d51ba084 DMs to unreachable peers use store-and-forward instead of failing instantly (#1415)
* DMs to unreachable peers route through store-and-forward; drop stale reachability gates

Field-found: sendPrivateMessage pre-judged reachability and marked the
message failed without ever calling the router, so the retained outbox,
courier deposits, and bridge drops never ran — a DM composed after a
peer's reachability window lapsed was dead on arrival while an identical
one sent a minute earlier delivered. Messages now always route; a live
path earns "sent", everything else stays "sending" until the router
reports carried/delivered or expires it as failed.

A sweep for sibling gates found and fixed:
- startPrivateChat refused offline non-mutual favorites ("mutual favorite
  required for offline messaging") — store-and-forward only needs the
  recipient's noise key, so the gate and its string are gone.
- markPrivateMessagesAsRead skipped the whole receipt pass for peers
  without a stored Nostr key, starving mesh-connected non-favorites; the
  router picks the transport now (sentReadReceipts dedups the parallel
  PrivateChatManager path).
- DM/geoDM blocked notices and the unknown-group error posted to the
  active public timeline; they now land in the thread they're about via
  addLocalPrivateSystemMessage.
- Banned copy: literal "user" fallbacks in code become "anon" (the
  default-nickname convention), and es/fr/it/pt translations of four keys
  still saying usuario/utilisateur/utente/usuário now say person. Orphaned
  keys removed (system.dm.unreachable, content.delivery.reason.unreachable,
  system.chat.requires_favorite).

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

* Deflake BLEServiceCoreTests: longTimeout for positive waits

CI flaked on duplicatePacket_isDeduped: the first message landed after the
5s wait expired on a loaded runner (the test's later count==1 assertions
passed, proving late delivery, not lost delivery). All four positive waits
in the file now use TestConstants.longTimeout, which exists for exactly
this — waitUntil returns as soon as the condition holds, so passing runs
never pay the longer ceiling.

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

* Fix read-receipt test contamination; baseline Periphery noiseKey flake

Two CI failures, both environmental:

- markReadReceiptSent_returnsFalseOnSecondCall flaked because every test
  ChatViewModel shared one per-process read-receipts scratch suite; the
  lifecycle test persists the same "read-1" ID (a path the receipt-gate
  removal now reaches), and test order decided who saw whose state. Each
  instance now gets its own UUID-suffixed suite.

- Periphery intermittently misses the loadFromDisk read of
  PrekeyBundleStore.StoredBundle.noiseKey and fails --strict with a false
  "assign-only" finding (recurrence of a known flake). Its USR is
  baselined; an in-source periphery:ignore can't work because strict mode
  flags it as superfluous on runs where the indexer gets it right.

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-08 22:42:39 +02:00
dc99d641c8 Give the macOS app a proper icon matching iOS (#1414)
The macOS Debug configuration uses AppIconDebug, which had no mac-idiom
entries at all, so Debug builds shipped with the generic dock icon. And
the mac slots in AppIcon reused the full-bleed square iOS artwork; macOS
does not mask icons at render time, so even Release showed a sharp-
cornered square.

- Regenerate all AppIcon mac PNGs with the Big Sur icon grid baked in
  (824x824 rounded-rect body on a transparent 1024 canvas + standard
  drop shadow), derived from the same 1024 iOS art.
- Add a full mac icon set to AppIconDebug from the green debug artwork.
- Add scripts/generate-mac-appicon.swift (pure CoreGraphics) to
  re-derive the mac sizes whenever the 1024 source changes.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 22:07:40 +02:00
020de96519 Mesh bridging: stitch nearby mesh islands over the internet, courier drops, unified settings (#1412)
* Mesh bridging: stitch nearby mesh islands over Nostr, courier drops, settings surface

Three features plus a UI consolidation, all opt-in behind a new Bridge toggle:

Channel bridge: while bridging, outgoing public mesh messages are also
signed (with a derived, unlinkable per-cell Nostr identity) as kind-20000
events tagged #r with the local geohash-6 cell and published to the cell's
deterministic geo relays; mesh-only peers deposit via new toBridge/fromBridge
carrier directions through a bridge gateway (bridge + gateway toggles).
Remote islands' events render into the mesh timeline marked with a network
glyph. Events carry the original mesh message ID so the store's insert-by-ID
absorbs radio/bridge duplicates in either order. Loop prevention mirrors
GatewayService (three BoundedIDSet caches + skip-if-seen-locally + budgets).
Nothing crosses a bridge unless its author signed it for the bridge; a
per-message "nearby only" composer toggle keeps a message radio-only.

Courier over the bridge: sealed courier envelopes park on default relays as
kind-1401 drops tagged #x with their day-rotating recipient tag (NIP-40
expiry), signed by per-drop throwaway keys. Recipients subscribe for their
own candidate tags; bridge gateways watch verified local peers' tags and
hand matching drops over as directed courier packets. DM delivery to known
peers stops requiring a physical courier encounter; the Noise-X seal never
opens in transit.

Presence: kind-20001 heartbeats on the rendezvous feed a "people across the
bridge" count in the header (approximate: local participants subtracted by
radio-copy attribution).

Settings/Info: AppInfoView is now a segmented Settings/Info sheet. Settings
hosts appearance, voice (fixes the duplicated Voice section), a Connectivity
section (bridge + gateway + Tor toggles, the latter two moved out of the
location sheet), and a confirmed panic-wipe button. New announce TLV 0x06
advertises the gateway's rendezvous cell; PeerCapabilities gains .bridge.

i18n: 23 new keys across all 29 locales; coverage tests green.
Tests: 50 new app tests + 3 BitFoundation tests; full suite 1445 green.

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

* Settings polish: one toggle style, sticky Info-first tab, location access in Settings

- The live-voice toggle now uses the same settings card + IRC pill as the
  connectivity toggles (settingToggle, renamed from connectivityToggle).
- Segmented control orders Info first; the selected pane persists across
  opens (AppStorage), so first-ever open lands on Info and afterwards the
  sheet reopens where it was left.
- "remove location access" moved from the channels sheet into the Settings
  Connectivity section (same key, still deep-links to system settings).

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

* Location UX + copy: state-aware access control, honest empty state, clearer bridge/gateway text

- Settings' location control now covers all three permission states: grant
  (real prompt, only possible while never-asked), open-system-settings when
  denied, remove-access when granted. The channels sheet keeps its own grant
  path for people who start there.
- The channels list no longer spins forever without permission; it shows
  "grant location access to find nearby channels" instead (new key, 29
  locales).
- Bridge and gateway subtitles rewritten for clarity; the gateway subtitle
  moved to a new key since it now carries bridge traffic, and the old
  geohash-only key is deleted. The word "user" is banned from copy in every
  locale ("this person is blocked").

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

* Field-test fixes: dedupe relay-connectivity triggers, throttle presence, log bridge decisions

First on-device run confirmed publishes accepted and the subscription
delivering, but exposed trigger spam: NostrRelayManager's isConnected
re-emits per relay recompute, so presence published 5x/second and the
courier-drop subscription rebuilt 6x in 300ms. removeDuplicates() on the
sinks + a 30s presence throttle (same-second heartbeats are byte-identical
events anyway). Also: injection/skip/downlink now log under 🌉 so field
verification is observable — the first test looked silent precisely because
dedup correctly suppressed same-island bridged copies.

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

* Fix restart self-echo: recognize own rendezvous events by derived pubkey

Second field run proved bidirectional bridging live (~1-2s Mac<->iPhone via
Tor) but caught a bug: relay backfill after an app relaunch re-delivered the
device's own pre-restart events, and with the in-memory published-ID cache
wiped they rendered as bridged copies of your own messages. The rendezvous
identity is deterministically derived per cell, so self-recognition by
pubkey needs no cache and survives restarts; own events are also marked
never-downlink. Regression test simulates the fresh-launch state.

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

* People sheet: mesh, bridge, and groups in one list

The header's bridged count had no matching faces anywhere — the people
sheet only knew mesh peers. BridgeService now publishes named participants
(nickname from message tags, geohash-style #last4 disambiguation, presence
keeps a known name alive) and the mesh people sheet gains an
"across the bridge" section between mesh peers and groups. Display-only
rows in v1 (bridged identities have no DM route yet). Two new catalog keys
x29 locales; also normalizes one out-of-sort-order entry inherited from a
hand-edited key on main.

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

* Header: one people icon, one count, one sheet

Fold the bridged-people count into the main person.2.fill count instead of
a second network-glyph counter; the merged people sheet (mesh / across the
bridge / groups) is the breakdown. VoiceOver still announces how many of
the total are across the bridge.

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

* People sheet symmetry: #mesh section header, no active count

Every section now gets the same glyph+label header shape (shared
PeopleSectionHeader): #mesh over the peer list, across-the-bridge over
bridged people. The "N active" line is gone (mesh); location channels keep
their geohash subtitle. Dead subtitle/count helpers removed.

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

* One switch: the bridge toggle drives all internet sharing

Field feedback: two toggles (bridge + gateway) with an invisible dependency
was a trap — bridged messages silently never reached mesh-only neighbors
unless a second lever was found and flipped. Collapsed to a single switch
that does the right thing for your situation:

- Bridge ON + internet: your messages cross, you see the bridge, AND your
  device serves its island — accepts toBridge deposits, carries remote
  messages onto the radio, watches courier drops for verified local peers,
  advertises the cell, and runs the geohash-channel gateway.
- Bridge ON, no internet: you ride whoever nearby is serving.
- Bridge OFF: nothing of yours crosses; radio reception of bridged traffic
  stays passive and free.

With every online bridger serving, downlink gets a 0.2-1.5s jittered
holdoff + send-time suppression recheck so co-located gateways don't burn
duplicate airtime (two-gateway test included). The internet-gateway card is
gone from Settings (GatewayService now follows the bridge switch, with
launch-time migration); its orphaned catalog keys deleted and the bridge
subtitle broadened across all 29 locales. Also: MeshPeerList's empty state
("nobody around...") aligned to the section row rhythm.

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

* Bridge pumps its own location; fix centered people sheet; stop drop-subscription churn

Field session 3 found the bridge silently cell-less: it read
availableChannels passively, which only flow while some other feature
(channels sheet, location notes, geo sampling) happens to pump location —
turn those off and the bridge never gets a rendezvous. BridgeService now
requests a one-shot fix whenever it's enabled without a cell (and
piggybacks one on the presence timer so moving devices migrate cells).

Also from the session: the people sheet's scroll content hugged its widest
child and got centered on iPhone when the list was empty — pinned to full
width, leading. And the courier-drop subscription rebuilt every ~60s on
verified announces despite an unchanged tag set — now resubscribes only
when the tags actually change.

(Also merges origin/main: keychain test isolation #1413.)

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

* Fix launch race: bridge reacts to the permission callback, retries cell-less

Field session 4: the launch-time location request ran before the CoreLocation
authorization callback delivered, so refreshChannels() silently no-opped
(it requires .authorized) and nothing ever retried — the bridge stayed
cell-less all session. Three layers now close it:

- a $permissionState sink re-enters refreshRendezvous the moment
  authorization resolves (the fast path),
- the maintenance timer arms even without a cell and retries the full
  rendezvous refresh (the backstop; it previously required a cell, which
  made it useless for exactly this failure),
- flipping the bridge switch while never-asked triggers the location
  prompt — that's the user-initiated moment for it.

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

* People sheet: one header style for every section; bridged people visible while own bridge is off

GroupChatList's header now uses the shared PeopleSectionHeader (glyph +
label, same size/padding as #mesh and across-the-bridge; keeps its key and
header trait). Bridge section and the header count are no longer gated on
this device's own toggle: bridged people arrive over passive radio from a
serving neighbor, and whoever is visible in the timeline belongs in the
sheet and the count.

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

* People sheet: equalize the first section's gap

MeshPeerList's first row kept a legacy 10pt top bump from when nothing sat
above it, and the outer VStack's 6pt inter-child spacing applied between
the #mesh header and the list but not inside the other sections. Both gone:
sections own their rhythm (header 12/4, rows 4), spacing 0 outside.

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

* People sheet: rows drop their leading glyphs — the section header carries the type

Mesh rows lose the per-state transport icon (connected/relayed/nostr/
offline), bridge rows the network glyph, group rows the person.3 icon.
Trailing state badges (star, lock, verified, unread, blocked, crown) stay,
and the row accessibility description still announces connection state, so
VoiceOver loses nothing.

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

* Remove dead code left behind by the Settings|Info consolidation

Periphery (--strict) flagged five leftovers on this branch:
- LocationChannelsModel.setGatewayEnabled: the standalone internet-gateway
  toggle is gone (the bridge switch drives internet sharing), so nothing
  calls it; the gatewayEnabled published property stays for the header dot.
- AppInfoView Strings.Location title/enable/openSettings: the old Location
  section's header and permission buttons no longer exist. Their orphaned
  Localizable.xcstrings entries go with them (the gateway-toggle keys were
  already pruned).
- BridgePeopleList's appTheme environment value was never read.

The sixth CI finding (PrekeyBundleStore.StoredBundle.noiseKey assign-only)
is a Periphery flake: the property is read in loadFromDisk, the finding
didn't reproduce locally or on the next CI run of unchanged code.

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

* People sheet: mesh rows get their transport glyph back

The mesh section is the one heterogeneous list — the leading icon encodes
HOW a peer is reachable (radio / relayed / nostr-only / offline), which the
header can't say. Bridge and group rows stay glyph-free.

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

* Fix courier drop skipped by stale BLE reachability; periodic deposit sweep

Field test: a DM sent seconds after the recipient's radio vanished still
saw them as "reachable" (60s verified retention), so canDeliverPromptly
held, every deposit was skipped, and the message sat spooled with no retry
path. MessageRouter now sweeps its outbox every 2 minutes and publishes
bridge drops for messages whose recipient no transport can promptly reach;
the drop layer's message-ID dedup makes the sweep idempotent. Regression
test reproduces the exact field sequence.

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

* Show "carried" when a message ships as a bridge drop

Field feedback: dropped DMs delivered but the sender saw nothing — the
drop path never fired onMessageCarried, and the recipient's delivery ack
has no radio route back until the peers next share a transport. depositDrop
now reports whether a fresh drop was sealed and the router marks the
message carried (📦) on both the send path and the sweep; the ack still
upgrades it to delivered whenever a route exists.

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

* Fix locale word order: offline tag after name in DM header; localized "ago"

The private-chat header rendered the localized offline word in the
availability-glyph slot, before the name — "sin conexión bob". Offline now
shows the same dimmed person glyph the mesh list uses, with the word as a
small trailing tag after the name and lock, so it reads correctly in every
locale. Full sweep of views found one more composition bug: notice
timestamps glued English "ago" onto a localized duration; now the whole
phrase comes from RelativeDateTimeFormatter (same as the "fades" label).

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

* DM header offline state: icon only

The availability slot now reads uniformly as a glyph (radio / relayed /
globe / dimmed person), matching the mesh list; the text tag is gone.
VoiceOver still announces "offline" via the glyph's accessibility label.

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

* Offline glyph: slashed antenna instead of dimmed person

Offline is now the visual negation of connected (same antenna glyph,
slashed) in both the DM header and mesh list rows; a generic person icon
didn't say "unreachable".

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-08 21:45:38 +02:00
d499c3e415 Isolate tests from the developer's real login keychain (#1413)
* Isolate tests from the developer's real login keychain

Every test run prompted for the login keychain password (repeatedly,
since the xctest runner's code signature changes each build, so
"Always Allow" can never stick) and silently deleted the developer's
real Nostr identity via panic-mode tests.

Two holes let tests reach the real keychain:

- NostrIdentityBridge() defaulted to the real KeychainManager. Tests
  inject mocks, but app-side constructions with no injection point
  (LocationNotesManager's static bridge, GeohashPresenceService,
  BoardManager, AppRuntime) read the real chat.bitchat.nostr item
  when exercised under test.
- clearAllAssociations() used raw SecItem* calls that bypassed the
  injected keychain entirely, so panicClearAllData tests wiped the
  real Nostr identity items on every run.

Fix: centralize FavoritesPersistenceService's test-guarded in-memory
default as KeychainManager.makeDefault() and use it for all default
keychain parameters, and add deleteAll(service:) to
KeychainManagerProtocol so clearAllAssociations() goes through the
injected keychain like every other operation.

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

* Share one in-memory test keychain per process (Codex P2)

A fresh store per makeDefault() call diverges from production, where
separate default-constructed bridges share chat.bitchat.nostr —
BoardManager's publish and NIP-09 delete paths would derive different
geohash identities under test. PreviewKeychainManager gains a lock
since the shared instance is reached from arbitrary threads.

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-08 17:20:00 +02:00
4baeaab717 Geo notes latency: EOSE scoped to reached relays + connecting state with auto-retry (#1411)
* Empty-mesh liveliness: nearby conversations, echoes, wave action, dead drops, radar

The empty mesh timeline was a dead end: a grey zero and "nobody in range
yet". This turns it into a live surface and gives the app pull when the
mesh wakes up:

- Nearest conversation: background geohash sampling now tracks actual
  chat messages (not just presence) per regional channel; the empty state
  surfaces the busiest nearby conversation with a preview, one tap to
  join (GeohashChatActivityTracker, fed from GeoPresenceTracker).
- Echoes: the carried 6h store-and-forward window renders as dimmed
  "heard here earlier" rows at launch (new Transport
  collectArchivedPublicMessages -> GossipSyncManager snapshot, decoded
  with signature-derived nicknames; content-identity dedup guards
  against re-synced duplicates).
- Wave: the "bitchatters nearby" notification gains a "wave" quick
  action that broadcasts a mesh 👋 straight from the notification, even
  backgrounded (first UNNotificationCategory in the app).
- Dead drops: /drop pins a note to the current building geohash as a
  kind-1 location note with a 24h NIP-40 expiry; expired notes are now
  dropped client-side at ingest; the notices sheet shows "fades in Xh";
  a "location notes" toggle plus location-permission controls live in
  app info (also fixes the duplicated Voice section).
- Radar: an ambient sonar animation shows the radio scanning, with a
  privacy-safe daily tally ("N devices passed within range today" via
  salted per-day hashes) and a "notes left here" hint that opens the
  notices geo tab.

All new user-facing strings ship in all 29 locales. 1403 tests green,
including new suites for the activity tracker, sightings tally, and
note expiry/drop publishing.

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

* Review round: app-info polish, urgent/expiry parity, centered radar, pin fill, Codex P2s

- App info: LOCATION header uppercased like sibling sections; location
  notes and live voice descriptions shortened (29 locales); redundant
  "location access granted" line removed.
- Notices parity: urgent + expiry controls now show on the geo tab too;
  the bridged Nostr note carries ["t","urgent"] and NIP-40 so relay-side
  readers see both; urgent parsed back from incoming notes.
- Radar moved from the top of the empty state to the center of the chat
  area, below the help text (empty state fills the visible height).
- Header pin fills whenever the scope has notices (was: only unseen),
  and Nostr-only nearby notes now light it too.
- Codex P2 fixes: notification completion deferred until the wave action
  is handled (background suspension dropped the send); NIP-40 notes now
  prune on a timer when they expire while displayed; the location-notes
  kill switch retargets the nearby-notes counter immediately.

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

* Hide macOS segmented picker's built-in label in notices composer (duplicate 'expires in')

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

* Geo notes: permanent (∞) expiry default, urgent stays mesh-only

- Geo expiry picker gains ∞ as the default: a permanent note posts as a
  pure relay note (no NIP-40 tag, no mesh-board copy — a board copy must
  fade within days, contradicting the ∞ the user picked). 1/3/7d keep
  the board + bridged-note path with NIP-40.
- The notes manager is now owned by the notices sheet (not the list) so
  the composer local-echoes ∞ notes into the list; it revives via
  refresh() after a tab-switch cancel, and its expiry-prune timer
  survives cancel (weak self, dies with the instance).
- Urgent toggle returns to mesh-only per review — notes are ambient;
  the read-side urgent-tag parse stays so tagged notes still render.
- macOS: hide the segmented picker's built-in label (duplicate
  "expires in").

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

* Geo notes latency: EOSE scoped to reached relays, connecting state with auto-retry

Field finding: opening notices on a cold launch showed "no notices yet"
after ~10s, with notes popping in later — the empty state was a lie told
by two mechanisms:

- EOSE tracking waited on ALL target relays, including ones still
  mid-Tor-circuit; one dead relay of five pinned "loading" until the
  10s fallback. Trackers now count a relay only once the REQ actually
  reached it (marked from the send completion, or proven by its EOSE),
  so the first responding relay resolves the initial load and dropped
  targets can't stall it. The 10s fallback stays as backstop.
- When that fallback fired with ZERO connected target relays (Tor still
  bootstrapping), LocationNotesManager reported .ready with no notes.
  It now enters a .connecting state — rendered as "connecting to
  relays…" instead of the empty state — and polls every 3s, re-
  subscribing for a fresh initial fetch the moment a target relay
  comes up.

New NostrRelayManager.isAnyRelayConnected(among:) feeds the check via
an injectable dependency (tests default to legacy behavior). String
localized in all 29 locales. 1412 tests green, iOS+macOS builds clean.

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

* Clearing the mesh timeline dismisses echoes for good; tighter divider copy

Triple-tap /clear emptied the timeline but the next launch re-seeded
"heard here earlier" from the persisted archive. A MeshEchoSettings
watermark now records the clear; only messages heard after it come back
(the archive itself still carries everything for peers' sync). The
echo dedup keys reset with it, and panic wipe drops the watermark.

Divider copy tightened to "heard here earlier · last 6h" (29 locales).

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

* Mark EOSE relays at send initiation, closing the in-flight-send race

Codex P2 on #1411: with several sockets already connected, a fast
relay's EOSE could complete the tracker while a slower relay's async
send completion hadn't yet moved it out of awaitingSend — the initial
load reported done with that relay's stored events still pending.
Relays are now marked awaiting-EOSE synchronously when the REQ send is
initiated (and when a flush skips an already-subscribed relay), so
every relay that was actually asked is counted before any EOSE can
race. Failed sends resolve via the disconnect settle or the fallback.

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

* Echoes visual polish: tinted history block, radar-captioned tally, ambient footer

Device-test feedback on the echoes screen:
- Archived echoes now sit on a subtle tinted background (secondary at
  8%) in addition to the dim, so "heard here earlier" reads as one
  distinct block; the divider carries the echo ID prefix to join it.
- "N devices passed within range today" moves out of the narration
  lines to sit centered under the radar as its caption.
- When the timeline holds only echoes/system lines, a compact ambient
  footer (small radar + tally + live hints) renders below the history
  instead of the whole ambient layer vanishing.

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

* Notes strip persists above the mesh chat; leaner empty-state narration

- The 📍 "notes left here" line was empty-state-only, so starting a
  conversation hid it. It is now a tappable strip pinned above the mesh
  timeline whenever unexpired notes exist at this place (opens the
  notices geo tab); the nearby-notes counter runs for the whole mesh
  timeline, not just the empty state.
- Empty state narration drops "nobody in range yet..." (the radar and
  the sightings caption already say it) and the nearby-conversation
  hint moves below the help line instead of splitting the narration.

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

* Radar means searching: hide the sweep once mesh peers are connected or reachable

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

* Resolve leftover merge conflict markers from the main restack

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

* Drop two Nostr helper constants resurrected by the restack merge (dead on main)

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-08 14:16:46 +02:00
74b8a47d02 Empty-mesh liveliness: nearby conversations, echoes, wave, dead drops, radar (#1409)
* Empty-mesh liveliness: nearby conversations, echoes, wave action, dead drops, radar

The empty mesh timeline was a dead end: a grey zero and "nobody in range
yet". This turns it into a live surface and gives the app pull when the
mesh wakes up:

- Nearest conversation: background geohash sampling now tracks actual
  chat messages (not just presence) per regional channel; the empty state
  surfaces the busiest nearby conversation with a preview, one tap to
  join (GeohashChatActivityTracker, fed from GeoPresenceTracker).
- Echoes: the carried 6h store-and-forward window renders as dimmed
  "heard here earlier" rows at launch (new Transport
  collectArchivedPublicMessages -> GossipSyncManager snapshot, decoded
  with signature-derived nicknames; content-identity dedup guards
  against re-synced duplicates).
- Wave: the "bitchatters nearby" notification gains a "wave" quick
  action that broadcasts a mesh 👋 straight from the notification, even
  backgrounded (first UNNotificationCategory in the app).
- Dead drops: /drop pins a note to the current building geohash as a
  kind-1 location note with a 24h NIP-40 expiry; expired notes are now
  dropped client-side at ingest; the notices sheet shows "fades in Xh";
  a "location notes" toggle plus location-permission controls live in
  app info (also fixes the duplicated Voice section).
- Radar: an ambient sonar animation shows the radio scanning, with a
  privacy-safe daily tally ("N devices passed within range today" via
  salted per-day hashes) and a "notes left here" hint that opens the
  notices geo tab.

All new user-facing strings ship in all 29 locales. 1403 tests green,
including new suites for the activity tracker, sightings tally, and
note expiry/drop publishing.

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

* Review round: app-info polish, urgent/expiry parity, centered radar, pin fill, Codex P2s

- App info: LOCATION header uppercased like sibling sections; location
  notes and live voice descriptions shortened (29 locales); redundant
  "location access granted" line removed.
- Notices parity: urgent + expiry controls now show on the geo tab too;
  the bridged Nostr note carries ["t","urgent"] and NIP-40 so relay-side
  readers see both; urgent parsed back from incoming notes.
- Radar moved from the top of the empty state to the center of the chat
  area, below the help text (empty state fills the visible height).
- Header pin fills whenever the scope has notices (was: only unseen),
  and Nostr-only nearby notes now light it too.
- Codex P2 fixes: notification completion deferred until the wave action
  is handled (background suspension dropped the send); NIP-40 notes now
  prune on a timer when they expire while displayed; the location-notes
  kill switch retargets the nearby-notes counter immediately.

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

* Hide macOS segmented picker's built-in label in notices composer (duplicate 'expires in')

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

* Geo notes: permanent (∞) expiry default, urgent stays mesh-only

- Geo expiry picker gains ∞ as the default: a permanent note posts as a
  pure relay note (no NIP-40 tag, no mesh-board copy — a board copy must
  fade within days, contradicting the ∞ the user picked). 1/3/7d keep
  the board + bridged-note path with NIP-40.
- The notes manager is now owned by the notices sheet (not the list) so
  the composer local-echoes ∞ notes into the list; it revives via
  refresh() after a tab-switch cancel, and its expiry-prune timer
  survives cancel (weak self, dies with the instance).
- Urgent toggle returns to mesh-only per review — notes are ambient;
  the read-side urgent-tag parse stays so tagged notes still render.
- macOS: hide the segmented picker's built-in label (duplicate
  "expires in").

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

* Clearing the mesh timeline dismisses echoes for good; tighter divider copy

Triple-tap /clear emptied the timeline but the next launch re-seeded
"heard here earlier" from the persisted archive. A MeshEchoSettings
watermark now records the clear; only messages heard after it come back
(the archive itself still carries everything for peers' sync). The
echo dedup keys reset with it, and panic wipe drops the watermark.

Divider copy tightened to "heard here earlier · last 6h" (29 locales).

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

* Echoes visual polish: tinted history block, radar-captioned tally, ambient footer

Device-test feedback on the echoes screen:
- Archived echoes now sit on a subtle tinted background (secondary at
  8%) in addition to the dim, so "heard here earlier" reads as one
  distinct block; the divider carries the echo ID prefix to join it.
- "N devices passed within range today" moves out of the narration
  lines to sit centered under the radar as its caption.
- When the timeline holds only echoes/system lines, a compact ambient
  footer (small radar + tally + live hints) renders below the history
  instead of the whole ambient layer vanishing.

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

* Notes strip persists above the mesh chat; leaner empty-state narration

- The 📍 "notes left here" line was empty-state-only, so starting a
  conversation hid it. It is now a tappable strip pinned above the mesh
  timeline whenever unexpired notes exist at this place (opens the
  notices geo tab); the nearby-notes counter runs for the whole mesh
  timeline, not just the empty state.
- Empty state narration drops "nobody in range yet..." (the radar and
  the sightings caption already say it) and the nearby-conversation
  hint moves below the help line instead of splitting the narration.

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

* Radar means searching: hide the sweep once mesh peers are connected or reachable

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-08 13:24:44 +02:00
ef848857b7 Remove dead code found by full Periphery audit; add scan config + advisory CI (#1410)
Periphery 3.7.4 audit of both schemes (macOS + iOS, intersected so
platform-specific code is never touched), with test targets indexed and
the share extension built. 277 dead declarations removed or demoted:
dead forwarding wrappers (ChatViewModel+Nostr/+PrivateChat), removed-
feature remnants (autocomplete command suggestions, back-swipe tuning,
MediaSendError, GeohashParticipantTracker), unused Tor dormancy
bindings, assign-only properties, unused parameters (renamed to _), and
redundant public accessibility. 13 orphaned localization keys deleted
across all 29 locales (old pre-#1392 location-notes UI, app_info
warnings).

Two real tests were flagged as unused because they never ran: Swift
Testing methods missing @Test (NostrProtocolTests.
testAckRoundTripNIP44V2_Delivered, NotificationStreamAssemblerTests.
testAssemblesCompressedLargeFrame). Re-armed both; they pass.

Deliberately kept, now recorded in .periphery.baseline.json: iOS-only
code invisible to the CI macOS scan, C FFI signatures, keep-alive
NWPathMonitor reference, InboundEventKey.eventID (dedup semantics),
wifiBulk capability bit (reserved for Wi-Fi bulk work, used by
BitFoundation package tests), and the String secureClear cluster
(exercised by package tests).

New: .periphery.yml config and an advisory Dead Code CI job (mirrors
the SwiftLint precedent from #1361) that fails on findings not in the
committed baseline.

Verified: full macOS app suite, BitFoundation (119) and BitLogger (13)
package tests green; periphery scan --strict exits clean.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 11:24:19 +02:00
d5cf64f99e Require signed sender for broadcast file transfers (#1406 follow-up) (#1407)
* Require signed sender for broadcast file transfers (#1406 follow-up)

Broadcast file transfers trusted the packet's claimed senderID whenever
the peer was merely connected (resolveKnownPeer allowConnectedUnverified:
true), unlike public messages and public voice frames, which both require
a valid packet signature from the claimed sender.

Codex flagged the consequence on PR #1406: a peer that observed a public
voice burst could broadcast a spoofed voice_<burstID>.m4a note under the
talker's senderID, and ChatLiveVoiceCoordinator.absorbFinalizedVoiceNote
would replace the signature-verified live bubble with attacker audio
(senderPeerID + scope were the only bindings, both attacker-forgeable on
this path).

Bring broadcast file transfers up to the same bar as public messages:
verify the packet signature against the registry signing key, falling
back to the persisted-identity signature lookup, before trusting the
sender. Directed (private) transfers keep the lenient connected-peer path
— they are addressed to us specifically and carry no broadcast exposure.

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

* Exempt self broadcasts from the file-transfer signature gate

Review of #1407 caught a regression: our own broadcast files replayed via
gossip sync arrive with ttl==0 (so isSelfEcho does not drop them) and
cannot be verified against the peer registry or identity cache, so the new
broadcast signature guard would drop them. Mirror BLEPublicMessageHandler's
self exemption — self packets are trivially authentic — and add a
regression test.

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

* Stop relaying broadcast file packets that fail sender authentication

Codex review on #1407: the new signature gate dropped spoofed broadcast
files locally, but BLEService's .fileTransfer case still fell through to
scheduleRelayIfNeeded, so a forged file kept propagating to downstream
(possibly older, ungated) nodes. Have the handler report failed sender
authentication and skip the relay step, like invalid board posts and
voice frames. Local-only drops (malformed payload, quota, save failure)
and files directed to other peers still relay unchanged.

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

* Fix CI hang: sign the max-size reassembly file transfer, unhang timeouts

Both CI runs on #1407 died at the 5-minute watchdog (SIGKILL, exit 137)
with the test process fully idle. Root cause was a pair of issues in
FragmentationTests:

- "Max-sized file transfer survives reassembly" injected an UNSIGNED
  broadcast file from an unknown peer, which the new broadcast
  signature gate now drops by design. Sign the packet and preseed the
  sender's signing key, mirroring the public-message reassembly tests.

- CaptureDelegate's wait helpers could never time out: the timeout task
  threw, but withThrowingTaskGroup then awaited the sibling child that
  was parked in a non-cancellable withCheckedContinuation, deadlocking
  the whole run (hang instead of a 5s failure). Resume the parked
  continuation from a cancellation handler so timeouts now fail fast.

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-08 11:07:40 +02:00
35fb9fdd42 Fix received DM images stuck as grey box: stamp incoming media delivered, reveal tap as Button (#1402)
* Make image tap-to-reveal a Button so the DM sheet can't swallow it

Received images in a DM rendered as a grey "tap to reveal" box that
never revealed: the DM sheet wraps the whole conversation in a
high-priority swipe-to-close DragGesture (ContentSheetViews), and an
ancestor high-priority gesture starves descendant TapGestures — the
image's reveal/open tap never fired. Button actions survive that
suppression (the sheet's own header buttons work for the same reason),
so the tap now lives on a plain-style Button wrapping the image;
swipe-to-hide stays attached as a simultaneous gesture.

Fixes #1388

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

* Move the cancel control out of the reveal Button

Nested buttons don't get reliable independent hit testing, and the
outer reveal tap is a no-op while sending, so the in-flight cancel x
could become untappable. The cancel overlay now sits on the Button
rather than inside its label.

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

* Root cause: received media stuck in .sending — grey box, dead tap

Field testing showed the Button conversion alone didn't fix #1388: a
received DM image still rendered as a flat grey box with a dead tap.
The real culprit is BitchatMessage's initializer, which defaults every
private message without an explicit status to `.sending`.
BLEFileTransferHandler built incoming media messages without one, so
every received private image/voice note was permanently "sending":
mediaSendState returned progress 0 → BlockRevealMask rendered 0% of
the image (the flat grey box is the blur overlay over an empty mask),
and the reveal tap was disabled by the isSending guard — regardless of
which gesture carried it.

Two layers:
- BLEFileTransferHandler now stamps incoming private media
  `.delivered(to: <local nickname>, at: <packet time>)`, matching the
  received-text path in ChatPrivateConversationCoordinator.
- MediaMessageView treats received messages as never-sending, so no
  other construction path can reproduce the grey-box state.

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-08 10:08:36 +02:00
3e9230229d Heal peer-ID rotation: rebind link on verified announce, retire ghost peer (#1401)
* Heal peer-ID rotation: rebind link on verified announce, retire ghost

When a peer relaunches it rotates its ephemeral peer ID, but a
still-open BLE connection kept its stale peripheral/central→peerID
binding for the reachability retention window (~45-60s). Until it aged
out, the other side showed a duplicate ghost peer and dropped the
rotated peer's direct announces as spoofing attempts.

Root cause: the ingress guard rejected a direct announce whose claimed
sender differed from the link binding before signature verification
could ever see it, so the binding could never heal.

- Ingress guard: let a mismatched direct announce through, attributed
  to the claimed sender; REQUEST_SYNC keeps the strict binding check.
- Bind sites: raw (pre-verification) announces may only bind unbound
  links, never rebind bound ones — rebinding now requires the announce
  handler's signature verification to pass.
- On a verified direct announce whose ingress link is bound to a
  different peer, rebind the link to the announced ID and retire the
  rotated-away ID immediately (registry + gossip + UI), mirroring
  handleLeave.
- BLELinkStateStore.bindPeripheral: drop the previous peer's reverse
  mapping on rebind so the retired ID no longer claims the link.

Fixes #1387

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

* Contain forged-directness rebinds: no identity steal, per-link cooldown

The announce signature does not authenticate directness (TTL is
excluded from signing because relays mutate it), so a bound peer could
replay another peer's fresh signed announce with its TTL restored and
reach the rotation rebind path. Contain what such a replay can do:

- Refuse a rebind when the claimed identity already owns another live
  link — a replayed announce can no longer steal a connected peer's
  binding or route its directed traffic to the replaying link.
- Allow at most one rebind per link per cooldown window (60s) so two
  identities cannot fight over a link in a replay flip-flop, with each
  flip retiring the other peer.

A replay against an identity with no live link remains possible, but
that capability already exists today on unbound links, where any raw
direct announce binds pre-verification.

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-08 10:08:23 +02:00
78a291ab77 Public-mesh push-to-talk: signed live voice bursts in the mesh channel (#1406)
* Live push-to-talk voice for DMs: stream while you talk, voice note as fallback

Holding the mic in a DM now streams AAC frames live over the Noise session
(walkie-talkie style, ~0.5s mouth-to-ear at one hop) while recording the same
audio as a normal voice note. On release the note ships through the existing
fileTransfer pipeline; receivers that heard the live stream absorb it silently
into the same bubble (matched by the burst ID embedded in the file name), so
reliability comes for free and nobody sees duplicates.

Protocol:
- NoisePayloadType.voiceFrame = 0x08 carrying VoiceBurstPacket
  (burstID + seq + START/data/END/CANCELED, length-prefixed AAC frames)
- 210-byte burst-content budget keeps each Noise packet inside the 256-byte
  padding bucket: one BLE frame, never the fragment scheduler
- fire-and-forget: frames are dropped (never queued) without an established
  session; live is only offered when the peer is mesh-reachable

Receive:
- ChatLiveVoiceCoordinator assembles bursts (jitter-ordered, 0.5s gap skip,
  3s idle end, flood/size caps), persists progressively as ADTS .aac so even
  a partial burst is a replayable bubble
- live autoplay only when the conversation is on screen, app active, and the
  new app-info "live voice messages" toggle is on (also gates live sending)
- one-playback-at-a-time via a shared ExclusivePlayback slot

Capture:
- PTTCaptureEngine taps AVAudioEngine, dual-encodes: live AAC frames + the
  finalized .m4a (same 16kHz/mono/16kbps settings as VoiceRecorder)
- VoiceRecordingViewModel now drives a pluggable VoiceCaptureSession; the
  composer HUD shows a pulsing LIVE treatment when streaming

Includes the push-to-talk design doc, 6 new localization keys across all 29
locales, and unit tests for framing, packetizer budget, ADTS output, codec
round-trip, and the assembly/absorb lifecycle. Public-mesh PTT (MessageType
0x29) lands separately on top of this.

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

* Public-mesh push-to-talk: signed live voice bursts in the mesh channel

Extends live PTT from DMs to the public mesh timeline. Holding the mic in
the mesh channel now broadcasts the burst live as signed voiceFrame packets
(MessageType 0x29) while the finalized voice note still ships on release —
new clients hear you as you speak and absorb the note silently into the live
bubble; old clients (and late joiners) keep receiving the note exactly as
before, so mixed-version meshes lose nothing.

Wire/relay:
- MessageType.voiceFrame = 0x29: ephemeral signed broadcast, never
  gossip-synced (SyncTypeFlags maps it to no bit), never padded (padding to
  the 512 block would push every ~490-byte signed packet into fragmentation)
- RelayController treats voiceFrame like media fragments: dense-graph TTL
  clamp contains the sustained ~15 pkt/s per-talker stream, tight 8-25 ms
  jitter keeps multi-hop latency inside the receiver's 350 ms jitter buffer
- inbound gate mirrors public messages: broadcast-only, 30 s freshness cap,
  packet signature verified against the claimed sender's announce before any
  audio reaches the UI

App:
- ChatLiveVoiceCoordinator gains burst scopes: public bubbles land in the
  mesh timeline, autoplay only while that timeline is on screen, and the
  finalized-note absorb is scope-bound (a public note can't replace a DM
  burst or vice versa)
- floor courtesy: while someone talks live in the public channel the
  composer mic tints red and pulses, with an accessibility value naming the
  talker ("%@ is speaking", localized in all 29 locales); holding still
  works — a decentralized mesh has no floor arbiter, the tint just
  discourages talk-over

Tests: relay policy (sparse cap + dense clamp), public bubble + talker
indicator lifecycle, note absorption into the mesh store, and scope-binding
rejection; full suite green (1382 tests).

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

* PTT follow-ups from review + field test: peer-ID normalization, toggle gates inbound, drop-path diagnostics

Codex review fixes (#1403):
- makeVoiceCaptureSession normalizes the selected peer with toShort() before
  the reachability/session checks and binds the send target to that same
  routing ID — a conversation selected under the stable 64-hex Noise key no
  longer silently falls back to a classic note while the short-ID session is
  established
- the live-voice toggle now gates inbound bursts too: off means
  classic-notes-only in both directions (no live bubble, partial file, or
  early notification; the finalized note still arrives), with a test

Field-test diagnostics (first device run: DM frames decrypted but no bubble
appeared, with no log evidence of which guard dropped them):
- coordinator logs undecodable frames (size + hex prefix) and blocked drops
- makeAssembly logs directory/file-handle failures instead of returning nil
  silently
- PTTLiveVoiceSession logs capture start and finish (packet/frame/duration
  counts); PTTCaptureEngine logs engine start success/failure with the input
  format; BLEService.sendVoiceFrame logs no-session drops

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

* Fix iPhone live-capture failure: dead input unit (AURemoteIO -10851, 0 Hz)

Field testing showed the phone's live capture failing at mic enable with
AURemoteIO -10851 and an input format of 0 Hz / 2 ch — an input unit bound
to an earlier (playback-only or settling) audio session. The Mac, which has
no session lifecycle, captured fine, which is why public bursts from the Mac
worked while phone-side sends degraded from working (first hold) to sporadic
to dead across holds.

Three layers of defense:
- PTTCaptureEngine recreates its AVAudioEngine on every start(), after the
  session is configured, so the input unit binds to the session that is
  active now; a dead input (0 Hz or 0 channels) is now a distinct, logged
  error instead of a silent setup failure
- PTTLiveVoiceSession retries the capture start once after a 150 ms
  route-settle pause
- VoiceRecordingViewModel falls back to the classic VoiceRecorder within the
  same hold if the live engine still cannot start — a route glitch now costs
  the live stream, never the voice note

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

* Blue mic when the hold will stream live

The mic button now shows readiness at a glance — and doubles as a build
marker for device testing:
- blue: holding will stream live (DM peer reachable with an established
  Noise session, or the public mesh channel)
- accent (orange in DMs): holding records a classic voice note (no session
  yet, peer unreachable, or live voice toggled off)
- red states unchanged (recording, floor busy)

Refactors capture-backend selection into a single liveVoiceTarget() so the
indicator and makeVoiceCaptureSession can never disagree.

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

* Revert the blue live-ready mic to the normal accent color

The build-verification marker did its job; idle mic color goes back to the
accent. The LIVE recording HUD remains the signal for whether a hold is
streaming. Keeps the liveVoiceTarget() refactor so backend selection stays
in one place.

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

* Leave a trace on every mic press and every inbound-frame drop

Field testing read "tap does nothing" as breakage: the mic start is async
(permission check + engine spin-up), so releasing before recording begins
has always been a silent cancel — for classic voice notes too. Every press
now logs which backend it chose and, for quick presses, that it released
before recording started.

Also logs the two remaining silent drops: inbound voice frames rejected by
the live-voice toggle (the one unlogged guard left in the receive path) and
the classic-note fallback now includes the toggle state.

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

* Fix mic hold dying instantly in DMs: sheet swipe gesture starved the composer

Field logs showed every DM mic hold ending 3-10 ms after it began, on both
platforms, while public-channel holds worked — the private sheet wraps its
entire content (composer included) in a high-priority swipe-right-to-leave
DragGesture, and a high-priority ancestor drag cancels the mic button's
press-and-hold within milliseconds. Same starvation mechanism as the DM
image-reveal bug (#1402), hitting a drag instead of a tap.

The swipe-to-leave gesture now lives on the message list only, so the
composer's gestures (mic hold, text field, buttons) are out of its reach and
the swipe still works where users actually swipe.

Also stops touching the capture engine when a hold cancels before the engine
ever started: probing inputNode on a never-started engine instantiates its
input unit against whatever session is active and spams benign-but-alarming
AURemoteIO -10851 errors into field logs.

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

* Reorder app info sheet: usage first, then settings, then reference

New section order: HOW TO USE, then the adjustable bits (appearance, voice,
network), then the reference material (features, privacy, symbols legend).

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

* App info: flow HOW TO USE into one paragraph; "list" and "person" wording

The six how-to-use bullets now read as a single comma-separated paragraph
(same instruction strings, legacy bullet prefix stripped at render). Two
wording updates across all 29 locales: the people icon opens the "list"
(not "sidebar"), and you tap a "person's" name (not a "peer's") to start
a DM.

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-08 09:23:16 +02:00
eacd8f0750 Live push-to-talk voice for DMs (streams while you talk, voice note as fallback) (#1403)
* Live push-to-talk voice for DMs: stream while you talk, voice note as fallback

Holding the mic in a DM now streams AAC frames live over the Noise session
(walkie-talkie style, ~0.5s mouth-to-ear at one hop) while recording the same
audio as a normal voice note. On release the note ships through the existing
fileTransfer pipeline; receivers that heard the live stream absorb it silently
into the same bubble (matched by the burst ID embedded in the file name), so
reliability comes for free and nobody sees duplicates.

Protocol:
- NoisePayloadType.voiceFrame = 0x08 carrying VoiceBurstPacket
  (burstID + seq + START/data/END/CANCELED, length-prefixed AAC frames)
- 210-byte burst-content budget keeps each Noise packet inside the 256-byte
  padding bucket: one BLE frame, never the fragment scheduler
- fire-and-forget: frames are dropped (never queued) without an established
  session; live is only offered when the peer is mesh-reachable

Receive:
- ChatLiveVoiceCoordinator assembles bursts (jitter-ordered, 0.5s gap skip,
  3s idle end, flood/size caps), persists progressively as ADTS .aac so even
  a partial burst is a replayable bubble
- live autoplay only when the conversation is on screen, app active, and the
  new app-info "live voice messages" toggle is on (also gates live sending)
- one-playback-at-a-time via a shared ExclusivePlayback slot

Capture:
- PTTCaptureEngine taps AVAudioEngine, dual-encodes: live AAC frames + the
  finalized .m4a (same 16kHz/mono/16kbps settings as VoiceRecorder)
- VoiceRecordingViewModel now drives a pluggable VoiceCaptureSession; the
  composer HUD shows a pulsing LIVE treatment when streaming

Includes the push-to-talk design doc, 6 new localization keys across all 29
locales, and unit tests for framing, packetizer budget, ADTS output, codec
round-trip, and the assembly/absorb lifecycle. Public-mesh PTT (MessageType
0x29) lands separately on top of this.

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

* PTT follow-ups from review + field test: peer-ID normalization, toggle gates inbound, drop-path diagnostics

Codex review fixes (#1403):
- makeVoiceCaptureSession normalizes the selected peer with toShort() before
  the reachability/session checks and binds the send target to that same
  routing ID — a conversation selected under the stable 64-hex Noise key no
  longer silently falls back to a classic note while the short-ID session is
  established
- the live-voice toggle now gates inbound bursts too: off means
  classic-notes-only in both directions (no live bubble, partial file, or
  early notification; the finalized note still arrives), with a test

Field-test diagnostics (first device run: DM frames decrypted but no bubble
appeared, with no log evidence of which guard dropped them):
- coordinator logs undecodable frames (size + hex prefix) and blocked drops
- makeAssembly logs directory/file-handle failures instead of returning nil
  silently
- PTTLiveVoiceSession logs capture start and finish (packet/frame/duration
  counts); PTTCaptureEngine logs engine start success/failure with the input
  format; BLEService.sendVoiceFrame logs no-session drops

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

* Fix iPhone live-capture failure: dead input unit (AURemoteIO -10851, 0 Hz)

Field testing showed the phone's live capture failing at mic enable with
AURemoteIO -10851 and an input format of 0 Hz / 2 ch — an input unit bound
to an earlier (playback-only or settling) audio session. The Mac, which has
no session lifecycle, captured fine, which is why public bursts from the Mac
worked while phone-side sends degraded from working (first hold) to sporadic
to dead across holds.

Three layers of defense:
- PTTCaptureEngine recreates its AVAudioEngine on every start(), after the
  session is configured, so the input unit binds to the session that is
  active now; a dead input (0 Hz or 0 channels) is now a distinct, logged
  error instead of a silent setup failure
- PTTLiveVoiceSession retries the capture start once after a 150 ms
  route-settle pause
- VoiceRecordingViewModel falls back to the classic VoiceRecorder within the
  same hold if the live engine still cannot start — a route glitch now costs
  the live stream, never the voice note

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-08 09:20:58 +02:00
6886035632 Fix press-and-hold mic dying instantly in private chats (#1405)
The private-chat sheet wraps its entire content — composer included — in a
high-priority swipe-right-to-leave DragGesture. A high-priority ancestor
drag preempts child gestures, so the composer mic's press-and-hold was
cancelled 3-10 ms after touch-down (observed on both iOS and macOS in field
logs), making voice notes unrecordable inside DMs while working fine in the
public timeline. Same starvation mechanism as the DM image-reveal bug
(#1402), hitting a drag instead of a tap.

The swipe-to-leave gesture now attaches to the message list only — where
users actually swipe — leaving the composer's gestures (mic hold, text
field, buttons) out of its reach.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 09:20:54 +02:00
e642f696cb Header: globe opens channel sheet; deterministic crowding cascade (#1400)
The gateway globe indicator was a plain Image with a tooltip but no tap
handler. Wrap it in a Button that opens the location channels sheet,
where the gateway toggle lives, so tapping it lets people turn the
gateway on or off. Adds an accessibility hint localized into all 29
locales.

When the header got crowded with indicator icons, the bitchat/ logo and
the nickname field both sat at layout priority 0, so compression was
split arbitrarily between them — and the logo, lacking a lineLimit,
wrapped to two lines inside the fixed-height bar. Make the degradation
order explicit: the nickname shrinks first, the logo holds full width
until the name is gone and then truncates on one line, and the icon
cluster (priority 3) never compresses.

Render-verified with an offscreen ImageRenderer harness at 320/375/500pt
with the maximum icon load (globe, courier, envelope, pin, bookmark).

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 08:46:40 +02:00
356 changed files with 52083 additions and 8136 deletions
+209 -23
View File
@@ -1,42 +1,228 @@
name: Fetch GeoRelays Data name: Propose GeoRelay Data Update
on: on:
schedule: schedule:
- cron: '0 6 * * 0' - cron: "0 6 * * 0"
workflow_dispatch: workflow_dispatch:
# Default to read-only. The publishing job receives only the scopes required
# to push its branch and publish either a PR or a tracking issue.
permissions:
contents: read
concurrency:
group: georelay-data-update
cancel-in-progress: false
env:
SOURCE_REPOSITORY: https://github.com/permissionlesstech/georelays.git
UPDATE_BRANCH: automation/georelay-data
TRACKING_ISSUE_TITLE: GeoRelay update awaiting pull request
jobs:
propose-relay-data:
name: Validate and propose relay data
runs-on: ubuntu-latest
timeout-minutes: 10
permissions: permissions:
contents: write contents: write
pull-requests: write pull-requests: write
issues: write
jobs:
update-relay-data:
runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout reviewed base
uses: actions/checkout@v4 # Pinned actions/checkout v5 so a mutable action tag cannot change the
# code that receives this job's write-capable token.
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
with: with:
token: ${{ secrets.GITHUB_TOKEN }} ref: main
fetch-depth: 0 fetch-depth: 0
# Do not expose the write token to fetch/validation subprocesses.
persist-credentials: false
- name: Fetch GeoRelays - name: Test GeoRelay validator
run: | run: |
wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv set -euo pipefail
mv nostr_relays.csv ./relays/online_relays_gps.csv python3 -m unittest discover -s scripts/tests -p "test_*.py" -v
- name: Check for changes - name: Fetch candidate over pinned HTTPS policy
id: git-check id: upstream
run: | run: |
git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT set -euo pipefail
source_commit=$(git ls-remote --refs "$SOURCE_REPOSITORY" refs/heads/main | awk 'NR == 1 { print $1 }')
if [[ ! "$source_commit" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::Could not resolve an immutable upstream commit"
exit 1
fi
source_url="https://raw.githubusercontent.com/permissionlesstech/georelays/$source_commit/nostr_relays.csv"
effective_url=$(curl --fail --show-error --silent --location --proto "=https" --proto-redir "=https" --tlsv1.2 --max-time 60 --retry 3 --retry-all-errors --output "$RUNNER_TEMP/georelays-candidate.csv" --write-out "%{url_effective}" "$source_url")
if [[ "$effective_url" != "$source_url" ]]; then
echo "::error::Unexpected GeoRelay redirect target: $effective_url"
exit 1
fi
echo "source_commit=$source_commit" >> "$GITHUB_OUTPUT"
echo "source_url=$source_url" >> "$GITHUB_OUTPUT"
- name: Commit and push changes - name: Validate candidate against reviewed baseline
if: steps.git-check.outputs.changes == 'true' id: validation
run: | run: |
git config --local user.email "action@github.com" set -euo pipefail
git config --local user.name "GitHub Action" python3 scripts/validate_georelays.py --input "$RUNNER_TEMP/georelays-candidate.csv" --baseline relays/online_relays_gps.csv --output relays/online_relays_gps.csv --github-output "$GITHUB_OUTPUT"
git add relays/online_relays_gps.csv
git commit -m "Automated update of relay data - $(date -u)" - name: Check for a reviewed-file change
git push id: changes
run: |
set -euo pipefail
if git diff --quiet -- relays/online_relays_gps.csv; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "Upstream GeoRelay data already matches main." >> "$GITHUB_STEP_SUMMARY"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
git diff --stat -- relays/online_relays_gps.csv
fi
- name: Push automation branch and publish review request
if: steps.changes.outputs.changed == 'true'
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ github.token }}
SOURCE_COMMIT: ${{ steps.upstream.outputs.source_commit }}
SOURCE_URL: ${{ steps.upstream.outputs.source_url }}
DATA_ROWS: ${{ steps.validation.outputs.data_rows }}
UNIQUE_RELAYS: ${{ steps.validation.outputs.unique_relays }}
DATA_SHA256: ${{ steps.validation.outputs.sha256 }}
run: |
set -euo pipefail
# Scope credential exposure to this final publishing step.
gh auth setup-git
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git switch -C "$UPDATE_BRANCH"
git add -- relays/online_relays_gps.csv
git diff --cached --quiet && {
echo "::error::Expected a staged GeoRelay data change"
exit 1
}
git commit -m "Update reviewed georelay directory" -m "Upstream-commit: $SOURCE_COMMIT"
remote_ref="refs/remotes/origin/$UPDATE_BRANCH"
if git fetch --no-tags origin "+refs/heads/$UPDATE_BRANCH:$remote_ref" 2>/dev/null; then
remote_sha=$(git rev-parse "$remote_ref")
git push --force-with-lease="refs/heads/$UPDATE_BRANCH:$remote_sha" origin "HEAD:refs/heads/$UPDATE_BRANCH"
else
git push origin "HEAD:refs/heads/$UPDATE_BRANCH"
fi
body_file="$RUNNER_TEMP/georelay-pr-body.md"
{
echo "## Automated GeoRelay data proposal"
echo
echo "- Source: $SOURCE_URL"
echo "- Upstream commit: $SOURCE_COMMIT"
echo "- Data rows: $DATA_ROWS"
echo "- Unique normalized relays: $UNIQUE_RELAYS"
echo "- SHA-256: $DATA_SHA256"
echo
echo "The candidate passed strict UTF-8, schema, size, row-count, secure-host, coordinate, duplicate-conflict, and baseline-delta validation."
echo
echo "This PR is intentionally not auto-merged. Review the relay additions/removals before merging."
} > "$body_file"
existing_pr=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --base main --head "$UPDATE_BRANCH" --json number --jq '.[0].number // empty')
pr_error="$RUNNER_TEMP/georelay-pr-error.txt"
pr_url=""
if [[ -n "$existing_pr" ]]; then
if gh pr edit "$existing_pr" --repo "$GITHUB_REPOSITORY" --title "Update reviewed GeoRelay directory" --body-file "$body_file" 2> "$pr_error"; then
pr_url=$(gh pr view "$existing_pr" --repo "$GITHUB_REPOSITORY" --json url --jq .url)
fi
else
if created_pr_url=$(gh pr create --repo "$GITHUB_REPOSITORY" --base main --head "$UPDATE_BRANCH" --title "Update reviewed GeoRelay directory" --body-file "$body_file" 2> "$pr_error"); then
pr_url="$created_pr_url"
fi
fi
tracking_issue_numbers=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "\"$TRACKING_ISSUE_TITLE\" in:title" --limit 100 --json number,title --jq ".[] | select(.title == \"$TRACKING_ISSUE_TITLE\") | .number")
tracking_issues=()
if [[ -n "$tracking_issue_numbers" ]]; then
mapfile -t tracking_issues <<< "$tracking_issue_numbers"
fi
if [[ -n "$pr_url" ]]; then
for issue_number in "${tracking_issues[@]}"; do
gh issue close "$issue_number" --repo "$GITHUB_REPOSITORY" --comment "A pull request is now available at $pr_url; closing this fallback tracking issue."
done
echo "Published GeoRelay review PR: $pr_url" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
echo "::warning::GITHUB_TOKEN could not create or update the GeoRelay pull request; publishing the issues-write fallback."
if [[ -s "$pr_error" ]]; then
cat "$pr_error" >&2
fi
compare_url="https://github.com/${GITHUB_REPOSITORY}/compare/main...${UPDATE_BRANCH}?expand=1"
issue_body_file="$RUNNER_TEMP/georelay-tracking-issue-body.md"
{
echo "## Validated GeoRelay update awaiting review"
echo
echo "The automation branch was updated, but this workflow token could not create or update the pull request. Use the compare link below to create it manually."
echo
echo "- Compare and create PR: $compare_url"
echo "- Automation branch: $UPDATE_BRANCH"
echo "- Source: $SOURCE_URL"
echo "- Upstream commit: $SOURCE_COMMIT"
echo "- Data rows: $DATA_ROWS"
echo "- Unique normalized relays: $UNIQUE_RELAYS"
echo "- SHA-256: $DATA_SHA256"
echo
echo "The snapshot passed the repository's strict validator before the branch was pushed."
} > "$issue_body_file"
if (( ${#tracking_issues[@]} > 0 )); then
primary_issue="${tracking_issues[0]}"
gh issue edit "$primary_issue" --repo "$GITHUB_REPOSITORY" --title "$TRACKING_ISSUE_TITLE" --body-file "$issue_body_file"
issue_url=$(gh issue view "$primary_issue" --repo "$GITHUB_REPOSITORY" --json url --jq .url)
for duplicate_issue in "${tracking_issues[@]:1}"; do
gh issue close "$duplicate_issue" --repo "$GITHUB_REPOSITORY" --comment "Closing duplicate GeoRelay automation tracking issue; #$primary_issue is canonical."
done
else
issue_url=$(gh issue create --repo "$GITHUB_REPOSITORY" --title "$TRACKING_ISSUE_TITLE" --body-file "$issue_body_file")
fi
# Do not claim success until the fallback issue was confirmed.
[[ -n "$issue_url" ]]
echo "Published GeoRelay tracking issue fallback: $issue_url" >> "$GITHUB_STEP_SUMMARY"
- name: Clean obsolete automation review state
if: steps.changes.outputs.changed == 'false'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh auth setup-git
existing_pr=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --base main --head "$UPDATE_BRANCH" --json number --jq '.[0].number // empty')
if [[ -n "$existing_pr" ]]; then
gh pr close "$existing_pr" --repo "$GITHUB_REPOSITORY" --comment "Upstream now matches the reviewed file on main; closing this obsolete automation proposal."
echo "Closed obsolete PR #$existing_pr." >> "$GITHUB_STEP_SUMMARY"
fi
tracking_issue_numbers=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "\"$TRACKING_ISSUE_TITLE\" in:title" --limit 100 --json number,title --jq ".[] | select(.title == \"$TRACKING_ISSUE_TITLE\") | .number")
if [[ -n "$tracking_issue_numbers" ]]; then
while IFS= read -r issue_number; do
gh issue close "$issue_number" --repo "$GITHUB_REPOSITORY" --comment "Upstream now matches the reviewed file on main; closing this obsolete automation tracker."
echo "Closed obsolete tracking issue #$issue_number." >> "$GITHUB_STEP_SUMMARY"
done <<< "$tracking_issue_numbers"
fi
if git ls-remote --exit-code --heads origin "refs/heads/$UPDATE_BRANCH" > /dev/null; then
git push origin --delete "$UPDATE_BRANCH"
echo "Deleted obsolete automation branch $UPDATE_BRANCH." >> "$GITHUB_STEP_SUMMARY"
else
ls_remote_status=$?
if (( ls_remote_status != 2 )); then
echo "::error::Could not inspect the obsolete automation branch"
exit "$ls_remote_status"
fi
fi
+30
View File
@@ -0,0 +1,30 @@
name: Dead Code
on:
push:
branches:
- main
pull_request:
jobs:
periphery:
name: Periphery scan
runs-on: macos-latest
timeout-minutes: 30
# Advisory, like SwiftLint (#1361): findings annotate the PR but don't
# block merges. Drop continue-on-error once the baseline proves stable.
continue-on-error: true
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Install Periphery
# homebrew-core formula; the peripheryapp tap lags years behind.
run: brew install periphery
- name: Scan for dead code
# Config comes from .periphery.yml; known findings (mostly iOS-only
# code invisible to a macOS scan) are suppressed by the committed
# baseline. --strict fails the step when NEW dead code appears.
run: periphery scan --strict --disable-update-check
+89 -23
View File
@@ -94,6 +94,24 @@ jobs:
kill "$watchdog_pid" 2>/dev/null || true kill "$watchdog_pid" 2>/dev/null || true
exit "$status" exit "$status"
# Read coverage before the serial benchmark command below rebuilds the
# test binary without instrumentation. Reporting against that newer
# binary makes llvm-cov reject the profile as out of date.
# Informational only: there is deliberately no percentage threshold, but
# a broken/missing report is a CI configuration error and must be visible.
- name: Coverage summary
run: |
BIN_PATH=$(swift build --show-bin-path --package-path ${{ matrix.path }})
PROF="$BIN_PATH/codecov/default.profdata"
XCTEST=$(find "$BIN_PATH" -maxdepth 1 -name '*.xctest' | head -1)
BINARY="$XCTEST/Contents/MacOS/$(basename "$XCTEST" .xctest)"
if [ ! -f "$PROF" ] || [ ! -f "$BINARY" ]; then
echo "::error::Coverage profile or test binary is missing"
exit 1
fi
xcrun llvm-cov report "$BINARY" -instr-profile "$PROF" \
-ignore-filename-regex='(Tests|\.build|checkouts|Mocks|_PreviewHelpers)'
# Benchmarks run serially on an otherwise idle runner for stable # Benchmarks run serially on an otherwise idle runner for stable
# numbers; BITCHAT_PERF_LOG captures the PERF[...] lines for the gate. # numbers; BITCHAT_PERF_LOG captures the PERF[...] lines for the gate.
- name: Run performance benchmarks (serial) - name: Run performance benchmarks (serial)
@@ -115,26 +133,10 @@ jobs:
timeout-minutes: 10 timeout-minutes: 10
run: ./scripts/check-perf-floors.sh perf-output.log run: ./scripts/check-perf-floors.sh perf-output.log
# Informational only: surfaces per-file and total line coverage in the # SPM tests do not link the shipping app targets. This job covers the
# job log so coverage trends are visible on every PR. No thresholds — # iOS-conditional paths and both universal Release link configurations.
# this must never be the reason a build goes red.
- name: Coverage summary
run: |
BIN_PATH=$(swift build --show-bin-path --package-path ${{ matrix.path }})
PROF="$BIN_PATH/codecov/default.profdata"
XCTEST=$(find "$BIN_PATH" -maxdepth 1 -name '*.xctest' | head -1)
BINARY="$XCTEST/Contents/MacOS/$(basename "$XCTEST" .xctest)"
if [ -f "$PROF" ] && [ -f "$BINARY" ]; then
xcrun llvm-cov report "$BINARY" -instr-profile "$PROF" \
-ignore-filename-regex='(Tests|\.build|checkouts|Mocks|_PreviewHelpers)' || true
else
echo "No coverage data found; skipping summary."
fi
# SPM tests above only compile the macOS slice; this job covers the
# iOS-conditional code paths (UIKit, CoreBluetooth restoration, etc.).
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
@@ -142,17 +144,81 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v5
- name: Check clean recipe safety
run: bash scripts/check-just-clean-safety.sh
- 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: |
set -o pipefail
xcodebuild -project bitchat.xcodeproj \
-scheme "bitchat (iOS)" \
-configuration Release \
-sdk iphonesimulator \
-destination 'generic/platform=iOS Simulator' \
ARCHS='arm64 x86_64' \
ONLY_ACTIVE_ARCH=NO \
CODE_SIGNING_ALLOWED=NO \
build
- name: Build macOS (universal, no signing)
run: |
set -o pipefail
xcodebuild -project bitchat.xcodeproj \
-scheme "bitchat (macOS)" \
-configuration Release \
-destination 'generic/platform=macOS' \
ARCHS='arm64 x86_64' \
ONLY_ACTIVE_ARCH=NO \
CODE_SIGNING_ALLOWED=NO \
build
# The SwiftPM matrix runs on macOS and cannot execute UIKit/CoreBluetooth
# conditional tests. Build the shared iOS test target and run it on the first
# available iPhone simulator from the runner image instead of hard-coding a
# model that changes when GitHub updates Xcode. The suite intentionally runs
# in one test runner: a number of integration tests exercise process-global
# stores and notification centers, so overlapping workers can corrupt each
# other's fixtures and turn sub-second tests into multi-minute timeouts.
ios-tests:
name: Run iOS simulator tests
runs-on: macos-latest
timeout-minutes: 20
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Select available iPhone simulator
id: destination
run: |
destinations=$(xcodebuild -project bitchat.xcodeproj -scheme "bitchat (iOS)" -showdestinations)
destination_id=$(awk -F'id:' '
/platform:iOS Simulator/ && /name:iPhone/ && !found {
value=$2
sub(/,.*/, "", value)
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
print value
found=1
}
' <<< "$destinations")
if [ -z "$destination_id" ]; then
echo "::error::No available iPhone simulator destination found"
exit 1
fi
echo "id=$destination_id" >> "$GITHUB_OUTPUT"
- name: Run iOS tests
run: | run: |
set -o pipefail set -o pipefail
xcodebuild -project bitchat.xcodeproj \ xcodebuild -project bitchat.xcodeproj \
-scheme "bitchat (iOS)" \ -scheme "bitchat (iOS)" \
-sdk iphonesimulator \ -sdk iphonesimulator \
-destination 'generic/platform=iOS Simulator' \ -destination "platform=iOS Simulator,id=${{ steps.destination.outputs.id }}" \
ARCHS=arm64 \ -parallel-testing-enabled NO \
CODE_SIGNING_ALLOWED=NO \ CODE_SIGNING_ALLOWED=NO \
build test
# Advisory only: SwiftLint reports style violations without ever failing the # Advisory only: SwiftLint reports style violations without ever failing the
# build. Runs in a pinned container (no Xcode plugin, no pbxproj changes) so # build. Runs in a pinned container (no Xcode plugin, no pbxproj changes) so
+1
View File
@@ -80,3 +80,4 @@ build.log
# Local configs # Local configs
Local.xcconfig Local.xcconfig
*.profraw
+1
View File
@@ -0,0 +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:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}}
+21
View File
@@ -0,0 +1,21 @@
# Periphery dead-code scan configuration (https://github.com/peripheryapp/periphery)
#
# CI runs the macOS scheme only (an iOS scan needs a device destination and
# doubles the build time). macOS-only scans falsely flag iOS-only code —
# state restoration, screenshot handlers, background BLE sampling — so those
# findings live in .periphery.baseline.json rather than being "fixed".
# When auditing by hand, scan BOTH schemes and intersect:
# periphery scan --schemes "bitchat (iOS)" -- -destination 'generic/platform=iOS' ARCHS=arm64
project: bitchat.xcodeproj
schemes:
- bitchat (macOS)
retain_swift_ui_previews: true
# Codable properties are (de)serialized via synthesized conformances the
# indexer doesn't always attribute reads to: PrekeyBundleStore.StoredBundle
# .noiseKey flaked CI as "assign-only" even while read in loadFromDisk —
# and slipped past its baselined USR. Retaining Codable properties outright
# is deterministic; a truly-dead Codable field is a persisted-format change
# anyway, never a safe mechanical delete.
retain_codable_properties: true
relative_results: true
baseline: .periphery.baseline.json
+1
View File
@@ -2,6 +2,7 @@
# (CI checkouts are fresh, so this only matters in a working tree). # (CI checkouts are fresh, so this only matters in a working tree).
excluded: excluded:
- .build - .build
- .claude
- .swiftpm - .swiftpm
- .DerivedData - .DerivedData
- DerivedData - DerivedData
+3
View File
@@ -3,3 +3,6 @@ DEVELOPMENT_TEAM = ABC123
// Unique bundle id to be able to register and run locally // Unique bundle id to be able to register and run locally
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.$(DEVELOPMENT_TEAM) PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.$(DEVELOPMENT_TEAM)
// App and share extension must use an App Group registered to your team.
APP_GROUP_ID = group.chat.bitchat.$(DEVELOPMENT_TEAM)
+1 -1
View File
@@ -1,4 +1,4 @@
MARKETING_VERSION = 1.6.0 MARKETING_VERSION = 1.7.1
CURRENT_PROJECT_VERSION = 1 CURRENT_PROJECT_VERSION = 1
IPHONEOS_DEPLOYMENT_TARGET = 16.0 IPHONEOS_DEPLOYMENT_TARGET = 16.0
+54 -95
View File
@@ -1,107 +1,66 @@
# BitChat macOS Build Justfile # BitChat developer commands
# Handles temporary modifications needed to build and run on macOS #
# Builds use a repository-local, ignored DerivedData directory. No recipe
# patches, restores, or removes tracked project/configuration files.
project := "bitchat.xcodeproj"
macos_scheme := "bitchat (macOS)"
ios_scheme := "bitchat (iOS)"
derived_data := ".DerivedData"
# Default recipe - shows available commands
default: default:
@echo "BitChat macOS Build Commands:" @echo "BitChat developer commands:"
@echo " just run - Build and run the macOS app" @echo " just run Build and run the macOS app"
@echo " just build - Build the macOS app only" @echo " just build Build the macOS app without signing"
@echo " just clean - Clean build artifacts and restore original files" @echo " just test Run the SwiftPM test suite"
@echo " just check - Check prerequisites" @echo " just test-ios Run tests on the iPhone 17 simulator"
@echo "" @echo " just clean Remove repo-local build artifacts only"
@echo "Original files are preserved - modifications are temporary for builds only" @echo " just nuke Also remove nested package build caches"
@echo " just check Validate the development environment"
# Check prerequisites # Static guard against reintroducing source-restoring or source-deleting clean
check: # behavior. CI runs the same script directly.
check-clean-safety:
@bash scripts/check-just-clean-safety.sh
check: check-clean-safety
@echo "Checking prerequisites..." @echo "Checking prerequisites..."
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install Xcode from App Store" && exit 1) @command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install full Xcode." && exit 1)
@xcode-select -p | grep -q "Xcode.app" || (echo "❌ Full Xcode required, not just command line tools. Install from App Store and run:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1) @developer_dir="$$(xcode-select -p 2>/dev/null)"; case "$$developer_dir" in *.app/Contents/Developer) ;; *) echo "❌ Full Xcode is not selected. Run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer"; exit 1;; esac
@test -d "/Applications/Xcode.app" || (echo "❌ Xcode.app not found in Applications folder. Install from App Store" && exit 1) @xcodebuild -version
@xcodebuild -version >/dev/null 2>&1 || (echo "❌ Xcode not properly configured. Try:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1) @echo "✅ Development environment ready (a signing identity is not required for just build)"
@security find-identity -v -p codesigning | grep -q "Apple Development\|Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
@echo "✅ All prerequisites met"
# Backup original files build: check
backup:
@echo "Backing up original project configuration..."
@if [ -f bitchat.xcodeproj/project.pbxproj ]; then cp bitchat.xcodeproj/project.pbxproj bitchat.xcodeproj/project.pbxproj.backup; fi
@if [ -f bitchat/Info.plist ]; then cp bitchat/Info.plist bitchat/Info.plist.backup; fi
# Restore original files
restore:
@echo "Restoring original project configuration..."
@if [ -f project.yml.backup ]; then mv project.yml.backup project.yml; fi
@# Restore iOS-specific files
@if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi
@# Use git to restore all modified files except Justfile
@git checkout -- project.yml bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Could not restore some files with git"
@# Remove any backup files
@rm -f bitchat.xcodeproj/project.pbxproj.backup bitchat/Info.plist.backup 2>/dev/null || true
# Apply macOS-specific modifications
patch-for-macos: backup
@echo "Temporarily hiding iOS-specific files for macOS build..."
@# Move iOS-specific files out of the way temporarily
@if [ -f bitchat/LaunchScreen.storyboard ]; then mv bitchat/LaunchScreen.storyboard bitchat/LaunchScreen.storyboard.ios; fi
# Build the macOS app
build: #check generate
@echo "Building BitChat for macOS..." @echo "Building BitChat for macOS..."
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build @xcodebuild -project "{{project}}" -scheme "{{macos_scheme}}" -configuration Debug -derivedDataPath "{{derived_data}}" CODE_SIGNING_ALLOWED=NO build
# Run the macOS app
run: build run: build
@echo "Launching BitChat..." @app="{{derived_data}}/Build/Products/Debug/bitchat.app"; test -d "$$app" || (echo "❌ Built app not found at $$app" && exit 1); open "$$app"
@find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}"
# Clean build artifacts and restore original files # Backward-compatible alias for the old quick-run recipe.
clean: restore dev-run: run
@echo "Cleaning build artifacts..."
@rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true
@# Only remove the generated project if we have a backup, otherwise use git
@if [ -f bitchat.xcodeproj/project.pbxproj.backup ]; then \
rm -rf bitchat.xcodeproj; \
else \
git checkout -- bitchat.xcodeproj/project.pbxproj 2>/dev/null || echo "⚠️ Could not restore project.pbxproj"; \
fi
@rm -f project-macos.yml 2>/dev/null || true
@echo "✅ Cleaned and restored original files"
# Quick run without cleaning (for development) test:
dev-run: check @swift test
@echo "Quick development build..."
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build test-ios: check
@find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}" @xcodebuild -project "{{project}}" -scheme "{{ios_scheme}}" -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' -derivedDataPath "{{derived_data}}" test
# Artifact-only cleanup. In particular, this recipe never invokes Git and
# never writes, moves, restores, or removes source/configuration files.
clean:
@echo "Cleaning repo-local build artifacts..."
@rm -rf -- "{{derived_data}}" ".build"
@echo "✅ Cleaned {{derived_data}} and .build; tracked files were untouched"
# Retain the familiar command, but keep it artifact-only as well.
nuke: clean
@echo "Cleaning nested package build caches..."
@find localPackages -type d -name .build -prune -exec rm -rf -- {} +
@rm -rf -- ".cache"
@echo "✅ Removed repository build caches; tracked files were untouched"
# Show app info
info: info:
@echo "BitChat - Decentralized Mesh Messaging" @echo "BitChat - decentralized mesh messaging"
@echo "======================================" @echo "macOS 13+ and iOS 16+"
@echo "• Native macOS SwiftUI app" @echo "Bluetooth mesh behavior requires physical Bluetooth-capable devices"
@echo "• Bluetooth LE mesh networking"
@echo "• End-to-end encryption"
@echo "• No internet required"
@echo "• Works offline with nearby devices"
@echo ""
@echo "Requirements:"
@echo "• macOS 13.0+ (Ventura)"
@echo "• Bluetooth LE capable Mac"
@echo "• Physical device (no simulator support)"
@echo ""
@echo "Usage:"
@echo "• Set nickname and start chatting"
@echo "• Use /join #channel for group chats"
@echo "• Use /msg @user for private messages"
@echo "• Triple-tap logo for emergency wipe"
# Force clean everything (nuclear option)
nuke:
@echo "🧨 Nuclear clean - removing all build artifacts and backups..."
@rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true
@rm -rf bitchat.xcodeproj 2>/dev/null || true
@rm -f bitchat.xcodeproj/project.pbxproj.backup 2>/dev/null || true
@rm -f bitchat/Info.plist.backup 2>/dev/null || true
@# Restore iOS-specific files if they were moved
@if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi
@git checkout bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Not a git repo or no changes to restore"
@echo "✅ Nuclear clean complete"
+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 as device-only items. Public keys are shared when required for messaging, verification, groups, or Nostr events.
- Keys remain until they are rotated, removed by the relevant feature, or erased with panic wipe. Because operating-system keychains can outlive an uninstall, bitchat records a non-secret install marker and deletes surviving app keys before use after a later reinstall.
1. **Identity Keys** 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 can retain one item you choose to share in the app-group preferences for up to 24 hours. The app shows the destination and a preview for review; it does not send the item automatically. The item is cleared when you add it to the composer, cancel, panic-wipe, or it expires.
- 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 BitChat's app-specific encrypted envelopes. This format is not NIP-17, NIP-44, or NIP-59 compatible. Relays can observe the recipient public-key tag, event timing and size, and network metadata, but not the message plaintext or stable sender identity.
- **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. BitChat private envelopes use secp256k1 key agreement, HKDF-SHA256, and XChaCha20-Poly1305. The envelope format is proprietary, only interoperates with BitChat clients, and does not provide forward secrecy against later compromise of the recipient's static Nostr private key.
- The persistent private-message outbox uses ChaCha20-Poly1305 with a key held in the keychain. Some other protected local identity state uses AES-GCM.
- Public mesh, bridge, geohash, and board content is signed or authenticated as appropriate but is intentionally not confidential.
No cryptographic system can protect content after a recipient reads, copies, screenshots, or exports it.
## Data Retention Summary
- **In-memory chat timelines and active connections:** until the app closes or state is cleared.
- **Queued outgoing private messages:** until acknowledged, dropped by bounded policy, or 24 hours, whichever comes first.
- **Opaque courier envelopes:** until handed off, evicted by bounded policy, or 24 hours, whichever comes first.
- **Recent public mesh gossip:** up to 15 minutes.
- **Public board posts and tombstones:** until expiry, at most seven days.
- **Groups, favorites, preferences, identity keys, bookmarks, and media:** until removed by the feature, panic wipe, quota eviction where applicable, or app removal.
- **Nostr data:** according to the policies of the relays that receive it.
## Your Controls
- **Panic wipe:** Triple-tap the logo to synchronously cancel in-flight media work and clear local keys, sessions, preferences, groups, queues, carried mail, public archives, board data, and media managed by the app.
- **Feature controls:** Location channels, mesh bridge, internet gateway, and related internet behaviors can be disabled in the app. Some already-published relay data cannot be recalled.
- **System permissions:** Bluetooth, location, microphone, camera, and photo-library access can be revoked in system settings.
- **No account:** The project operates no account record for you to request or export.
## What the Project Does Not Do
bitchat does not:
- Operate an account database or project-owned messaging backend.
- Include advertising, analytics, or tracking SDKs.
- Sell user data or create advertising profiles.
- Include exact GPS coordinates in bitchat mesh or Nostr message payloads.
## Children's Privacy ## 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.*
+5 -1
View File
@@ -63,7 +63,11 @@ let package = Package(
// Only the vector fixture: declaring the whole "Noise" // Only the vector fixture: declaring the whole "Noise"
// directory would claim its .swift test files as resources // directory would claim its .swift test files as resources
// and silently drop them from compilation. // and silently drop them from compilation.
.process("Noise/NoiseTestVectors.json") .process("Noise/NoiseTestVectors.json"),
// Frozen envelopes produced by the released iOS (733098bb)
// and Android (b7f0b33d) private-DM implementations; prove
// receive compatibility independently of the local generator.
.process("Nostr/Fixtures")
] ]
) )
] ]
+52 -14
View File
@@ -19,7 +19,7 @@ This project is released into the public domain. See the [LICENSE](LICENSE) file
- **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback) - **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE - **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers - **Privacy First**: No accounts, no phone numbers, no persistent identifiers
- **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, NIP-17 for Nostr - **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, BitChat private envelopes for Nostr fallback
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface - **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
- **Universal App**: Native support for iOS and macOS - **Universal App**: Native support for iOS and macOS
- **Emergency Wipe**: Triple-tap to instantly clear all data - **Emergency Wipe**: Triple-tap to instantly clear all data
@@ -44,9 +44,15 @@ BitChat uses a **hybrid messaging architecture** with two complementary transpor
- **Global Reach**: Connect with users worldwide via internet relays - **Global Reach**: Connect with users worldwide via internet relays
- **Location Channels**: Geographic chat rooms using geohash coordinates - **Location Channels**: Geographic chat rooms using geohash coordinates
- **290+ Relay Network**: Distributed across the globe for reliability - **290+ Relay Network**: Distributed across the globe for reliability
- **NIP-17 Encryption**: Gift-wrapped private messages for internet privacy - **BitChat Private Envelopes**: App-specific encrypted private messages over Nostr relays
- **Ephemeral Keys**: Fresh cryptographic identity per geohash area - **Ephemeral Keys**: Fresh cryptographic identity per geohash area
BitChat's private-envelope format is proprietary and is **not** NIP-17,
NIP-44, or NIP-59 compatible. It uses Nostr as a relay transport but only
interoperates with BitChat clients: private payloads travel inside kind-1059
events whose `v2:`-prefixed content is a BitChat-specific XChaCha20-Poly1305
construction, not NIP-44 encryption.
### Channel Types ### Channel Types
#### `mesh #bluetooth` #### `mesh #bluetooth`
@@ -80,7 +86,7 @@ Private messages use **intelligent transport selection**:
2. **Nostr Fallback** (when Bluetooth unavailable) 2. **Nostr Fallback** (when Bluetooth unavailable)
- Uses recipient's Nostr public key - Uses recipient's Nostr public key
- NIP-17 gift-wrapping for privacy - BitChat's app-specific private-envelope encryption
- Routes through global relay network - Routes through global relay network
3. **Smart Queuing** (when neither available) 3. **Smart Queuing** (when neither available)
@@ -94,29 +100,61 @@ For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.m
### Option 1: Using Xcode ### Option 1: Using Xcode
```bash ```bash
cd bitchat
open bitchat.xcodeproj open bitchat.xcodeproj
``` ```
To run on a device there're a few steps to prepare the code: For a signed device build, create your ignored local configuration and replace
- Clone the local configs: `cp Configs/Local.xcconfig.example Configs/Local.xcconfig` the example team ID with your Apple Developer Team ID:
- Add your Developer Team ID into the newly created `Configs/Local.xcconfig`
- Bundle ID would be set to `chat.bitchat.<team_id>` (unless you set to something else) ```bash
- Entitlements need to be updated manually (TODO: Automate): cp Configs/Local.xcconfig.example Configs/Local.xcconfig
- Search and replace `group.chat.bitchat` with `group.<your_bundle_id>` (e.g. `group.chat.bitchat.ABC123`) ```
`Local.xcconfig.example` derives unique app and App Group identifiers from that
team ID. The entitlement files already reference `$(APP_GROUP_ID)`, so tracked
project or entitlement files do not need to be edited.
Useful command-line checks from the repository root:
```bash
# macOS Debug build without signing
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" \
-configuration Debug CODE_SIGNING_ALLOWED=NO build
# Full SwiftPM test suite
swift test
# iOS simulator tests
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (iOS)" \
-sdk iphonesimulator \
-destination 'platform=iOS Simulator,name=iPhone 17' test
```
If `iPhone 17` is unavailable, choose an installed simulator from:
```bash
xcodebuild -showdestinations -project bitchat.xcodeproj -scheme "bitchat (iOS)"
```
### Option 2: Using `just` ### Option 2: Using `just`
```bash ```bash
brew install just brew install just
just check
just run
``` ```
Want to try this on macos: `just run` will set it up and run from source. `just build` and `just run` use the current `bitchat (macOS)` scheme and keep
Run `just clean` afterwards to restore things to original state for mobile app building and development. Xcode output in the ignored `.DerivedData/` directory. They never patch source,
project, configuration, or entitlement files.
`just clean` removes only `.DerivedData/` and `.build/`. It does not invoke Git
or restore tracked files, so uncommitted work is preserved. `just test` runs the
SwiftPM suite and `just test-ios` runs the iPhone 17 simulator suite.
## Localization ## Localization
- Base app resources live under `bitchat/Localization/Base.lproj/`. Add new copy to `Localizable.strings` and plural rules to `Localizable.stringsdict`. - App localizations live in `bitchat/Localizable.xcstrings`.
- Share extension strings are separate in `bitchatShareExtension/Localization/Base.lproj/Localizable.strings`. - Share extension strings are separate in `bitchatShareExtension/Localization/Localizable.xcstrings`.
- Prefer keys that describe intent (`app_info.features.offline.title`) and reuse existing ones where possible. - Prefer keys that describe intent (`app_info.features.offline.title`) and reuse existing ones where possible.
- Run `xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGNING_ALLOWED=NO build` to compile-check any localization updates. - Run `xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGNING_ALLOWED=NO build` to compile-check any localization updates.
+6 -4
View File
@@ -25,7 +25,7 @@ bitchat is a decentralized, peer-to-peer messaging application for secure, priva
Two transports implement a common `Transport` interface and are coordinated by a `MessageRouter`: Two transports implement a common `Transport` interface and are coordinated by a `MessageRouter`:
* **BLE mesh** — every device is simultaneously a GATT central and peripheral, relaying packets in a controlled flood. No infrastructure, pairing, or accounts. * **BLE mesh** — every device is simultaneously a GATT central and peripheral, relaying packets in a controlled flood. No infrastructure, pairing, or accounts.
* **Nostr** — private messages to mutual favorites travel as NIP-17 gift-wrapped events over public relays (over Tor where enabled), bridging separate meshes through the internet. * **Nostr** — private messages to mutual favorites travel in BitChat's app-specific encrypted envelopes over public relays (over Tor where enabled), bridging separate meshes through the internet.
The router prefers a live mesh link, falls back to Nostr, and engages the courier system when neither can deliver promptly. The router prefers a live mesh link, falls back to Nostr, and engages the courier system when neither can deliver promptly.
@@ -78,7 +78,9 @@ Courier envelopes are sealed to the recipient's *static* key with the one-way No
### 5.3 Nostr Path ### 5.3 Nostr Path
Private messages to mutual favorites are wrapped per NIP-17/NIP-59: a rumor (kind 14) sealed (kind 13) and gift-wrapped (kind 1059) under a throwaway ephemeral key, so relays learn neither sender nor content. Private messages to mutual favorites use BitChat's proprietary private-envelope protocol. An unsigned inner message (kind 14) is encrypted and placed in a sender-signed seal (kind 13); that seal is encrypted again inside a public envelope (kind 1059) signed by a one-time key, so relays learn neither the stable sender identity nor the content. Each encrypted content field is `v2:` followed by base64url of a 24-byte nonce, XChaCha20-Poly1305 ciphertext, and its 16-byte tag. Keys come from secp256k1 ECDH and HKDF-SHA256 (the derivation reuses a "nip44-v2" info label but is not the NIP-44 key schedule).
This format reuses NIP-17/NIP-59 kind numbers but is **not NIP-17, NIP-44, or NIP-59 compatible** and interoperates only with BitChat clients. The outer `p` tag exposes the recipient's Nostr public key to relays; the plaintext and stable sender identity remain inside authenticated ciphertext. Public seal and envelope timestamps are randomized by up to ±15 minutes, while the actual message timestamp is encrypted. The protocol does not provide forward secrecy: compromise of the recipient's static Nostr private key can expose stored envelopes addressed to that key.
## 6. Store and Forward ## 6. Store and Forward
@@ -105,7 +107,7 @@ Public broadcast messages are cached (1000 packets) and reconciled between peers
### 6.4 Nostr Mailboxes ### 6.4 Nostr Mailboxes
Gift-wrapped messages rest on Nostr relays; clients re-subscribe with a 24-hour lookback on reconnect, covering the both-devices-offline case for mutual favorites whenever either side touches the internet. BitChat private envelopes rest on Nostr relays; clients re-subscribe with a 24-hour lookback on reconnect, covering the both-devices-offline case for mutual favorites whenever either side touches the internet.
### 6.5 Delivery Metrics ### 6.5 Delivery Metrics
@@ -127,7 +129,7 @@ Bare local counters (deposits, handovers, sprays, opens, outbox flushes and drop
* **Flooding abuse** is bounded by TTL clamps, deduplication, per-depositor quotas, connect-rate limits, and announce-rate limiting. * **Flooding abuse** is bounded by TTL clamps, deduplication, per-depositor quotas, connect-rate limits, and announce-rate limiting.
* **Replay** of public broadcasts is bounded by the 6-hour acceptance window plus deduplication; private payloads are protected by Noise nonces. * **Replay** of public broadcasts is bounded by the 6-hour acceptance window plus deduplication; private payloads are protected by Noise nonces.
* **Metadata.** BLE proximity is inherently observable; ephemeral IDs and daily-rotating courier tags limit long-term correlation. Nostr traffic can ride Tor. * **Metadata.** BLE proximity is inherently observable; ephemeral IDs and daily-rotating courier tags limit long-term correlation. Nostr traffic can ride Tor.
* **No forward secrecy for sealed mail** (§5.2) is the main cryptographic trade-off of the offline path. * **No forward secrecy for sealed mail or Nostr private envelopes** (§5.25.3) means compromise of a recipient's static key can expose retained ciphertext addressed to that key.
## 9. Future Work ## 9. Future Work
+15 -1
View File
@@ -70,6 +70,7 @@
A6E32D1B2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchatShareExtension" target */ = { A6E32D1B2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchatShareExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet; isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = ( membershipExceptions = (
Services/SharedContentHandoff.swift,
Services/TransportConfig.swift, Services/TransportConfig.swift,
); );
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */; target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
@@ -92,7 +93,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 +260,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 */;
@@ -332,6 +338,7 @@
es, es,
ar, ar,
de, de,
fa,
fr, fr,
he, he,
id, id,
@@ -388,6 +395,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 */
+1 -28
View File
@@ -2,11 +2,6 @@ import BitFoundation
import Combine import Combine
import Foundation import Foundation
enum SharedContentKind: String, Sendable, Equatable {
case text
case url
}
enum RuntimeScenePhase: String, Sendable, Equatable { enum RuntimeScenePhase: String, Sendable, Equatable {
case active case active
case inactive case inactive
@@ -25,7 +20,7 @@ enum AppEvent: Sendable, Equatable {
case startupCompleted case startupCompleted
case scenePhaseChanged(RuntimeScenePhase) case scenePhaseChanged(RuntimeScenePhase)
case openedURL(String) case openedURL(String)
case sharedContentAccepted(SharedContentKind) case sharedContentReadyForReview(SharedContentKind)
case notificationOpened(peerID: PeerID?) case notificationOpened(peerID: PeerID?)
case deepLinkOpened(String) case deepLinkOpened(String)
case torLifecycleChanged(TorLifecycleEvent) case torLifecycleChanged(TorLifecycleEvent)
@@ -36,34 +31,12 @@ enum AppEvent: Sendable, Equatable {
actor AppEventStream { actor AppEventStream {
private var continuations: [UUID: AsyncStream<AppEvent>.Continuation] = [:] private var continuations: [UUID: AsyncStream<AppEvent>.Continuation] = [:]
func stream() -> AsyncStream<AppEvent> {
let id = UUID()
return AsyncStream { continuation in
continuations[id] = continuation
continuation.onTermination = { [id] _ in
Task {
await self.removeContinuation(id)
}
}
}
}
func emit(_ event: AppEvent) { func emit(_ event: AppEvent) {
for continuation in continuations.values { for continuation in continuations.values {
continuation.yield(event) continuation.yield(event)
} }
} }
func finish() {
for continuation in continuations.values {
continuation.finish()
}
continuations.removeAll()
}
private func removeContinuation(_ id: UUID) {
continuations.removeValue(forKey: id)
}
} }
/// Identity key for a direct conversation. Equality and hashing use the /// Identity key for a direct conversation. Equality and hashing use the
+25 -1
View File
@@ -10,19 +10,32 @@ final class AppChromeModel: ObservableObject {
@Published var showingFingerprintFor: PeerID? @Published var showingFingerprintFor: PeerID?
@Published var isAppInfoPresented = false @Published var isAppInfoPresented = false
@Published var isLocationChannelsSheetPresented = false @Published var isLocationChannelsSheetPresented = false
@Published var isNoticesSheetPresented = false
/// When the sheet is opened for "notes left here" (empty mesh timeline),
/// it should land on the geo tab instead of the channel-derived default.
@Published var noticesSheetPrefersGeoTab = false
@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 var showScreenshotPrivacyWarning = false @Published var showScreenshotPrivacyWarning = false
private let chatViewModel: ChatViewModel private let chatViewModel: ChatViewModel
private let onPanicWipe: () -> Void
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
/// The composer owns capture state above ChatViewModel. ContentView
/// installs this hook so both panic entry points synchronously stop it.
private var prepareForPanic: (@MainActor () -> Void)?
/// Bulletin-board coordinator, created on first use of the board sheet. /// Bulletin-board coordinator, created on first use of the board sheet.
private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService) private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService)
init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) { init(
chatViewModel: ChatViewModel,
privateInboxModel: PrivateInboxModel,
onPanicWipe: @escaping () -> Void = {}
) {
self.chatViewModel = chatViewModel self.chatViewModel = chatViewModel
self.onPanicWipe = onPanicWipe
self.nickname = chatViewModel.nickname self.nickname = chatViewModel.nickname
bind(privateInboxModel: privateInboxModel) bind(privateInboxModel: privateInboxModel)
@@ -62,6 +75,11 @@ final class AppChromeModel: ObservableObject {
isAppInfoPresented = true isAppInfoPresented = true
} }
func presentNotices(geoTab: Bool = false) {
noticesSheetPrefersGeoTab = geoTab
isNoticesSheetPresented = true
}
/// Builds the mesh topology map model from the transport's gossiped /// Builds the mesh topology map model from the transport's gossiped
/// graph plus the live nickname table. Unknown nodes (heard about via a /// graph plus the live nickname table. Unknown nodes (heard about via a
/// neighbor claim but never announced to us) fall back to a short ID. /// neighbor claim but never announced to us) fall back to a short ID.
@@ -88,7 +106,13 @@ final class AppChromeModel: ObservableObject {
showScreenshotPrivacyWarning = true showScreenshotPrivacyWarning = true
} }
func setPanicPreparation(_ preparation: (@MainActor () -> Void)?) {
prepareForPanic = preparation
}
func panicClearAllData() { func panicClearAllData() {
prepareForPanic?()
onPanicWipe()
chatViewModel.panicClearAllData() chatViewModel.panicClearAllData()
} }
+58 -38
View File
@@ -18,8 +18,6 @@ final class AppRuntime: ObservableObject {
/// (docs/CONVERSATION-STORE-DESIGN.md). Owned here; the feature models /// (docs/CONVERSATION-STORE-DESIGN.md). Owned here; the feature models
/// and `ChatViewModel` observe and mutate it through its intent API. /// and `ChatViewModel` observe and mutate it through its intent API.
let conversations: ConversationStore let conversations: ConversationStore
let peerIdentityStore: PeerIdentityStore
let locationPresenceStore: LocationPresenceStore
let publicChatModel: PublicChatModel let publicChatModel: PublicChatModel
let privateInboxModel: PrivateInboxModel let privateInboxModel: PrivateInboxModel
let privateConversationModel: PrivateConversationModel let privateConversationModel: PrivateConversationModel
@@ -29,6 +27,7 @@ final class AppRuntime: ObservableObject {
let peerListModel: PeerListModel let peerListModel: PeerListModel
let appChromeModel: AppChromeModel let appChromeModel: AppChromeModel
let boardAlertsModel: BoardAlertsModel let boardAlertsModel: BoardAlertsModel
let sharedContentImportModel: SharedContentImportModel
private let idBridge: NostrIdentityBridge private let idBridge: NostrIdentityBridge
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
@@ -42,8 +41,9 @@ final class AppRuntime: ObservableObject {
#endif #endif
init( init(
keychain: KeychainManagerProtocol = KeychainManager(), keychain: KeychainManagerProtocol = KeychainManager.makeDefault(),
idBridge: NostrIdentityBridge = NostrIdentityBridge() idBridge: NostrIdentityBridge = NostrIdentityBridge(),
sharedContentStore: SharedContentStore? = nil
) { ) {
self.idBridge = idBridge self.idBridge = idBridge
let conversations = ConversationStore() let conversations = ConversationStore()
@@ -51,8 +51,6 @@ final class AppRuntime: ObservableObject {
let locationPresenceStore = LocationPresenceStore() let locationPresenceStore = LocationPresenceStore()
let locationManager = LocationChannelManager.shared let locationManager = LocationChannelManager.shared
self.conversations = conversations self.conversations = conversations
self.peerIdentityStore = peerIdentityStore
self.locationPresenceStore = locationPresenceStore
self.chatViewModel = ChatViewModel( self.chatViewModel = ChatViewModel(
keychain: keychain, keychain: keychain,
idBridge: idBridge, idBridge: idBridge,
@@ -88,9 +86,20 @@ final class AppRuntime: ObservableObject {
peerIdentityStore: peerIdentityStore, peerIdentityStore: peerIdentityStore,
locationPresenceStore: locationPresenceStore locationPresenceStore: locationPresenceStore
) )
let resolvedSharedContentStore: SharedContentStore?
if let sharedContentStore {
resolvedSharedContentStore = sharedContentStore
} else if let sharedDefaults = UserDefaults(suiteName: BitchatApp.groupID) {
resolvedSharedContentStore = SharedContentStore(defaults: sharedDefaults)
} else {
resolvedSharedContentStore = nil
}
let sharedContentImportModel = SharedContentImportModel(store: resolvedSharedContentStore)
self.sharedContentImportModel = sharedContentImportModel
self.appChromeModel = AppChromeModel( self.appChromeModel = AppChromeModel(
chatViewModel: self.chatViewModel, chatViewModel: self.chatViewModel,
privateInboxModel: self.privateInboxModel privateInboxModel: self.privateInboxModel,
onPanicWipe: { sharedContentImportModel.discardAll() }
) )
let chatViewModel = self.chatViewModel let chatViewModel = self.chatViewModel
self.boardAlertsModel = BoardAlertsModel( self.boardAlertsModel = BoardAlertsModel(
@@ -110,13 +119,15 @@ final class AppRuntime: ObservableObject {
} }
) )
) )
if chatViewModel.networkActivationAllowed {
GeoRelayDirectory.shared.prefetchIfNeeded() GeoRelayDirectory.shared.prefetchIfNeeded()
}
bindRuntimeObservers() bindRuntimeObservers()
NotificationDelegate.shared.runtime = self NotificationDelegate.shared.runtime = self
} }
func start() { func start() {
guard chatViewModel.networkActivationAllowed else { return }
guard !started else { guard !started else {
checkForSharedContent() checkForSharedContent()
return return
@@ -155,12 +166,14 @@ final class AppRuntime: ObservableObject {
} }
func handleDidBecomeActiveNotification() { func handleDidBecomeActiveNotification() {
guard chatViewModel.networkActivationAllowed else { return }
chatViewModel.handleDidBecomeActive() chatViewModel.handleDidBecomeActive()
checkForSharedContent() checkForSharedContent()
} }
#if os(macOS) #if os(macOS)
func handleMacDidBecomeActiveNotification() { func handleMacDidBecomeActiveNotification() {
guard chatViewModel.networkActivationAllowed else { return }
record(.scenePhaseChanged(.active)) record(.scenePhaseChanged(.active))
chatViewModel.handleDidBecomeActive() chatViewModel.handleDidBecomeActive()
checkForSharedContent() checkForSharedContent()
@@ -179,6 +192,7 @@ final class AppRuntime: ObservableObject {
didEnterBackground = true didEnterBackground = true
case .active: case .active:
guard chatViewModel.networkActivationAllowed else { return }
record(.scenePhaseChanged(.active)) record(.scenePhaseChanged(.active))
chatViewModel.meshService.startServices() chatViewModel.meshService.startServices()
TorManager.shared.setAppForeground(true) TorManager.shared.setAppForeground(true)
@@ -221,7 +235,17 @@ final class AppRuntime: ObservableObject {
chatViewModel.applicationWillTerminate() chatViewModel.applicationWillTerminate()
} }
func handleNotificationResponse(identifier: String, userInfo: [AnyHashable: Any]) { func handleNotificationResponse(
identifier: String,
actionIdentifier: String = UNNotificationDefaultActionIdentifier,
userInfo: [AnyHashable: Any]
) {
guard chatViewModel.networkActivationAllowed else { return }
if actionIdentifier == NotificationService.waveActionID {
chatViewModel.sendMeshWave()
return
}
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) { if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
record(.notificationOpened(peerID: peerID)) record(.notificationOpened(peerID: peerID))
chatViewModel.startPrivateChat(with: peerID) chatViewModel.startPrivateChat(with: peerID)
@@ -268,6 +292,8 @@ private extension AppRuntime {
NotificationCenter.default.publisher(for: .TorWillRestart) NotificationCenter.default.publisher(for: .TorWillRestart)
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] _ in .sink { [weak self] _ in
guard self?.chatViewModel.networkActivationAllowed == true
else { return }
self?.record(.torLifecycleChanged(.willRestart)) self?.record(.torLifecycleChanged(.willRestart))
self?.chatViewModel.handleTorWillRestart() self?.chatViewModel.handleTorWillRestart()
} }
@@ -276,6 +302,8 @@ private extension AppRuntime {
NotificationCenter.default.publisher(for: .TorDidBecomeReady) NotificationCenter.default.publisher(for: .TorDidBecomeReady)
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] _ in .sink { [weak self] _ in
guard self?.chatViewModel.networkActivationAllowed == true
else { return }
self?.record(.torLifecycleChanged(.didBecomeReady)) self?.record(.torLifecycleChanged(.didBecomeReady))
self?.chatViewModel.handleTorDidBecomeReady() self?.chatViewModel.handleTorDidBecomeReady()
} }
@@ -284,6 +312,8 @@ private extension AppRuntime {
NotificationCenter.default.publisher(for: .TorWillStart) NotificationCenter.default.publisher(for: .TorWillStart)
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] _ in .sink { [weak self] _ in
guard self?.chatViewModel.networkActivationAllowed == true
else { return }
self?.record(.torLifecycleChanged(.willStart)) self?.record(.torLifecycleChanged(.willStart))
self?.chatViewModel.handleTorWillStart() self?.chatViewModel.handleTorWillStart()
} }
@@ -292,6 +322,8 @@ private extension AppRuntime {
NotificationCenter.default.publisher(for: .TorUserPreferenceChanged) NotificationCenter.default.publisher(for: .TorUserPreferenceChanged)
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] notification in .sink { [weak self] notification in
guard self?.chatViewModel.networkActivationAllowed == true
else { return }
self?.record(.torLifecycleChanged(.preferenceChanged)) self?.record(.torLifecycleChanged(.preferenceChanged))
self?.chatViewModel.handleTorPreferenceChanged(notification) self?.chatViewModel.handleTorPreferenceChanged(notification)
} }
@@ -308,36 +340,22 @@ private extension AppRuntime {
} }
func checkForSharedContent() { func checkForSharedContent() {
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID), let previousID = sharedContentImportModel.offer?.id
let sharedContent = userDefaults.string(forKey: "sharedContent"), guard let payload = sharedContentImportModel.refresh(
let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else { destination: currentSharedContentDestination
return ) else { return }
if previousID != payload.id {
record(.sharedContentReadyForReview(payload.kind))
}
} }
guard Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds else { var currentSharedContentDestination: SharedContentDestination {
return SharedContentDestination.resolve(
} selectedPrivatePeerID: privateConversationModel.selectedPeerID,
privateDisplayName: privateConversationModel.selectedHeaderState?.displayName,
let contentKind = SharedContentKind(rawValue: userDefaults.string(forKey: "sharedContentType") ?? "") ?? .text activeChannel: locationChannelsModel.selectedChannel
)
userDefaults.removeObject(forKey: "sharedContent")
userDefaults.removeObject(forKey: "sharedContentType")
userDefaults.removeObject(forKey: "sharedContentDate")
switch contentKind {
case .url:
if let data = sharedContent.data(using: .utf8),
let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String],
let url = urlData["url"] {
chatViewModel.sendMessage(url)
} else {
chatViewModel.sendMessage(sharedContent)
}
case .text:
chatViewModel.sendMessage(sharedContent)
}
record(.sharedContentAccepted(contentKind))
} }
func handleNostrRelayConnectionChanged(_ isConnected: Bool) { func handleNostrRelayConnectionChanged(_ isConnected: Bool) {
@@ -346,7 +364,9 @@ private extension AppRuntime {
let becameConnected = isConnected && !lastNostrRelayConnectedState let becameConnected = isConnected && !lastNostrRelayConnectedState
lastNostrRelayConnectedState = isConnected lastNostrRelayConnectedState = isConnected
guard started, becameConnected else { return } guard chatViewModel.networkActivationAllowed,
started,
becameConnected else { return }
let isInitialConnection = !didHandleInitialNostrConnection let isInitialConnection = !didHandleInitialNostrConnection
didHandleInitialNostrConnection = true didHandleInitialNostrConnection = true
+58 -35
View File
@@ -39,15 +39,17 @@ final class Conversation: ObservableObject, Identifiable {
@Published private(set) var messages: [BitchatMessage] = [] @Published private(set) var messages: [BitchatMessage] = []
@Published private(set) var isUnread: Bool = false @Published private(set) var isUnread: Bool = false
/// Incrementally-maintained message-ID index map for O(1) dedup and /// Incrementally-maintained message-ID logical-index map for O(1)
/// delivery-status lookup. Kept in sync on every mutation: /// dedup and delivery-status lookup. Logical indexes are physical array
/// - tail append: single insert /// indexes plus `indexOffset`; trimming from the head advances the offset
/// - out-of-order insert: suffix reindex from the insertion point /// instead of rewriting every surviving dictionary entry. This matters
/// - trim: full rebuild `removeFirst(k)` is already O(n), so the /// after the 1337-message cap is reached, when every steady-state tail
/// rebuild does not change the asymptotics, and trim only happens once /// append evicts one old row.
/// the cap (1337) is reached. Simple and correct beats the ///
/// offset-tracking alternative here. /// Out-of-order inserts and middle removals still reindex only the
/// affected suffix. Full filtering resets the offset while rebuilding.
private var indexByMessageID: [String: Int] = [:] private var indexByMessageID: [String: Int] = [:]
private var indexOffset = 0
fileprivate init(id: ConversationID, cap: Int) { fileprivate init(id: ConversationID, cap: Int) {
self.id = id self.id = id
@@ -61,7 +63,7 @@ final class Conversation: ObservableObject, Identifiable {
} }
func message(withID messageID: String) -> BitchatMessage? { func message(withID messageID: String) -> BitchatMessage? {
guard let index = indexByMessageID[messageID] else { return nil } guard let index = physicalIndex(forMessageID: messageID) else { return nil }
return messages[index] return messages[index]
} }
@@ -101,7 +103,7 @@ final class Conversation: ObservableObject, Identifiable {
reindex(from: index) reindex(from: index)
} else { } else {
messages.append(message) messages.append(message)
indexByMessageID[message.id] = messages.count - 1 indexByMessageID[message.id] = indexOffset + messages.count - 1
} }
return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded()) return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded())
@@ -111,7 +113,7 @@ final class Conversation: ObservableObject, Identifiable {
/// timeline position (in-place updates like media progress reuse the /// timeline position (in-place updates like media progress reuse the
/// original timestamp); a new message goes through ordered insertion. /// original timestamp); a new message goes through ordered insertion.
fileprivate func upsert(_ message: BitchatMessage) -> UpsertOutcome { fileprivate func upsert(_ message: BitchatMessage) -> UpsertOutcome {
if let index = indexByMessageID[message.id] { if let index = physicalIndex(forMessageID: message.id) {
messages[index] = message messages[index] = message
return .updated return .updated
} }
@@ -125,7 +127,7 @@ final class Conversation: ObservableObject, Identifiable {
/// `.read` is never downgraded to `.delivered` or `.sent`. /// `.read` is never downgraded to `.delivered` or `.sent`.
/// Returns `true` when the status was applied. /// Returns `true` when the status was applied.
fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool { fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
guard let index = indexByMessageID[messageID] else { return false } guard let index = physicalIndex(forMessageID: messageID) else { return false }
let message = messages[index] let message = messages[index]
guard !Self.shouldSkipStatusUpdate(current: message.deliveryStatus, new: status) else { return false } guard !Self.shouldSkipStatusUpdate(current: message.deliveryStatus, new: status) else { return false }
@@ -142,7 +144,7 @@ final class Conversation: ObservableObject, Identifiable {
/// observers still need an @Published emission to re-render. /// observers still need an @Published emission to re-render.
@discardableResult @discardableResult
fileprivate func republishMessage(withID messageID: String) -> Bool { fileprivate func republishMessage(withID messageID: String) -> Bool {
guard let index = indexByMessageID[messageID] else { return false } guard let index = physicalIndex(forMessageID: messageID) else { return false }
messages[index] = messages[index] messages[index] = messages[index]
return true return true
} }
@@ -157,10 +159,14 @@ final class Conversation: ObservableObject, Identifiable {
/// Removes a single message by ID. Returns the removed message, or /// Removes a single message by ID. Returns the removed message, or
/// `nil` when no message with that ID exists. /// `nil` when no message with that ID exists.
fileprivate func remove(messageID: String) -> BitchatMessage? { fileprivate func remove(messageID: String) -> BitchatMessage? {
guard let index = indexByMessageID[messageID] else { return nil } guard let index = physicalIndex(forMessageID: messageID) else { return nil }
let removed = messages.remove(at: index) let removed = messages.remove(at: index)
indexByMessageID.removeValue(forKey: messageID) indexByMessageID.removeValue(forKey: messageID)
if index == 0 {
indexOffset += 1
} else {
reindex(from: index) reindex(from: index)
}
return removed return removed
} }
@@ -177,6 +183,7 @@ final class Conversation: ObservableObject, Identifiable {
for id in removedIDs { for id in removedIDs {
indexByMessageID.removeValue(forKey: id) indexByMessageID.removeValue(forKey: id)
} }
indexOffset = 0
reindex(from: 0) reindex(from: 0)
return removedIDs return removedIDs
} }
@@ -184,6 +191,7 @@ final class Conversation: ObservableObject, Identifiable {
fileprivate func clearMessages() { fileprivate func clearMessages() {
messages.removeAll() messages.removeAll()
indexByMessageID.removeAll() indexByMessageID.removeAll()
indexOffset = 0
} }
// MARK: Diagnostics // MARK: Diagnostics
@@ -205,9 +213,10 @@ final class Conversation: ObservableObject, Identifiable {
let message = messages[position] let message = messages[position]
// Count equality + every message resolving to its own position // Count equality + every message resolving to its own position
// proves the index is exactly the inverse map (no stale extras). // proves the index is exactly the inverse map (no stale extras).
if let index = indexByMessageID[message.id] { if let logicalIndex = indexByMessageID[message.id] {
if index != position { let expectedIndex = indexOffset + position
violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(index)") if logicalIndex != expectedIndex {
violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(logicalIndex - indexOffset)")
} }
} else { } else {
violations.append("\(label): message \(message.id.prefix(8))… at \(position) missing from index") violations.append("\(label): message \(message.id.prefix(8))… at \(position) missing from index")
@@ -225,8 +234,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
@@ -252,10 +278,17 @@ final class Conversation: ObservableObject, Identifiable {
private func reindex(from start: Int) { private func reindex(from start: Int) {
for index in start..<messages.count { for index in start..<messages.count {
indexByMessageID[messages[index].id] = index indexByMessageID[messages[index].id] = indexOffset + index
} }
} }
private func physicalIndex(forMessageID messageID: String) -> Int? {
guard let logicalIndex = indexByMessageID[messageID] else { return nil }
let index = logicalIndex - indexOffset
guard messages.indices.contains(index) else { return nil }
return index
}
/// Trims oldest messages over the cap; returns the trimmed message IDs. /// Trims oldest messages over the cap; returns the trimmed message IDs.
private func trimIfNeeded() -> [String] { private func trimIfNeeded() -> [String] {
guard messages.count > cap else { return [] } guard messages.count > cap else { return [] }
@@ -265,7 +298,7 @@ final class Conversation: ObservableObject, Identifiable {
indexByMessageID.removeValue(forKey: id) indexByMessageID.removeValue(forKey: id)
} }
messages.removeFirst(overflow) messages.removeFirst(overflow)
reindex(from: 0) indexOffset += overflow
return trimmedIDs return trimmedIDs
} }
} }
@@ -796,16 +829,6 @@ extension ConversationStore {
return messageIDs return messageIDs
} }
/// Removes every direct conversation (panic clear).
func removeAllDirectConversations() {
let directIDs = conversationIDs.filter { id in
if case .direct = id { return true }
return false
}
for id in directIDs {
removeConversation(id)
}
}
} }
// MARK: - Diagnostics support // MARK: - Diagnostics support
@@ -837,8 +860,8 @@ extension Conversation {
/// (positions 0 and 1 swap their index entries). Requires >= 2 messages. /// (positions 0 and 1 swap their index entries). Requires >= 2 messages.
func _testCorruptIndexEntries() { func _testCorruptIndexEntries() {
guard messages.count >= 2 else { return } guard messages.count >= 2 else { return }
indexByMessageID[messages[0].id] = 1 indexByMessageID[messages[0].id] = indexOffset + 1
indexByMessageID[messages[1].id] = 0 indexByMessageID[messages[1].id] = indexOffset
} }
/// Drops a message's index entry entirely (count mismatch + missing). /// Drops a message's index entry entirely (count mismatch + missing).
@@ -852,8 +875,8 @@ extension Conversation {
func _testCorruptOrderingPreservingIndex() { func _testCorruptOrderingPreservingIndex() {
guard messages.count >= 2 else { return } guard messages.count >= 2 else { return }
messages.swapAt(0, messages.count - 1) messages.swapAt(0, messages.count - 1)
indexByMessageID[messages[0].id] = 0 indexByMessageID[messages[0].id] = indexOffset
indexByMessageID[messages[messages.count - 1].id] = messages.count - 1 indexByMessageID[messages[messages.count - 1].id] = indexOffset + messages.count - 1
} }
} }
@@ -893,7 +916,7 @@ extension ConversationStore {
extension Conversation { extension Conversation {
fileprivate func _testAppendBypassingTrim(_ message: BitchatMessage) { fileprivate func _testAppendBypassingTrim(_ message: BitchatMessage) {
messages.append(message) messages.append(message)
indexByMessageID[message.id] = messages.count - 1 indexByMessageID[message.id] = indexOffset + messages.count - 1
} }
} }
#endif #endif
+30
View File
@@ -12,6 +12,10 @@ 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
/// courtesy: the composer mic tints "busy" while someone holds the floor).
@Published private(set) var activeLiveVoiceTalker: String?
private let chatViewModel: ChatViewModel private let chatViewModel: ChatViewModel
private let privateConversationModel: PrivateConversationModel private let privateConversationModel: PrivateConversationModel
@@ -150,6 +154,24 @@ 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
/// peer can hear it now, classic voice note otherwise.
func makeVoiceCaptureSession() -> VoiceCaptureSession {
chatViewModel.makeVoiceCaptureSession()
}
/// Whether this message is a live voice burst still streaming in.
func isLiveVoiceMessage(_ message: BitchatMessage) -> Bool {
chatViewModel.liveVoiceCoordinator.isLiveVoiceMessage(message)
}
func cancelMediaSend(messageID: String) { func cancelMediaSend(messageID: String) {
chatViewModel.cancelMediaSend(messageID: messageID) chatViewModel.cancelMediaSend(messageID: messageID)
} }
@@ -175,6 +197,14 @@ final class ConversationUIModel: ObservableObject {
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.assign(to: &$isBatchingPublic) .assign(to: &$isBatchingPublic)
chatViewModel.$activePublicVoiceTalker
.receive(on: DispatchQueue.main)
.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
-6
View File
@@ -1,4 +1,3 @@
import BitFoundation
import Combine import Combine
import Foundation import Foundation
@@ -17,7 +16,6 @@ final class LocationChannelsModel: ObservableObject {
private let manager: LocationChannelManager private let manager: LocationChannelManager
private let network: NetworkActivationService private let network: NetworkActivationService
private let gateway: GatewayService private let gateway: GatewayService
private var cancellables = Set<AnyCancellable>()
init( init(
manager: LocationChannelManager? = nil, manager: LocationChannelManager? = nil,
@@ -102,10 +100,6 @@ final class LocationChannelsModel: ObservableObject {
network.setUserTorEnabled(enabled) network.setUserTorEnabled(enabled)
} }
func setGatewayEnabled(_ enabled: Bool) {
gateway.setEnabled(enabled)
}
func refreshMeshChannelsIfNeeded() { func refreshMeshChannelsIfNeeded() {
guard case .mesh = selectedChannel, guard case .mesh = selectedChannel,
permissionState == .authorized, permissionState == .authorized,
+110 -9
View File
@@ -7,45 +7,146 @@ final class LocationPresenceStore: ObservableObject {
@Published private(set) var geoNicknames: [String: String] = [:] @Published private(set) var geoNicknames: [String: String] = [:]
@Published private(set) var teleportedGeo: Set<String> = [] @Published private(set) var teleportedGeo: Set<String> = []
private let teleportedGeoCapacity: Int
private var teleportedGeoOrder: [String] = []
private let geoNicknameCapacity: Int
private var geoNicknameOrder: [String] = []
init(
teleportedGeoCapacity: Int = TransportConfig.geoTeleportedParticipantsCap,
geoNicknameCapacity: Int = TransportConfig.geoNicknameParticipantsCap
) {
self.teleportedGeoCapacity = max(0, teleportedGeoCapacity)
self.geoNicknameCapacity = max(0, geoNicknameCapacity)
}
func setCurrentGeohash(_ geohash: String?) { func setCurrentGeohash(_ geohash: String?) {
currentGeohash = geohash?.lowercased() let normalized = geohash?.lowercased()
if currentGeohash != normalized {
// Presence markers are scoped to the active geohash channel.
clearTeleportedGeo()
clearGeoNicknames()
}
currentGeohash = normalized
} }
func setNickname(_ nickname: String, for pubkeyHex: String) { func setNickname(_ nickname: String, for pubkeyHex: String) {
geoNicknames[pubkeyHex.lowercased()] = nickname guard geoNicknameCapacity > 0 else {
clearGeoNicknames()
return
}
let key = pubkeyHex.lowercased()
if geoNicknames[key] != nil {
geoNicknames[key] = nickname
return
}
while geoNicknameOrder.count >= geoNicknameCapacity, let oldest = geoNicknameOrder.first {
geoNicknameOrder.removeFirst()
geoNicknames.removeValue(forKey: oldest)
}
geoNicknames[key] = nickname
geoNicknameOrder.append(key)
} }
func replaceGeoNicknames(_ nicknames: [String: String]) { func replaceGeoNicknames(_ nicknames: [String: String]) {
geoNicknames = Dictionary( guard geoNicknameCapacity > 0 else {
uniqueKeysWithValues: nicknames.map { key, value in clearGeoNicknames()
(key.lowercased(), value) return
} }
)
var seen: Set<String> = []
var ordered: [String] = []
var normalized: [String: String] = [:]
for (key, value) in nicknames {
let lower = key.lowercased()
guard seen.insert(lower).inserted else { continue }
ordered.append(lower)
normalized[lower] = value
}
if ordered.count > geoNicknameCapacity {
let kept = Array(ordered.suffix(geoNicknameCapacity))
ordered = kept
normalized = Dictionary(uniqueKeysWithValues: kept.compactMap { key in
normalized[key].map { (key, $0) }
})
}
geoNicknameOrder = ordered
geoNicknames = normalized
} }
func clearGeoNicknames() { func clearGeoNicknames() {
geoNicknames.removeAll() geoNicknames.removeAll()
geoNicknameOrder.removeAll()
}
func retainGeoNicknames(keeping pubkeys: Set<String>) {
let allowed = Set(pubkeys.map { $0.lowercased() })
geoNicknameOrder = geoNicknameOrder.filter { allowed.contains($0) }
geoNicknames = geoNicknames.filter { allowed.contains($0.key) }
} }
func markTeleported(_ pubkeyHex: String) { func markTeleported(_ pubkeyHex: String) {
teleportedGeo.insert(pubkeyHex.lowercased()) guard teleportedGeoCapacity > 0 else {
clearTeleportedGeo()
return
}
let key = pubkeyHex.lowercased()
guard !teleportedGeo.contains(key) else { return }
while teleportedGeoOrder.count >= teleportedGeoCapacity, let oldest = teleportedGeoOrder.first {
teleportedGeoOrder.removeFirst()
teleportedGeo.remove(oldest)
}
teleportedGeo.insert(key)
teleportedGeoOrder.append(key)
} }
func clearTeleported(_ pubkeyHex: String) { func clearTeleported(_ pubkeyHex: String) {
teleportedGeo.remove(pubkeyHex.lowercased()) let key = pubkeyHex.lowercased()
teleportedGeo.remove(key)
teleportedGeoOrder.removeAll { $0 == key }
} }
func replaceTeleportedGeo(_ pubkeys: Set<String>) { func replaceTeleportedGeo(_ pubkeys: Set<String>) {
teleportedGeo = Set(pubkeys.map { $0.lowercased() }) guard teleportedGeoCapacity > 0 else {
clearTeleportedGeo()
return
}
var seen: Set<String> = []
var ordered: [String] = []
for key in pubkeys.map({ $0.lowercased() }) where !seen.contains(key) {
seen.insert(key)
ordered.append(key)
}
if ordered.count > teleportedGeoCapacity {
ordered = Array(ordered.suffix(teleportedGeoCapacity))
}
teleportedGeoOrder = ordered
teleportedGeo = Set(ordered)
}
func retainTeleportedGeo(keeping pubkeys: Set<String>) {
let allowed = Set(pubkeys.map { $0.lowercased() })
teleportedGeoOrder = teleportedGeoOrder.filter { allowed.contains($0) }
teleportedGeo = teleportedGeo.intersection(allowed)
} }
func clearTeleportedGeo() { func clearTeleportedGeo() {
teleportedGeo.removeAll() teleportedGeo.removeAll()
teleportedGeoOrder.removeAll()
} }
func reset() { func reset() {
currentGeohash = nil currentGeohash = nil
geoNicknames.removeAll() geoNicknames.removeAll()
geoNicknameOrder.removeAll()
teleportedGeo.removeAll() teleportedGeo.removeAll()
teleportedGeoOrder.removeAll()
} }
} }
+147
View File
@@ -0,0 +1,147 @@
//
// NearbyNotesCounter.swift
// bitchat
//
// Counts unexpired location notes left at the user's current building-level
// geohash so the empty mesh timeline can say "📍 3 notes left here". Only
// subscribes while a view holds it active, and only when location notes are
// enabled and location permission is already granted (it never prompts).
// This is free and unencumbered software released into the public domain.
//
import Combine
import Foundation
@MainActor
final class NearbyNotesCounter: ObservableObject {
static let shared = NearbyNotesCounter()
@Published private(set) var noteCount = 0
/// Whether an explicit notes act (the empty-timeline "check for notes"
/// tap, opening the notices sheet's geo tab, or a successful /drop) has
/// unlocked the counter this session. Until then nothing subscribes:
/// merely looking at the mesh timeline must not open a building-precision
/// relay REQ that leaks location passively.
@Published private(set) var revealed = false
private var manager: LocationNotesManager?
private var managerCancellable: AnyCancellable?
private var channelsCancellable: AnyCancellable?
private var permissionCancellable: AnyCancellable?
private var settingCancellable: AnyCancellable?
private var activeHolders = 0
private let locationManager: LocationChannelManager
private let managerFactory: @MainActor (String) -> LocationNotesManager
private let releaseManager: @MainActor (LocationNotesManager?) -> Void
private let locationNotesEnabled: @MainActor () -> Bool
private let locationNotesSettingsPublisher: AnyPublisher<Void, Never>
init(
locationManager: LocationChannelManager = .shared,
managerFactory: @escaping @MainActor (String) -> LocationNotesManager = { LocationNotesPool.shared.acquire($0) },
releaseManager: @escaping @MainActor (LocationNotesManager?) -> Void = { LocationNotesPool.shared.release($0) },
locationNotesEnabled: @escaping @MainActor () -> Bool = { LocationNotesSettings.enabled },
locationNotesSettings: AnyPublisher<Void, Never>? = nil
) {
self.locationManager = locationManager
self.managerFactory = managerFactory
self.releaseManager = releaseManager
self.locationNotesEnabled = locationNotesEnabled
self.locationNotesSettingsPublisher = locationNotesSettings
?? NotificationCenter.default
.publisher(for: LocationNotesSettings.didChangeNotification)
.map { _ in () }
.eraseToAnyPublisher()
}
/// 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 && locationNotesEnabled() && 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
/// geohash. Balanced by `deactivate()`; ref-counted so multiple views can
/// hold it.
func activate() {
activeHolders += 1
guard activeHolders == 1 else { return }
channelsCancellable = locationManager.$availableChannels
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.retarget() }
// CoreLocation can revoke authorization while the view remains
// mounted. `availableChannels` deliberately retains its last value,
// so permission must be an independent invalidation signal or the
// building REQ survives on stale coordinates.
permissionCancellable = locationManager.$permissionState
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.retarget() }
// The app-info kill switch must take effect immediately, not on the
// next location change or remount.
settingCancellable = locationNotesSettingsPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.retarget() }
retarget()
}
func deactivate() {
activeHolders = max(0, activeHolders - 1)
guard activeHolders == 0 else { return }
channelsCancellable = nil
permissionCancellable = nil
settingCancellable = nil
managerCancellable = nil
releaseManager(manager)
manager = nil
noteCount = 0
}
private func retarget() {
guard activeHolders > 0,
revealed,
locationNotesEnabled(),
locationManager.permissionState == .authorized,
let geohash = locationManager.availableChannels
.first(where: { $0.level == .building })?.geohash
else {
managerCancellable = nil
releaseManager(manager)
manager = nil
noteCount = 0
return
}
if let manager {
guard manager.geohash != geohash.lowercased() else { return }
// Pooled managers are shared; never retarget one in place
// release the old cell and acquire the new one.
managerCancellable = nil
releaseManager(manager)
self.manager = nil
}
let fresh = managerFactory(geohash)
manager = fresh
managerCancellable = fresh.$notes
.receive(on: DispatchQueue.main)
.sink { [weak self] notes in
let now = Date()
self?.noteCount = notes.filter { $0.expiresAt.map { $0 > now } ?? true }.count
}
}
}
-8
View File
@@ -25,10 +25,6 @@ final class PeerIdentityStore: ObservableObject {
stablePeerIDsByShortID[peerID] = stablePeerID stablePeerIDsByShortID[peerID] = stablePeerID
} }
func replaceStablePeerIDs(_ mappings: [PeerID: PeerID]) {
stablePeerIDsByShortID = mappings
}
func fingerprint(for peerID: PeerID) -> String? { func fingerprint(for peerID: PeerID) -> String? {
peerFingerprintsByPeerID[peerID] peerFingerprintsByPeerID[peerID]
} }
@@ -94,10 +90,6 @@ final class PeerIdentityStore: ObservableObject {
invalidateEncryptionCache(for: peerID) invalidateEncryptionCache(for: peerID)
} }
func replaceEncryptionStatuses(_ statuses: [PeerID: EncryptionStatus]) {
encryptionStatuses = statuses
}
func setVerifiedFingerprints(_ fingerprints: Set<String>) { func setVerifiedFingerprints(_ fingerprints: Set<String>) {
verifiedFingerprints = fingerprints verifiedFingerprints = fingerprints
} }
+4 -4
View File
@@ -265,10 +265,10 @@ final class PrivateConversationModel: ObservableObject {
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID) let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
let peer = chatViewModel.getPeer(byID: headerPeerID) let peer = chatViewModel.getPeer(byID: headerPeerID)
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer) let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
// Geo DMs are always routed over Nostr (NIP-17); their nostr_ keys // Geo DMs are always routed through BitChat private envelopes over
// never resolve to a reachable mesh peer, so resolveAvailability would // Nostr; their nostr_ keys never resolve to a reachable mesh peer, so
// report .offline. Report .nostrAvailable so the header shows the // resolveAvailability would report .offline. Report .nostrAvailable
// globe instead of a misleading "offline" tag. // so the header shows the globe instead of a misleading "offline" tag.
let availability = conversationPeerID.isGeoDM let availability = conversationPeerID.isGeoDM
? .nostrAvailable ? .nostrAvailable
: resolveAvailability(for: headerPeerID, peer: peer) : resolveAvailability(for: headerPeerID, peer: peer)
+119
View File
@@ -0,0 +1,119 @@
import BitFoundation
import Combine
import Foundation
enum SharedContentDestination: Sendable, Equatable {
case mesh
case geohash(String)
case privateConversation(peerID: PeerID, displayName: String)
static func resolve(
selectedPrivatePeerID: PeerID?,
privateDisplayName: String?,
activeChannel: ChannelID
) -> SharedContentDestination {
if let selectedPrivatePeerID {
let fallback = String(selectedPrivatePeerID.id.prefix(12))
return .privateConversation(
peerID: selectedPrivatePeerID,
displayName: privateDisplayName?.trimmedOrNilIfEmpty ?? fallback
)
}
switch activeChannel {
case .mesh:
return .mesh
case .location(let channel):
return .geohash(channel.geohash.lowercased())
}
}
var displayName: String {
switch self {
case .mesh:
return "#mesh"
case .geohash(let geohash):
return "#\(geohash)"
case .privateConversation(_, let displayName):
return displayName
}
}
}
struct SharedContentOffer: Identifiable, Sendable, Equatable {
let payload: SharedContentPayload
let destination: SharedContentDestination
var id: UUID { payload.id }
}
/// Holds a pending extension handoff until the user chooses a destination and
/// explicitly adds it to the composer. This type has no send dependency by
/// design: confirming an import can never transmit a message.
@MainActor
final class SharedContentImportModel: ObservableObject {
@Published private(set) var offer: SharedContentOffer?
private let store: SharedContentStore?
init(store: SharedContentStore?) {
self.store = store
}
@discardableResult
func refresh(
destination: SharedContentDestination,
now: Date = Date()
) -> SharedContentPayload? {
guard let payload = store?.pending(now: now) else {
offer = nil
return nil
}
let nextOffer = SharedContentOffer(payload: payload, destination: destination)
if offer != nextOffer {
offer = nextOffer
}
return payload
}
func updateDestination(_ destination: SharedContentDestination) {
guard let offer, offer.destination != destination else { return }
self.offer = SharedContentOffer(payload: offer.payload, destination: destination)
}
/// Returns composer text only when the currently displayed destination is
/// still current and the reviewed envelope is still the stored envelope.
/// A destination change updates the prompt and requires another tap.
func confirm(
destination: SharedContentDestination,
now: Date = Date()
) -> String? {
guard let offer else { return nil }
guard offer.destination == destination else {
updateDestination(destination)
return nil
}
guard let payload = store?.consume(id: offer.id, now: now) else {
_ = refresh(destination: destination, now: now)
return nil
}
self.offer = nil
return payload.composerText
}
func cancel(destination: SharedContentDestination, now: Date = Date()) {
guard let offer else { return }
store?.discard(id: offer.id)
self.offer = nil
// If a newer share replaced the reviewed envelope, surface it rather
// than losing it with the older cancellation.
_ = refresh(destination: destination, now: now)
}
func discardAll() {
store?.discardAll()
offer = nil
}
}
-6
View File
@@ -3,7 +3,6 @@ import Combine
import Foundation import Foundation
struct FingerprintPresentationState: Equatable { struct FingerprintPresentationState: Equatable {
let statusPeerID: PeerID
let peerNickname: String let peerNickname: String
let encryptionStatus: EncryptionStatus let encryptionStatus: EncryptionStatus
let theirFingerprint: String? let theirFingerprint: String?
@@ -56,10 +55,6 @@ final class VerificationModel: ObservableObject {
return VerificationService.shared.buildMyQRString(nickname: currentNickname, npub: npub) ?? "" return VerificationService.shared.buildMyQRString(nickname: currentNickname, npub: npub) ?? ""
} }
func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {
chatViewModel.beginQRVerification(with: qr)
}
func verifyScannedPayload(_ payload: String) -> VerificationScanOutcome { func verifyScannedPayload(_ payload: String) -> VerificationScanOutcome {
guard let qr = VerificationService.shared.verifyScannedQR(payload) else { guard let qr = VerificationService.shared.verifyScannedQR(payload) else {
return .invalid return .invalid
@@ -110,7 +105,6 @@ final class VerificationModel: ObservableObject {
} }
return FingerprintPresentationState( return FingerprintPresentationState(
statusPeerID: statusPeerID,
peerNickname: peerNickname, peerNickname: peerNickname,
encryptionStatus: encryptionStatus, encryptionStatus: encryptionStatus,
theirFingerprint: theirFingerprint, theirFingerprint: theirFingerprint,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 848 B

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 356 B

After

Width:  |  Height:  |  Size: 460 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 429 B

After

Width:  |  Height:  |  Size: 833 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 429 B

After

Width:  |  Height:  |  Size: 833 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 597 B

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 50 KiB

@@ -27,6 +27,66 @@
"idiom" : "universal", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"
},
{
"filename" : "mac_16x16.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"filename" : "mac_16x16@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"filename" : "mac_32x32.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"filename" : "mac_32x32@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"filename" : "mac_128x128.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"filename" : "mac_128x128@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"filename" : "mac_256x256.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "mac_256x256@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"filename" : "mac_512x512.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "mac_512x512@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
} }
], ],
"info" : { "info" : {
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 505 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 930 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 930 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

+11 -2
View File
@@ -41,6 +41,7 @@ struct BitchatApp: App {
.environmentObject(runtime.peerListModel) .environmentObject(runtime.peerListModel)
.environmentObject(runtime.appChromeModel) .environmentObject(runtime.appChromeModel)
.environmentObject(runtime.boardAlertsModel) .environmentObject(runtime.boardAlertsModel)
.environmentObject(runtime.sharedContentImportModel)
.onAppear { .onAppear {
appDelegate.runtime = runtime appDelegate.runtime = runtime
runtime.start() runtime.start()
@@ -104,13 +105,21 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
let identifier = response.notification.request.identifier let identifier = response.notification.request.identifier
let actionIdentifier = response.actionIdentifier
let userInfo = response.notification.request.content.userInfo let userInfo = response.notification.request.content.userInfo
// Complete only after the response is handled: for a background
// action (👋 wave) the system may suspend the app the moment the
// completion handler runs, which would drop the queued send.
Task { @MainActor in Task { @MainActor in
self.runtime?.handleNotificationResponse(identifier: identifier, userInfo: userInfo) self.runtime?.handleNotificationResponse(
} identifier: identifier,
actionIdentifier: actionIdentifier,
userInfo: userInfo
)
completionHandler() completionHandler()
} }
}
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
let identifier = notification.request.identifier let identifier = notification.request.identifier
@@ -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
+175
View File
@@ -0,0 +1,175 @@
//
// PTTAudioCodec.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
/// Streaming PCM -> AAC-LC encoder for live voice. Stateful (the AAC encoder
/// carries a bit reservoir across frames); one instance per burst.
/// Not thread-safe confine to one queue.
final class PTTFrameEncoder {
private let converter: AVAudioConverter
private var pendingInput: [AVAudioPCMBuffer] = []
init?() {
guard let pcm = PTTAudioFormat.pcmFormat,
let aac = PTTAudioFormat.aacFormat,
let converter = AVAudioConverter(from: pcm, to: aac)
else { return nil }
converter.bitRate = PTTAudioFormat.bitRate
self.converter = converter
}
/// Feeds PCM (16 kHz mono float) and returns every complete AAC frame the
/// encoder produced. Frames come out ~130 bytes each at 16 kbps.
func encode(_ buffer: AVAudioPCMBuffer) -> [Data] {
pendingInput.append(buffer)
return drainConverter()
}
private func drainConverter() -> [Data] {
var frames: [Data] = []
while true {
let output = AVAudioCompressedBuffer(
format: converter.outputFormat,
packetCapacity: 8,
maximumPacketSize: max(converter.maximumOutputPacketSize, 1)
)
var error: NSError?
let status = converter.convert(to: output, error: &error) { [weak self] _, outStatus in
guard let self, let next = self.pendingInput.first else {
outStatus.pointee = .noDataNow
return nil
}
self.pendingInput.removeFirst()
outStatus.pointee = .haveData
return next
}
if status == .error {
SecureLogger.error("PTT encode failed: \(error?.localizedDescription ?? "unknown")", category: .session)
return frames
}
frames.append(contentsOf: Self.extractPackets(from: output))
// .haveData means the output buffer filled and more may be ready;
// anything else means the converter wants more input.
if status != .haveData { return frames }
}
}
private static func extractPackets(from buffer: AVAudioCompressedBuffer) -> [Data] {
guard buffer.packetCount > 0, let descriptions = buffer.packetDescriptions else { return [] }
var frames: [Data] = []
frames.reserveCapacity(Int(buffer.packetCount))
for index in 0..<Int(buffer.packetCount) {
let description = descriptions[index]
guard description.mDataByteSize > 0 else { continue }
let start = buffer.data.advanced(by: Int(description.mStartOffset))
frames.append(Data(bytes: start, count: Int(description.mDataByteSize)))
}
return frames
}
}
/// Streaming AAC-LC -> PCM decoder for live voice. Stateful; one instance per
/// inbound burst. Not thread-safe confine to one queue/actor.
final class PTTFrameDecoder {
private let converter: AVAudioConverter
private let pcmFormat: AVAudioFormat
private let aacFormat: AVAudioFormat
init?() {
guard let pcm = PTTAudioFormat.pcmFormat,
let aac = PTTAudioFormat.aacFormat,
let converter = AVAudioConverter(from: aac, to: pcm)
else { return nil }
self.converter = converter
self.pcmFormat = pcm
self.aacFormat = aac
}
/// Decodes one raw AAC frame to PCM. Returns nil for malformed input or
/// while the decoder is still priming (the first frame of a stream).
func decode(_ frame: Data) -> AVAudioPCMBuffer? {
guard !frame.isEmpty, frame.count <= 8 * 1024 else { return nil }
let input = AVAudioCompressedBuffer(format: aacFormat, packetCapacity: 1, maximumPacketSize: frame.count)
frame.withUnsafeBytes { raw in
guard let base = raw.baseAddress else { return }
input.data.copyMemory(from: base, byteCount: frame.count)
}
input.byteLength = UInt32(frame.count)
input.packetCount = 1
input.packetDescriptions?.pointee = AudioStreamPacketDescription(
mStartOffset: 0,
mVariableFramesInPacket: 0,
mDataByteSize: UInt32(frame.count)
)
guard let output = AVAudioPCMBuffer(
pcmFormat: pcmFormat,
frameCapacity: PTTAudioFormat.samplesPerFrame * 2
) else { return nil }
var consumed = false
var error: NSError?
let status = converter.convert(to: output, error: &error) { _, outStatus in
if consumed {
outStatus.pointee = .noDataNow
return nil
}
consumed = true
outStatus.pointee = .haveData
return input
}
guard status != .error else {
SecureLogger.debug("PTT decode failed: \(error?.localizedDescription ?? "unknown")", category: .session)
return nil
}
return output.frameLength > 0 ? output : nil
}
}
/// Sample-rate/channel converter from the microphone's native format to the
/// 16 kHz mono processing format. Stateful; not thread-safe.
final class PTTInputResampler {
private let converter: AVAudioConverter
private let outputFormat: AVAudioFormat
private let ratio: Double
init?(inputFormat: AVAudioFormat) {
guard let pcm = PTTAudioFormat.pcmFormat,
let converter = AVAudioConverter(from: inputFormat, to: pcm)
else { return nil }
self.converter = converter
self.outputFormat = pcm
self.ratio = PTTAudioFormat.sampleRate / inputFormat.sampleRate
}
func resample(_ buffer: AVAudioPCMBuffer) -> AVAudioPCMBuffer? {
let capacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio) + 64
guard let output = AVAudioPCMBuffer(pcmFormat: outputFormat, frameCapacity: capacity) else { return nil }
var consumed = false
var error: NSError?
let status = converter.convert(to: output, error: &error) { _, outStatus in
if consumed {
outStatus.pointee = .noDataNow
return nil
}
consumed = true
outStatus.pointee = .haveData
return buffer
}
guard status != .error else {
SecureLogger.debug("PTT resample failed: \(error?.localizedDescription ?? "unknown")", category: .session)
return nil
}
return output.frameLength > 0 ? output : nil
}
}
@@ -0,0 +1,84 @@
//
// PTTAudioFormat.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import AVFoundation
import Foundation
/// Shared audio parameters for live push-to-talk: AAC-LC, 16 kHz, mono,
/// ~16 kbps deliberately identical to `VoiceRecorder`'s voice-note settings
/// so a burst's finalized `.m4a` and its live frames sound the same.
enum PTTAudioFormat {
static let sampleRate: Double = 16_000
static let channelCount: AVAudioChannelCount = 1
static let bitRate = 16_000
/// AAC-LC frame size is fixed by the codec: 1024 samples = 64 ms at 16 kHz.
static let samplesPerFrame: AVAudioFrameCount = 1024
static var frameDuration: TimeInterval { Double(samplesPerFrame) / sampleRate }
/// Uncompressed processing format (deinterleaved float PCM).
static var pcmFormat: AVAudioFormat? {
AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: channelCount)
}
/// Compressed wire format.
static var aacFormat: AVAudioFormat? {
var description = AudioStreamBasicDescription(
mSampleRate: sampleRate,
mFormatID: kAudioFormatMPEG4AAC,
mFormatFlags: 0,
mBytesPerPacket: 0,
mFramesPerPacket: samplesPerFrame,
mBytesPerFrame: 0,
mChannelsPerFrame: channelCount,
mBitsPerChannel: 0,
mReserved: 0
)
return AVAudioFormat(streamDescription: &description)
}
/// Voice-note container settings for the finalized `.m4a`, mirroring
/// `VoiceRecorder.startRecording()`.
static var voiceNoteFileSettings: [String: Any] {
[
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: sampleRate,
AVNumberOfChannelsKey: Int(channelCount),
AVEncoderBitRateKey: bitRate
]
}
}
/// Builds ADTS-framed AAC so a receiver can persist a burst progressively:
/// unlike `.m4a` (whose moov atom only exists after close), an ADTS `.aac`
/// stream is playable at any prefix a partially received burst is still a
/// replayable voice note.
enum ADTSFramer {
private static let headerSize = 7
/// MPEG-4 sampling frequency index for 16 kHz.
private static let samplingFrequencyIndex: UInt8 = 8
private static let channelConfiguration: UInt8 = 1
/// Wraps one raw AAC-LC frame in an ADTS header.
static func frame(_ aacFrame: Data) -> Data {
let frameLength = aacFrame.count + headerSize
var data = Data(capacity: frameLength)
// Syncword 0xFFF, MPEG-4, layer 00, no CRC.
data.append(0xFF)
data.append(0xF1)
// Profile AAC-LC (audio object type 2 -> bits 01), frequency index,
// private bit 0, channel config high bit.
data.append((0b01 << 6) | (samplingFrequencyIndex << 2) | ((channelConfiguration >> 2) & 0x1))
data.append(((channelConfiguration & 0x3) << 6) | UInt8((frameLength >> 11) & 0x3))
data.append(UInt8((frameLength >> 3) & 0xFF))
data.append(UInt8((frameLength & 0x7) << 5) | 0x1F)
// Buffer fullness 0x7FF (VBR), one AAC frame per ADTS frame.
data.append(0xFC)
data.append(aacFrame)
return data
}
}
+509
View File
@@ -0,0 +1,509 @@
//
// PTTBurstPlayer.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
@preconcurrency import AVFoundation
import BitLogger
import Foundation
/// The engine operations behind live-burst playback, abstracted so the
/// player's lifecycle (jitter start, category-escalation restart, stop) is
/// unit-testable without real audio hardware.
@MainActor
protocol PTTPlaybackEngine: AnyObject {
/// The object `AVAudioEngineConfigurationChange` notifications are posted
/// for (nil for mocks no observer is registered).
var configChangeObject: AnyObject? { get }
func start() throws
func play()
func stop()
func schedule(
_ buffer: AVAudioPCMBuffer,
completionType: PTTPlaybackCompletionType,
completionHandler: @escaping @Sendable (PTTPlaybackCompletionEvent) -> Void
)
}
/// The lifecycle point requested from `AVAudioPlayerNode` for a scheduled
/// buffer. `dataConsumed` only means the node no longer needs the bytes; it
/// may arrive before the render pipeline has made the audio audible.
enum PTTPlaybackCompletionType: Equatable, Sendable {
case dataConsumed
case dataPlayedBack
}
enum PTTPlaybackCompletionEvent: Equatable, Sendable {
case dataConsumed
case dataPlayedBack
/// AVAudioPlayerNode invokes the requested callback when the node is
/// stopped too. That is not audible completion and must remain replayable.
case playbackStopped
}
/// One `AVAudioEngine` + `AVAudioPlayerNode` pair. Created fresh per (re)start:
/// an engine instantiated against an earlier audio-session configuration keeps
/// rendering to the stale route (same class of failure as the capture side's
/// fresh-engine-per-press rule).
@MainActor
private final class SystemPTTPlaybackEngine: PTTPlaybackEngine {
private let engine = AVAudioEngine()
private let node = AVAudioPlayerNode()
init(format: AVAudioFormat) {
engine.attach(node)
engine.connect(node, to: engine.mainMixerNode, format: format)
}
var configChangeObject: AnyObject? { engine }
func start() throws {
engine.prepare()
try engine.start()
}
func play() {
node.play()
}
func stop() {
node.stop()
engine.stop()
}
func schedule(
_ buffer: AVAudioPCMBuffer,
completionType: PTTPlaybackCompletionType,
completionHandler: @escaping @Sendable (PTTPlaybackCompletionEvent) -> Void
) {
let callbackType: AVAudioPlayerNodeCompletionCallbackType = switch completionType {
case .dataConsumed: .dataConsumed
case .dataPlayedBack: .dataPlayedBack
}
let scheduledEngine = engine
node.scheduleBuffer(buffer, completionCallbackType: callbackType) { [weak scheduledEngine] callbackType in
// The API invokes this callback when the player is stopped as
// well. A configuration change can stop the engine before its
// notification reaches MainActor, so do not misclassify that
// flushed tail as audible playback.
guard scheduledEngine?.isRunning == true else {
completionHandler(.playbackStopped)
return
}
switch callbackType {
case .dataConsumed:
completionHandler(.dataConsumed)
case .dataRendered:
completionHandler(.dataConsumed)
case .dataPlayedBack:
completionHandler(.dataPlayedBack)
@unknown default:
completionHandler(.playbackStopped)
}
}
}
}
/// Completion callbacks arrive off the main actor, while engine rebuilds are
/// serialized on it. This small lock-backed latch lets a rebuild atomically
/// claim only buffers whose completion has not already fired even when the
/// callback's hop back to the main actor is still queued.
private final class PTTPlaybackCompletionState: @unchecked Sendable {
private enum State {
case scheduled
case completed
case retired
}
private let lock = NSLock()
private var state: State = .scheduled
/// Returns true exactly once when playback completion wins the race with
/// an engine rebuild or stop.
func complete() -> Bool {
lock.withLock {
guard case .scheduled = state else { return false }
state = .completed
return true
}
}
/// Returns true exactly once when a rebuild or stop claims this
/// still-pending schedule. Later callbacks from that engine are stale.
func retireIfPending() -> Bool {
lock.withLock {
guard case .scheduled = state else { return false }
state = .retired
return true
}
}
}
/// Plays one inbound live voice burst with a small jitter buffer.
///
/// Frames are decoded and scheduled back-to-back on an `AVAudioPlayerNode`;
/// an underrun (missing/late packets) simply pauses output until the next
/// buffer arrives, which self-heals timing without explicit silence
/// insertion. Playback starts once `TransportConfig.pttJitterBufferSeconds`
/// of audio is queued or `pttJitterDeadlineSeconds` has elapsed.
///
/// Talk-over is bidirectional: when push-to-talk capture starts while this
/// burst plays, the session category escalates underneath the engine the
/// player rebuilds a fresh engine against the new configuration and keeps
/// streaming instead of dying. Real interruptions (phone call, route device
/// gone) still stop it; the burst keeps assembling to file either way.
@MainActor
final class PTTBurstPlayer {
/// Restart-on-reconfigure ceiling: a burst is at most ~2 minutes, so a
/// handful of category/route changes is plenty beyond it something is
/// thrashing and stopping cleanly beats an engine-rebuild loop.
private static let maxEngineRestarts = 8
private let makeEngine: @MainActor () -> PTTPlaybackEngine
private var engine: PTTPlaybackEngine
private let decoder: PTTFrameDecoder
private let coordinator: AudioSessionCoordinator
/// Injectable so tests don't fight over the app-wide exclusive-playback
/// slot (a parallel test's `play()` would stop this player mid-test).
private let exclusivity: VoiceNotePlaybackCoordinator
private var queuedBuffers: [AVAudioPCMBuffer] = []
private var queuedDuration: TimeInterval = 0
private struct ScheduledBuffer {
let id: UInt64
let buffer: AVAudioPCMBuffer
let completionState: PTTPlaybackCompletionState
}
/// Buffers handed to the current engine whose completion has not yet
/// been processed on the main actor. Keeping the buffers themselves lets
/// a category-escalation rebuild replay the unfinished tail in order.
private var scheduledBuffers: [ScheduledBuffer] = []
private var nextScheduledBufferID: UInt64 = 0
/// Bumped on every engine rebuild or stop so completion tasks from a
/// torn-down engine cannot mutate the current generation's pending list.
private var engineGeneration = 0
private var engineRestarts = 0
private var engineStarted = false
private var finished = false
/// Latched off (internal read so tests can await the async failure path).
private(set) var stopped = false
/// A session acquire is in flight (it suspends off-main for the blocking
/// session IPC); gates `startIfReady` against double acquisition.
private var acquiringSession = false
private var deadlineTask: Task<Void, Never>?
private var sessionToken: AudioSessionCoordinator.Token?
/// Reserved before the session acquire suspends. Activation succeeds only
/// if no newer playback request claimed the floor in the meantime.
private var playbackReservation: VoiceNotePlaybackCoordinator.Reservation?
private var configChangeObserver: NSObjectProtocol?
private(set) var isPlaying = false
/// Fires exactly once when the player stops for good (drain-out, cancel,
/// interruption, failure). `ChatLiveVoiceCoordinator` uses it to unpark
/// the draining player it keeps alive after the assembly the player's
/// only long-lived owner is discarded on burst END.
var onStopped: (() -> Void)?
init?(
coordinator: AudioSessionCoordinator? = nil,
exclusivity: VoiceNotePlaybackCoordinator? = nil,
makeEngine: (@MainActor () -> PTTPlaybackEngine)? = nil
) {
guard let format = PTTAudioFormat.pcmFormat, let decoder = PTTFrameDecoder() else { return nil }
self.decoder = decoder
self.coordinator = coordinator ?? .shared
self.exclusivity = exclusivity ?? .shared
let factory = makeEngine ?? { SystemPTTPlaybackEngine(format: format) }
self.makeEngine = factory
self.engine = factory()
deadlineTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(TransportConfig.pttJitterDeadlineSeconds * 1_000_000_000))
self?.startIfReady(force: true)
}
}
deinit {
// Backstop for an owner dropping the player before it stopped: the
// session coordinator retains registered tokens strongly, so a token
// leaked here would keep the session active (and pin any escalated
// category) for the app's lifetime. `release` is fire-and-forget
// onto the coordinator's queue, so it is deinit-safe.
if let token = sessionToken {
coordinator.release(token)
}
if let observer = configChangeObserver {
NotificationCenter.default.removeObserver(observer)
}
deadlineTask?.cancel()
}
/// Decodes and queues frames (in burst order). Starts playback when the
/// jitter buffer fills.
func enqueue(_ frames: [Data]) {
guard !stopped else { return }
for frame in frames {
guard let pcm = decoder.decode(frame) else { continue }
if engineStarted {
schedule(pcm)
} else {
queuedBuffers.append(pcm)
queuedDuration += Double(pcm.frameLength) / PTTAudioFormat.sampleRate
}
}
startIfReady(force: false)
}
/// The burst ended: stop once everything scheduled has played out.
func finishAfterDrain() {
finished = true
// The complete burst is queued no jitter left to wait for. This
// also matters when END lands while the async session acquire is
// still in flight: the queued audio must play out, not be treated
// as already drained.
startIfReady(force: true)
stopIfDrained()
}
/// Immediate stop (cancel, another playback taking over, interruption,
/// teardown).
func stop() {
guard !stopped else { return }
stopped = true
deadlineTask?.cancel()
removeConfigObserver()
queuedBuffers = []
retireScheduledBuffers()
if engineStarted {
engine.stop()
}
isPlaying = false
releaseSessionToken()
exclusivity.deactivate(self)
onStopped?()
}
private func startIfReady(force: Bool) {
guard !engineStarted, !acquiringSession, !stopped, !queuedBuffers.isEmpty else { return }
guard force || queuedDuration >= TransportConfig.pttJitterBufferSeconds else { return }
// Acquiring the session suspends for its blocking IPC (off the main
// actor); frames arriving meanwhile keep queueing and are flushed
// onto the engine once it starts.
acquiringSession = true
playbackReservation = exclusivity.reserve(self)
Task { [weak self] in
await self?.acquireSessionAndStart()
}
}
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 {
try engine.start()
} catch {
SecureLogger.error("PTT playback engine failed to start: \(error)", category: .session)
// stop() removes the observer, hands the token back, and
// fires onStopped for any parked draining owner.
stop()
return
}
}
engineStarted = true
guard exclusivity.activate(self, reservation: playbackReservation)
else {
// A newer user-initiated playback reserved the floor while this
// older PTT request was suspended in audio-session activation.
// Never let the late completion steal playback back.
stop()
return
}
isPlaying = true
engine.play()
let buffered = queuedBuffers
queuedBuffers = []
queuedDuration = 0
for buffer in buffered {
schedule(buffer)
}
}
/// The audio session was reconfigured underneath the running engine
/// (category escalation for talk-over, or an engine configuration
/// change): rebuild a fresh engine against the new configuration and
/// keep streaming. Buffers already completed stay completed; the
/// unfinished scheduled tail is replayed in order on the fresh engine,
/// and frames still arriving continue scheduling after it.
private func restartEngine() {
guard engineStarted, !stopped else { return }
engineRestarts += 1
guard engineRestarts <= Self.maxEngineRestarts else {
SecureLogger.warning("PTT playback: engine reconfigured \(engineRestarts) times in one burst — stopping", category: .session)
stop()
return
}
removeConfigObserver()
// Claim the unfinished tail before stopping the old engine. Stopping
// a player node may itself invoke its completion handlers; retiring
// the claimed entries first makes those callbacks unambiguously stale.
// A completion that fired just before this rebuild wins the latch and
// is excluded even if its MainActor task has not run yet.
let buffersToReplay = scheduledBuffers.compactMap { scheduled in
scheduled.completionState.retireIfPending() ? scheduled.buffer : nil
}
scheduledBuffers = []
engineGeneration += 1
engine.stop()
engine = makeEngine()
registerConfigObserver()
do {
try engine.start()
} catch {
SecureLogger.error("PTT playback engine failed to restart after session reconfigure: \(error)", category: .session)
stop()
return
}
engine.play()
for buffer in buffersToReplay {
schedule(buffer)
}
SecureLogger.info("PTT playback: engine restarted after session reconfigure", category: .session)
// If every old buffer completed before the rebuild, a finished burst
// can stop now. Otherwise the replayed tail keeps it alive until its
// new-generation completions arrive.
stopIfDrained()
}
private func registerConfigObserver() {
guard let object = engine.configChangeObject else { return }
configChangeObserver = NotificationCenter.default.addObserver(
forName: .AVAudioEngineConfigurationChange,
object: object,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.restartEngine()
}
}
}
private func removeConfigObserver() {
if let observer = configChangeObserver {
NotificationCenter.default.removeObserver(observer)
configChangeObserver = nil
}
}
private func schedule(_ buffer: AVAudioPCMBuffer) {
let id = nextScheduledBufferID
nextScheduledBufferID &+= 1
let completionState = PTTPlaybackCompletionState()
scheduledBuffers.append(ScheduledBuffer(
id: id,
buffer: buffer,
completionState: completionState
))
let generation = engineGeneration
engine.schedule(buffer, completionType: .dataPlayedBack) { [weak self, completionState] event in
guard event == .dataPlayedBack else { return }
// Mark completion before hopping to MainActor. A rebuild can then
// distinguish already-completed audio from an unfinished tail
// even when this task has not run yet.
guard completionState.complete() else { return }
Task { @MainActor [weak self] in
guard let self, self.engineGeneration == generation else { return }
self.scheduledBuffers.removeAll { $0.id == id }
self.stopIfDrained()
}
}
}
private func retireScheduledBuffers() {
engineGeneration += 1
for scheduled in scheduledBuffers {
_ = scheduled.completionState.retireIfPending()
}
scheduledBuffers = []
}
private func stopIfDrained() {
guard finished, scheduledBuffers.isEmpty else { return }
// Started: everything scheduled has played out. Never started with
// nothing queued or in flight (e.g. no decodable frames): nothing
// will ever play. Otherwise the engine start is still pending (the
// async session acquire) and the queued audio must play out first.
guard engineStarted || (!acquiringSession && queuedBuffers.isEmpty) else { return }
stop()
}
private func releaseSessionToken() {
sessionToken.map(coordinator.release)
sessionToken = nil
}
}
extension PTTBurstPlayer: ExclusivePlayback {
/// A live stream can't meaningfully pause; yielding the floor stops it.
/// The burst keeps assembling to file, so nothing is lost.
nonisolated func pauseForExclusivity() {
Task { @MainActor [weak self] in
self?.stop()
}
}
}
@@ -0,0 +1,326 @@
//
// PTTCaptureEngine.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
/// Owns one capture token and returns it even when the capture engine's owner
/// disappears without reaching its normal stop/cancel path. The coordinator
/// retains registered tokens strongly, so relying on `Token.deinit` cannot
/// reclaim an abandoned hold.
final class PTTCaptureSessionLease: @unchecked Sendable {
private let coordinator: AudioSessionCoordinator
private let lock = NSLock()
private var token: AudioSessionCoordinator.Token?
init(coordinator: AudioSessionCoordinator) {
self.coordinator = coordinator
}
func install(_ token: AudioSessionCoordinator.Token) {
let previous = lock.withLock {
let previous = self.token
self.token = token
return previous
}
previous.map(coordinator.release)
}
func release() {
let token = lock.withLock {
let token = self.token
self.token = nil
return token
}
token.map(coordinator.release)
}
deinit {
release()
}
}
/// Monotonic capture identity shared by main-actor lifecycle code and queued
/// engine callbacks. Removing a notification observer does not cancel a block
/// already enqueued on the main queue, so every callback must also prove it
/// still belongs to the current hold before mutating capture state.
final class PTTCaptureGeneration: @unchecked Sendable {
private let lock = NSLock()
private var value: UInt = 0
func begin() -> UInt {
lock.withLock {
value &+= 1
return value
}
}
func invalidate() {
lock.withLock { value &+= 1 }
}
func invalidate(ifCurrent generation: UInt) -> Bool {
lock.withLock {
guard value == generation else { return false }
value &+= 1
return true
}
}
func isCurrent(_ generation: UInt) -> Bool {
lock.withLock { value == generation }
}
}
/// Captures microphone audio for a live push-to-talk burst, producing both:
/// - live AAC frames via `onFrames` (called on the capture queue), and
/// - a finalized `.m4a` voice note on `stop()` the same artifact
/// `VoiceRecorder` produces, so the existing voice-note send pipeline
/// handles delivery to receivers that missed the live stream.
/// `@unchecked Sendable`: every mutable property is confined to one executor
/// the capture `queue` (resampler/encoder/file/counters) or the main actor
/// (`engine`, `engineStarted`, `sessionLease`, `configChangeObserver`) so
/// weak references may cross the `@Sendable` tap/notification closures, which
/// immediately hop back to the owning executor.
final class PTTCaptureEngine: @unchecked Sendable {
/// Hard cap matching `VoiceRecorder.maxRecordingDuration`: past it the
/// engine keeps running (the UI owns the gesture) but stops encoding.
private static let maxCaptureDuration: TimeInterval = 120
/// Recreated on every `start()`: an engine whose input unit was
/// instantiated against an earlier (playback-only or inactive) audio
/// session keeps reporting a dead 0 Hz / 2 ch input format and fails to
/// enable the mic (AURemoteIO -10851, observed on iPhone field tests).
private var engine = AVAudioEngine()
private let queue = DispatchQueue(label: "chat.bitchat.ptt.capture", qos: .userInitiated)
private let coordinator: AudioSessionCoordinator
private let sessionLease: PTTCaptureSessionLease
private let captureGeneration = PTTCaptureGeneration()
// Capture-queue-confined state.
private var resampler: PTTInputResampler?
private var encoder: PTTFrameEncoder?
private var file: AVAudioFile?
private var fileURL: URL?
private var encodedFrameCount = 0
private var running = false
private var captureStart = Date()
/// Whether `engine.start()` succeeded for the current capture
/// (see `stopEngineIfStarted`).
@MainActor private var engineStarted = false
@MainActor private var configChangeObserver: NSObjectProtocol?
/// Called on the capture queue with each batch of encoded AAC frames.
var onFrames: (([Data]) -> Void)?
enum CaptureError: Error {
case inputUnavailable
case audioSetupFailed
}
init(coordinator: AudioSessionCoordinator = .shared) {
self.coordinator = coordinator
self.sessionLease = PTTCaptureSessionLease(coordinator: coordinator)
}
deinit {
sessionLease.release()
}
/// Async because acquiring the session hops its blocking IPC off the main
/// actor (a PTT press used to stall main >1 s in `setActive`); the engine
/// itself still starts back on main once the session is configured.
@MainActor
func start(outputURL: URL) async throws {
let generation = captureGeneration.begin()
let token = try await coordinator.acquire(.capture) { [weak self] in
self?.handleInterruption(for: generation)
}
// The hold ended (stop/cancel) while the session was activating:
// starting the engine now would leave a hot mic after release.
guard captureGeneration.isCurrent(generation) else {
coordinator.release(token)
throw CancellationError()
}
sessionLease.install(token)
do {
try beginCapture(outputURL: outputURL, generation: generation)
} catch {
releaseSessionToken()
throw error
}
}
@MainActor
private func beginCapture(outputURL: URL, generation: UInt) throws {
// Fresh engine per capture so its input unit binds to the session
// that is active *now* (see `engine` doc comment).
engine = AVAudioEngine()
let inputFormat = engine.inputNode.outputFormat(forBus: 0)
guard inputFormat.sampleRate > 0, inputFormat.channelCount > 0 else {
SecureLogger.error("PTT: capture input unavailable (input reports \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch)", category: .session)
throw CaptureError.inputUnavailable
}
guard let resampler = PTTInputResampler(inputFormat: inputFormat),
let encoder = PTTFrameEncoder(),
let pcmFormat = PTTAudioFormat.pcmFormat
else { throw CaptureError.audioSetupFailed }
let file = try AVAudioFile(
forWriting: outputURL,
settings: PTTAudioFormat.voiceNoteFileSettings,
commonFormat: pcmFormat.commonFormat,
interleaved: pcmFormat.isInterleaved
)
queue.sync {
self.resampler = resampler
self.encoder = encoder
self.file = file
self.fileURL = outputURL
self.encodedFrameCount = 0
self.captureStart = Date()
self.running = true
}
engine.inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] buffer, _ in
self?.queue.async { self?.process(buffer, generation: generation) }
}
// Route/category changes reconfigure the engine underneath the tap;
// stop and finalize cleanly the .m4a captured so far still sends.
// Registered before start() so no reconfigure lands unobserved
// (handleInterruption also validates this capture generation).
configChangeObserver = NotificationCenter.default.addObserver(
forName: .AVAudioEngineConfigurationChange,
object: engine,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.handleInterruption(for: generation)
}
}
engine.prepare()
do {
try engine.start()
} catch {
SecureLogger.error("PTT: capture engine failed to start (input: \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch): \(error)", category: .session)
if let observer = configChangeObserver {
NotificationCenter.default.removeObserver(observer)
configChangeObserver = nil
}
engine.inputNode.removeTap(onBus: 0)
queue.sync { self.teardown(deleteFile: true) }
throw error
}
engineStarted = true
SecureLogger.info("PTT: capture engine running (input: \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch)", category: .session)
}
/// Stops capture and finalizes the `.m4a`. Returns the file URL and the
/// number of encoded AAC frames (each `PTTAudioFormat.frameDuration` long).
@MainActor
func stop() -> (url: URL?, encodedFrames: Int) {
captureGeneration.invalidate()
stopEngineIfStarted()
let result: (URL?, Int) = queue.sync {
let url = fileURL
let frames = encodedFrameCount
teardown(deleteFile: false)
return (url, frames)
}
releaseSessionToken()
return result
}
@MainActor
func cancel() {
captureGeneration.invalidate()
stopEngineIfStarted()
queue.sync { teardown(deleteFile: true) }
releaseSessionToken()
}
/// Audio session interrupted (call, Siri) or the engine was reconfigured
/// mid-capture: behave like `stop()` finalize the `.m4a` container but
/// keep `fileURL`/`encodedFrameCount` so the caller's pending `stop()`
/// still returns the note for delivery.
@MainActor
private func handleInterruption(for generation: UInt) {
// Also invalidate a start whose acquire has registered its token but
// has not returned to this actor yet. Without this bump the callback
// is lost while `engineStarted == false`, and the resumed start can
// open the mic after the stop signal.
guard captureGeneration.invalidate(ifCurrent: generation) else { return }
guard engineStarted else {
releaseSessionToken()
return
}
stopEngineIfStarted()
queue.sync {
running = false
// Releasing the AVAudioFile finalizes the .m4a container.
file = nil
encoder = nil
resampler = nil
}
releaseSessionToken()
SecureLogger.info("PTT: capture interrupted — burst finalized early", category: .session)
}
/// Touching `inputNode` on an engine that never started instantiates its
/// input unit against whatever session is active and spams AURemoteIO
/// errors a canceled-before-start hold must not touch the engine.
@MainActor
private func stopEngineIfStarted() {
if let observer = configChangeObserver {
NotificationCenter.default.removeObserver(observer)
configChangeObserver = nil
}
guard engineStarted else { return }
engineStarted = false
engine.inputNode.removeTap(onBus: 0)
engine.stop()
}
@MainActor
private func releaseSessionToken() {
sessionLease.release()
}
// MARK: - Capture queue
private func process(_ buffer: AVAudioPCMBuffer, generation: UInt) {
guard captureGeneration.isCurrent(generation),
running,
Date().timeIntervalSince(captureStart) < Self.maxCaptureDuration,
let resampled = resampler?.resample(buffer)
else { return }
do {
try file?.write(from: resampled)
} catch {
SecureLogger.error("PTT capture file write failed: \(error)", category: .session)
}
guard let frames = encoder?.encode(resampled), !frames.isEmpty else { return }
encodedFrameCount += frames.count
onFrames?(frames)
}
private func teardown(deleteFile: Bool) {
running = false
// Releasing the AVAudioFile finalizes the .m4a container.
file = nil
encoder = nil
resampler = nil
if deleteFile, let url = fileURL {
try? FileManager.default.removeItem(at: url)
}
fileURL = nil
}
}
+39
View File
@@ -0,0 +1,39 @@
//
// PTTSettings.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
/// User preference for live push-to-talk voice. One switch controls both
/// directions: streaming your holds live, and auto-playing inbound bursts.
/// Off means voice messages behave exactly like classic voice notes.
enum PTTSettings {
private static let liveVoiceEnabledKey = "ptt.liveVoiceEnabled"
static var liveVoiceEnabled: Bool {
get { UserDefaults.standard.object(forKey: liveVoiceEnabledKey) as? Bool ?? true }
set { UserDefaults.standard.set(newValue, forKey: liveVoiceEnabledKey) }
}
/// Autoplay is foreground-only: audio must never start from the
/// background.
@MainActor
static var isAppActive: Bool {
#if os(iOS)
return UIApplication.shared.applicationState == .active
#elseif os(macOS)
return NSApplication.shared.isActive
#else
return true
#endif
}
}
@@ -0,0 +1,250 @@
//
// VoiceCaptureSession.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import BitLogger
import Foundation
/// Capture backend behind the composer's hold-to-record gesture.
/// `VoiceRecordingViewModel` drives one session per press; the concrete type
/// decides *how* audio leaves the device: `VoiceNoteCaptureSession` records a
/// note delivered on release (today's behavior), `PTTLiveVoiceSession`
/// additionally streams frames live while the button is held.
@MainActor
protocol VoiceCaptureSession: AnyObject {
/// Whether audio is leaving the device in real time while recording
/// drives the composer's LIVE treatment.
var isLive: Bool { get }
func requestPermission() async -> Bool
func start() async throws
/// Stops capture and returns the finalized voice-note file, or nil when
/// nothing valid was captured.
func finish() async -> URL?
func cancel() async
/// Stops capture and suppresses every later send before returning.
func panicCancelSynchronously()
}
/// The classic record-then-send backend, wrapping the shared `VoiceRecorder`.
@MainActor
final class VoiceNoteCaptureSession: VoiceCaptureSession {
private let recorder: VoiceRecorder
private let owner = VoiceRecorder.RecordingOwner()
var isLive: Bool { false }
init(recorder: VoiceRecorder = .shared) {
self.recorder = recorder
}
func requestPermission() async -> Bool {
await recorder.requestPermission()
}
func start() async throws {
try await recorder.startRecording(owner: owner)
}
func finish() async -> URL? {
await recorder.stopRecording(owner: owner)
}
func cancel() async {
await recorder.cancelRecording(owner: owner)
}
func panicCancelSynchronously() {
recorder.panicCancelSynchronously(owner: owner)
}
}
/// Testable surface of the live capture engine. Production uses
/// `PTTCaptureEngine`; tests can supply captured-frame counts without opening
/// real audio hardware.
@MainActor
protocol PTTCapturing: AnyObject {
var onFrames: (([Data]) -> Void)? { get set }
func start(outputURL: URL) async throws
func stop() -> (url: URL?, encodedFrames: Int)
func cancel()
}
extension PTTCaptureEngine: PTTCapturing {}
/// Live push-to-talk backend: streams `VoiceBurstPacket`s to one peer while
/// recording, then finalizes the same audio as a standard voice note whose
/// file name carries the burst ID (`voice_<burstID>.m4a`) so receivers that
/// heard the live stream absorb the note silently instead of seeing a
/// duplicate.
@MainActor
final class PTTLiveVoiceSession: VoiceCaptureSession {
let burstID: Data
private let sendPacket: (Data) -> Void
private let capture: any PTTCapturing
private let now: () -> Date
/// Capture-queue-confined stream state: packetizes frames and lazily
/// emits START so packet order is guaranteed by queue serialization.
private final class StreamState {
var packetizer: VoiceBurstPacketizer
var sentStart = false
init(burstID: Data) {
packetizer = VoiceBurstPacketizer(burstID: burstID)
}
}
private let stream: StreamState
private var startDate: Date?
private var completed = false
var isLive: Bool { true }
/// - Parameter sendPacket: delivers one encoded `VoiceBurstPacket` to the
/// target peer; must be safe to call from any queue (BLEService hops to
/// its own message queue internally).
init(
sendPacket: @escaping (Data) -> Void,
capture: (any PTTCapturing)? = nil,
now: @escaping () -> Date = Date.init,
burstID: Data? = nil
) {
self.burstID = burstID ?? VoiceBurstPacket.makeBurstID()
self.sendPacket = sendPacket
self.capture = capture ?? PTTCaptureEngine()
self.now = now
self.stream = StreamState(burstID: self.burstID)
}
func requestPermission() async -> Bool {
await VoiceRecorder.shared.requestPermission()
}
func start() async throws {
let outputURL = try Self.makeOutputURL(burstID: burstID)
let sendPacket = sendPacket
let stream = stream
capture.onFrames = { frames in
if !stream.sentStart {
stream.sentStart = true
if let start = VoiceBurstPacket(
burstID: stream.packetizer.burstID,
seq: 0,
kind: .start(codec: .aacLC16kMono)
) {
sendPacket(start.encode())
}
}
for frame in frames {
for packet in stream.packetizer.add(frame) {
sendPacket(packet)
}
}
// Flush per callback batch: at ~130-byte frames the budget fits
// one frame per packet anyway, and holding residue would add
// ~100 ms of avoidable latency.
for packet in stream.packetizer.flush() {
sendPacket(packet)
}
}
do {
try await capture.start(outputURL: outputURL)
} catch is CancellationError {
// The hold was released/canceled while the session acquire was
// in flight: the engine never started and the capture already
// handed its token back nothing to retry. A coordinator-side
// interruption during handoff also cancels acquire, but that is
// not a successful start and must propagate to the view model.
guard completed else { throw CancellationError() }
return
} catch {
// The HAL can briefly report a dead input right after the audio
// session (re)activates while the route settles; one retry after
// a short pause covers it (observed on iPhone field tests).
SecureLogger.warning("PTT: capture start failed (\(error)) — retrying once after route settle", category: .session)
try? await Task.sleep(nanoseconds: 150_000_000)
// The hold may have been released/canceled during the retry pause.
// Starting the mic now would leave it live and streaming after the
// user let go, so bail instead of opening a hot mic.
guard !completed else {
capture.cancel()
return
}
try await capture.start(outputURL: outputURL)
}
startDate = now()
SecureLogger.info("PTT: live burst \(burstID.hexEncodedString()) capture started", category: .session)
}
func finish() async -> URL? {
guard !completed else { return nil }
completed = true
let elapsed = startDate.map { now().timeIntervalSince($0) } ?? 0
let (url, encodedFrames) = capture.stop()
// stop() drained the capture queue, so touching `stream` is safe now.
let capturedDuration = Double(encodedFrames) * PTTAudioFormat.frameDuration
guard elapsed >= VoiceRecorder.minRecordingDuration,
capturedDuration >= VoiceRecorder.minRecordingDuration,
let url
else {
sendControlPacket(.canceled)
if let url {
try? FileManager.default.removeItem(at: url)
}
return nil
}
for packet in stream.packetizer.flush() {
sendPacket(packet)
}
let durationMs = UInt32((capturedDuration * 1000).rounded())
sendControlPacket(.end(totalDataPackets: stream.packetizer.dataPacketCount, durationMs: durationMs))
SecureLogger.info("PTT: live burst \(burstID.hexEncodedString()) finished — \(stream.packetizer.dataPacketCount) data packets, \(encodedFrames) frames, \(durationMs) ms", category: .session)
return url
}
func cancel() async {
let alreadyCompleted = completed
completed = true
// Always tear down the capture, even if a quick-release already marked
// us completed: the engine can start late (during start()'s retry
// pause), and only capture.cancel() stops the mic and deactivates the
// session. It is idempotent, so a redundant call is harmless.
capture.cancel()
if !alreadyCompleted {
sendControlPacket(.canceled)
}
}
func panicCancelSynchronously() {
// Do not emit a canceled packet: it would itself be pre-panic
// conversation data racing the emergency transport reset.
completed = true
capture.cancel()
}
private func sendControlPacket(_ kind: VoiceBurstPacket.Kind) {
guard let packet = VoiceBurstPacket(burstID: burstID, seq: stream.packetizer.nextSeq, kind: kind) else { return }
sendPacket(packet.encode())
}
private static func makeOutputURL(burstID: Data) throws -> URL {
let base = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let directory = base
.appendingPathComponent("files", isDirectory: true)
.appendingPathComponent("voicenotes/outgoing", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
return directory.appendingPathComponent("voice_\(burstID.hexEncodedString()).m4a")
}
}
@@ -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() {
@@ -181,25 +283,75 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
} }
} }
/// Ensures only one voice note plays at a time. /// Something that can hold the app's single audio-playback slot and yield it
/// when another playback starts (voice notes pause; live bursts stop).
protocol ExclusivePlayback: AnyObject {
func pauseForExclusivity()
}
extension VoiceNotePlaybackController: ExclusivePlayback {
func pauseForExclusivity() {
pause()
}
}
/// Ensures only one voice playback (note or live burst) runs at a time.
final class VoiceNotePlaybackCoordinator { final class VoiceNotePlaybackCoordinator {
static let shared = VoiceNotePlaybackCoordinator() static let shared = VoiceNotePlaybackCoordinator()
private weak var activeController: VoiceNotePlaybackController? struct Reservation: Equatable {
fileprivate let generation: UInt64
}
private init() {} private weak var activeController: (any ExclusivePlayback)?
private weak var latestReservedController: (any ExclusivePlayback)?
private var latestReservation = Reservation(generation: 0)
func activate(_ controller: VoiceNotePlaybackController) { /// Internal so tests can isolate their own exclusivity slot; the app
/// uses `shared`.
init() {}
/// 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?.pause() activeController?.pauseForExclusivity()
activeController = controller activeController = controller
return true
} }
func deactivate(_ controller: VoiceNotePlaybackController) { func isCurrent(_ reservation: Reservation, for controller: any ExclusivePlayback) -> Bool {
latestReservation == reservation && latestReservedController === controller
}
func deactivate(_ controller: any ExclusivePlayback) {
if activeController === controller { if activeController === controller {
activeController = nil activeController = nil
} }
if latestReservedController === controller {
latestReservedController = nil
}
} }
} }
+227 -58
View File
@@ -1,22 +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 recorderInitializationFailed
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
@@ -42,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)
} }
@@ -125,14 +246,60 @@ actor VoiceRecorder {
currentURL = nil currentURL = nil
} }
/// Panic is a synchronous security boundary: the caller must know the
/// microphone, audio-session lease, and partial file are gone before it
/// rotates identities or deletes the media tree. VoiceRecorder is an
/// independent actor and this cleanup path never hops to MainActor, so a
/// short semaphore join is safe even when invoked by the UI actor.
nonisolated
func panicCancelSynchronously(owner: RecordingOwner) {
let finished = DispatchSemaphore(value: 0)
Task {
await cancelRecording(owner: owner)
finished.signal()
}
finished.wait()
}
/// The audio session was interrupted (call, Siri) or reconfigured: stop
/// the recorder but keep `recorder`/`currentURL` so the caller's pending
/// `stopRecording()` still returns the partial note.
private func handleSessionInterruption(for owner: RecordingOwner) async {
// A callback captured for a released token must never stop a newer
// recording. Conversely, an interruption delivered while acquire is
// still suspended invalidates that acquire before it can open the mic.
guard activeOwner === owner else { return }
if startInFlight {
activeOwner = nil
startInFlight = false
return
}
startInFlight = false
if let recorder, recorder.isRecording {
recorder.stop()
}
releaseSessionToken()
}
// MARK: - Helpers // 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)
} }
@@ -147,9 +314,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)
} }
} }
-6
View File
@@ -56,12 +56,6 @@ final class WaveformCache {
} }
} }
func purgeAll() {
queue.async(flags: .barrier) { [weak self] in
self?.cache.removeAll()
}
}
private func computeWaveform(url: URL, bins: Int) -> [Float]? { private func computeWaveform(url: URL, bins: Int) -> [Float]? {
guard bins > 0 else { return nil } guard bins > 0 else { return nil }
// Use autoreleasepool to manage memory from audio buffer allocations // Use autoreleasepool to manage memory from audio buffer allocations
+44 -7
View File
@@ -88,8 +88,6 @@ import BitFoundation
/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy. /// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy.
/// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships. /// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships.
struct EphemeralIdentity { struct EphemeralIdentity {
let peerID: PeerID // 8 random bytes
let sessionStart: Date
var handshakeState: HandshakeState var handshakeState: HandshakeState
} }
@@ -98,7 +96,6 @@ enum HandshakeState {
case initiated case initiated
case inProgress case inProgress
case completed(fingerprint: String) case completed(fingerprint: String)
case failed(reason: String)
} }
/// Represents the cryptographic layer of identity - the stable Noise Protocol static key pair. /// Represents the cryptographic layer of identity - the stable Noise Protocol static key pair.
@@ -110,7 +107,6 @@ struct CryptographicIdentity: Codable {
// Optional Ed25519 signing public key (used to authenticate public messages) // Optional Ed25519 signing public key (used to authenticate public messages)
var signingPublicKey: Data? = nil var signingPublicKey: Data? = nil
let firstSeen: Date let firstSeen: Date
let lastHandshake: Date?
} }
/// Represents the social layer of identity - user-assigned names and trust relationships. /// Represents the social layer of identity - user-assigned names and trust relationships.
@@ -180,9 +176,9 @@ struct IdentityCache: Codable {
var blockedNostrPubkeys: Set<String> = [] var blockedNostrPubkeys: Set<String> = []
// Vouching (transitive verification). All three fields are Optional so // Vouching (transitive verification). All three fields are Optional so
// caches persisted before this feature decode cleanly the synthesized // caches persisted before this feature decode cleanly decodeIfPresent
// decoder uses decodeIfPresent for optionals, and a missing key must not // is used below, and a missing key must not trip the "unreadable cache"
// trip the "unreadable cache" recovery path that discards everything. // recovery path that discards everything.
// Vouchee fingerprint -> accepted vouches (capped per vouchee) // Vouchee fingerprint -> accepted vouches (capped per vouchee)
var vouchesByVouchee: [String: [VouchRecord]]? = nil var vouchesByVouchee: [String: [VouchRecord]]? = nil
@@ -194,8 +190,49 @@ struct IdentityCache: Codable {
// 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
// Fingerprint -> Cryptographic identity (noise + pinned signing key).
// Persisting the signing-key pin is security-critical: it must survive
// app restarts so an attacker cannot replay a known peer's
// noiseKey/peerID with their own signing key and be treated as first
// contact (TOFU downgrade).
var cryptographicIdentities: [String: CryptographicIdentity] = [:]
// Schema version for future migrations // Schema version for future migrations
var version: Int = 1 var version: Int = 1
init() {}
// Custom decoding so caches written by older builds (missing newer keys
// such as `cryptographicIdentities` or the vouching fields) still load
// instead of being discarded. Every field uses decodeIfPresent so a
// missing key falls back to its default rather than throwing.
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
socialIdentities = try container.decodeIfPresent([String: SocialIdentity].self, forKey: .socialIdentities) ?? [:]
nicknameIndex = try container.decodeIfPresent([String: Set<String>].self, forKey: .nicknameIndex) ?? [:]
verifiedFingerprints = try container.decodeIfPresent(Set<String>.self, forKey: .verifiedFingerprints) ?? []
lastInteractions = try container.decodeIfPresent([String: Date].self, forKey: .lastInteractions) ?? [:]
blockedNostrPubkeys = try container.decodeIfPresent(Set<String>.self, forKey: .blockedNostrPubkeys) ?? []
vouchesByVouchee = try container.decodeIfPresent([String: [VouchRecord]].self, forKey: .vouchesByVouchee)
vouchBatchSentAt = try container.decodeIfPresent([String: Date].self, forKey: .vouchBatchSentAt)
verifiedAt = try container.decodeIfPresent([String: Date].self, forKey: .verifiedAt)
privateMediaCapableFingerprints = try container.decodeIfPresent(Set<String>.self, forKey: .privateMediaCapableFingerprints)
authenticatedSigningKeysByFingerprint = try container.decodeIfPresent([String: Data].self, forKey: .authenticatedSigningKeysByFingerprint)
cryptographicIdentities = try container.decodeIfPresent([String: CryptographicIdentity].self, forKey: .cryptographicIdentities) ?? [:]
version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1
}
} }
// //
+177 -63
View File
@@ -108,8 +108,6 @@ protocol SecureIdentityStateManagerProtocol {
func updateSocialIdentity(_ identity: SocialIdentity) func updateSocialIdentity(_ identity: SocialIdentity)
// MARK: Favorites Management // MARK: Favorites Management
func getFavorites() -> Set<String>
func setFavorite(_ fingerprint: String, isFavorite: Bool)
func isFavorite(fingerprint: String) -> Bool func isFavorite(fingerprint: String) -> Bool
// MARK: Blocked Users Management // MARK: Blocked Users Management
@@ -123,7 +121,6 @@ protocol SecureIdentityStateManagerProtocol {
// MARK: Ephemeral Session Management // MARK: Ephemeral Session Management
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState) func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState)
func updateHandshakeState(peerID: PeerID, state: HandshakeState)
// MARK: Cleanup // MARK: Cleanup
func clearAllIdentityData() func clearAllIdentityData()
@@ -139,11 +136,18 @@ protocol SecureIdentityStateManagerProtocol {
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool
func validVouchers(for fingerprint: String) -> [VouchRecord] func validVouchers(for fingerprint: String) -> [VouchRecord]
func isVouched(fingerprint: String) -> Bool func isVouched(fingerprint: String) -> Bool
func effectiveTrustLevel(for fingerprint: String) -> TrustLevel
func lastVouchBatchSent(to fingerprint: String) -> Date? func lastVouchBatchSent(to fingerprint: String) -> Date?
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.
@@ -156,18 +160,30 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
// In-memory state // In-memory state
private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:] private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:]
private var cryptographicIdentities: [String: CryptographicIdentity] = [:] // Cryptographic identities (including pinned signing keys) live inside
// `cache` so they persist across app restarts; see IdentityCache.
private var cache: IdentityCache = IdentityCache() private var cache: IdentityCache = IdentityCache()
// 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 //
// than a retained DispatchSourceTimer: a lingering, never-cancelled timer // Persistence is SYNCHRONOUS: every mutating API runs its mutate + encrypt
// keeps the dispatch machinery alive and prevents the unit-test process from // + keychain write inside `queue.sync(flags: .barrier)`, so when the call
// exiting. (The original code used Timer.scheduledTimer on a GCD queue with // returns the write is already complete and NOTHING is left scheduled on
// no run loop, so saves never actually fired.) // the queue. This is deliberate a retained DispatchSourceTimer (the
// original design) kept the dispatch machinery alive and prevented the
// unit-test process from exiting, and fire-and-forget `queue.async(.barrier)`
// (a later design) left a backlog of instrumented barrier saves still
// draining when LLVM's `--enable-code-coverage` `atexit` handler dumped
// `.profraw`, deadlocking the process at teardown on the constrained CI
// runner. Synchronous persistence has zero outstanding dispatch at exit, so
// neither failure mode is possible. `pendingSave` is now effectively always
// false after any mutation (saveIdentityCache persists inline and clears
// it); it remains only as a belt-and-suspenders flag read by `forceSave`
// and `deinit`.
private var pendingSave = false private var pendingSave = false
// Encryption key // Encryption key
@@ -218,6 +234,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.
@@ -227,7 +244,22 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
} }
deinit { deinit {
forceSave() // Do NOT dispatch onto `queue` here. `deinit` can run on any thread
// (including one draining `queue`), and the object is being
// deallocated: a `queue.sync` risks a re-entrant same-queue wait
// (deadlock) and a `queue.async` schedules work that resurrects `self`
// and may not drain before process exit.
//
// A flush here is redundant anyway: every mutating API already
// persists inline within its own barrier, so the keychain is already
// up to date. As a queue-free best-effort belt-and-suspenders, only
// flush if something is still pending. This is a direct read of
// in-hand state safe because a deallocating object has no other
// live references, so nothing can be mutating `cache` concurrently.
if pendingSave {
pendingSave = false
persist(snapshot: cache)
}
} }
// MARK: - Secure Loading/Saving // MARK: - Secure Loading/Saving
@@ -252,21 +284,27 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
} }
} }
/// Persists the cache. Always invoked on `queue` under a barrier (its callers /// Persists the cache. Always invoked on `queue` under a barrier (its
/// run inside `queue.async(.barrier)`), so it simply marks the cache dirty /// callers run inside `queue.sync(flags: .barrier)`), so `cache` is read
/// and persists it on the same serialized context no timer, nothing left /// while serialized. The encode + keychain write are done here (already on
/// scheduled to keep the process alive. /// the exclusive barrier context), synchronously, so no separate hop is
/// scheduled and nothing is left to keep the process alive.
private func saveIdentityCache() { private func saveIdentityCache() {
pendingSave = true pendingSave = true
performSave() // On the barrier context already: snapshot is trivially consistent.
persist(snapshot: cache)
pendingSave = false
} }
/// Writes the cache to the keychain. Must run on `queue` with exclusive /// Encodes, seals, and writes a *snapshot* of the cache to the keychain.
/// (barrier) access. ///
private func performSave() { /// Takes the cache by value so callers can capture a consistent snapshot
guard pendingSave else { return } /// under `queue` and then encode without holding it. Reading `cache`
pendingSave = false /// concurrently with a barrier writer would be a data race on the
/// dictionary storage, which because `JSONEncoder` walks that storage
/// can spin forever (observed as a CI test-suite hang), so the snapshot
/// must be taken on `queue`, never off it.
private func persist(snapshot: IdentityCache) {
// Never persist under an ephemeral key it would overwrite the real // Never persist under an ephemeral key it would overwrite the real
// cache with data the next launch cannot decrypt. // cache with data the next launch cannot decrypt.
guard !encryptionKeyIsEphemeral else { guard !encryptionKeyIsEphemeral else {
@@ -275,7 +313,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
} }
do { do {
let data = try JSONEncoder().encode(cache) let data = try JSONEncoder().encode(snapshot)
let sealedBox = try AES.GCM.seal(data, using: encryptionKey) let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey) let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey)
if saved { if saved {
@@ -286,14 +324,26 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
} }
} }
// Force immediate save (for app termination / lifecycle events). Mutations // Force a flush (for app-termination / lifecycle events NOT from
// already persist synchronously via saveIdentityCache, so this is normally a // `deinit`, which persists inline; see the deinit note). Every mutating
// no-op (performSave early-returns when nothing is pending). Runs directly on // API already persists inline inside its own barrier via
// the caller's thread deliberately NOT a `queue.sync(barrier)`, which is // `saveIdentityCache`, so by the time this is called the keychain is
// reachable from `deinit` and from async tests on the swift-concurrency // already up to date and this is normally a no-op; it exists as a
// cooperative pool where a blocking barrier-sync can starve/deadlock it. // belt-and-suspenders flush of any `pendingSave` left set.
//
// Runs synchronously inside a `queue.sync(flags: .barrier)`: the barrier
// makes the `cache` read race-free (a plain off-queue read races in-flight
// barrier writers JSONEncoder walking a concurrently-mutated dictionary
// can spin forever, which surfaced as a CI hang), and being synchronous it
// leaves nothing scheduled to keep the process alive at teardown. Safe
// against re-entrant deadlock because this is never invoked from `deinit`
// (the only path that can run *on* `queue`).
func forceSave() { func forceSave() {
performSave() queue.sync(flags: .barrier) {
guard pendingSave else { return }
pendingSave = false
persist(snapshot: cache)
}
} }
// MARK: - Social Identity Management // MARK: - Social Identity Management
@@ -307,36 +357,46 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
// MARK: - Cryptographic Identities // MARK: - Cryptographic Identities
/// Insert or update a cryptographic identity and optionally persist its signing key and claimed nickname. /// Insert or update a cryptographic identity and optionally persist its signing key and claimed nickname.
///
/// TOFU signing-key pinning: once a signing key has been persisted for a
/// fingerprint, an update carrying a *different* signing key is refused in
/// full (including the claimed-nickname update) and security-logged. This
/// mirrors `BLEPeerRegistry.upsertVerifiedAnnounce` without it, an
/// attacker replaying a victim's noiseKey/peerID with their own signing
/// key could overwrite the victim's persisted identity while the victim is
/// offline or after an app restart. The refusal is permanent: there is
/// currently no targeted in-app way to reset the pin (`setVerified` does
/// not touch it). Recovering from a legitimate signing re-key requires the
/// peer to establish a new noise identity (new peerID) or the local user
/// to wipe all identity data (`clearAllIdentityData`, e.g. panic wipe).
/// - Parameters: /// - Parameters:
/// - fingerprint: SHA-256 hex of the Noise static public key /// - fingerprint: SHA-256 hex of the Noise static public key
/// - noisePublicKey: Noise static public key data /// - noisePublicKey: Noise static public key data
/// - signingPublicKey: Optional Ed25519 signing public key for authenticating public messages /// - signingPublicKey: Optional Ed25519 signing public key for authenticating public messages
/// - claimedNickname: Optional latest claimed nickname to persist into social identity /// - claimedNickname: Optional latest claimed nickname to persist into social identity
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String? = nil) { func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String? = nil) {
queue.async(flags: .barrier) { queue.sync(flags: .barrier) {
let now = Date() let now = Date()
if var existing = self.cryptographicIdentities[fingerprint] { if var existing = self.cache.cryptographicIdentities[fingerprint] {
if let pinnedSigningKey = existing.signingPublicKey,
let announcedSigningKey = signingPublicKey,
pinnedSigningKey != announcedSigningKey {
SecureLogger.warning("🚨 Refusing to replace pinned signing key for \(fingerprint.prefix(8))… (possible impersonation attempt)", category: .security)
return
}
// Update keys if changed // Update keys if changed
if existing.publicKey != noisePublicKey { if existing.publicKey != noisePublicKey {
existing = CryptographicIdentity( existing = CryptographicIdentity(
fingerprint: fingerprint, fingerprint: fingerprint,
publicKey: noisePublicKey, publicKey: noisePublicKey,
signingPublicKey: signingPublicKey ?? existing.signingPublicKey, signingPublicKey: signingPublicKey ?? existing.signingPublicKey,
firstSeen: existing.firstSeen, firstSeen: existing.firstSeen
lastHandshake: now
) )
self.cryptographicIdentities[fingerprint] = existing self.cache.cryptographicIdentities[fingerprint] = existing
} else { } else {
// Update signing key and lastHandshake // Update signing key
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
let updated = CryptographicIdentity( self.cache.cryptographicIdentities[fingerprint] = existing
fingerprint: existing.fingerprint,
publicKey: existing.publicKey,
signingPublicKey: existing.signingPublicKey,
firstSeen: existing.firstSeen,
lastHandshake: now
)
self.cryptographicIdentities[fingerprint] = updated
} }
// Persist updated state (already assigned in branches above) // Persist updated state (already assigned in branches above)
} else { } else {
@@ -345,10 +405,9 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
fingerprint: fingerprint, fingerprint: fingerprint,
publicKey: noisePublicKey, publicKey: noisePublicKey,
signingPublicKey: signingPublicKey, signingPublicKey: signingPublicKey,
firstSeen: now, firstSeen: now
lastHandshake: now
) )
self.cryptographicIdentities[fingerprint] = entry self.cache.cryptographicIdentities[fingerprint] = entry
} }
// Optionally persist claimed nickname into social identity // Optionally persist claimed nickname into social identity
@@ -380,12 +439,72 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
queue.sync { queue.sync {
// Defensive: ensure hex and correct length // Defensive: ensure hex and correct length
guard peerID.isShort else { return [] } guard peerID.isShort else { return [] }
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) } return cache.cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
}
}
// 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.cache.cryptographicIdentities[fingerprint] {
cryptoIdentity.signingPublicKey = signingPublicKey
self.cache.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.sync(flags: .barrier) {
let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname
self.cache.socialIdentities[identity.fingerprint] = identity self.cache.socialIdentities[identity.fingerprint] = identity
@@ -421,7 +540,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
} }
func setFavorite(_ fingerprint: String, isFavorite: Bool) { func setFavorite(_ fingerprint: String, isFavorite: Bool) {
queue.async(flags: .barrier) { queue.sync(flags: .barrier) {
if var identity = self.cache.socialIdentities[fingerprint] { if var identity = self.cache.socialIdentities[fingerprint] {
identity.isFavorite = isFavorite identity.isFavorite = isFavorite
self.cache.socialIdentities[fingerprint] = identity self.cache.socialIdentities[fingerprint] = identity
@@ -459,7 +578,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func setBlocked(_ fingerprint: String, isBlocked: Bool) { func setBlocked(_ fingerprint: String, isBlocked: Bool) {
SecureLogger.info("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: .security) SecureLogger.info("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: .security)
queue.async(flags: .barrier) { queue.sync(flags: .barrier) {
if var identity = self.cache.socialIdentities[fingerprint] { if var identity = self.cache.socialIdentities[fingerprint] {
identity.isBlocked = isBlocked identity.isBlocked = isBlocked
if isBlocked { if isBlocked {
@@ -493,7 +612,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) { func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) {
let key = pubkeyHexLowercased.lowercased() let key = pubkeyHexLowercased.lowercased()
queue.async(flags: .barrier) { queue.sync(flags: .barrier) {
if isBlocked { if isBlocked {
self.cache.blockedNostrPubkeys.insert(key) self.cache.blockedNostrPubkeys.insert(key)
} else { } else {
@@ -511,16 +630,12 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) { func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
self.ephemeralSessions[peerID] = EphemeralIdentity( self.ephemeralSessions[peerID] = EphemeralIdentity(handshakeState: handshakeState)
peerID: peerID,
sessionStart: Date(),
handshakeState: handshakeState
)
} }
} }
func updateHandshakeState(peerID: PeerID, state: HandshakeState) { func updateHandshakeState(peerID: PeerID, state: HandshakeState) {
queue.async(flags: .barrier) { queue.sync(flags: .barrier) {
self.ephemeralSessions[peerID]?.handshakeState = state self.ephemeralSessions[peerID]?.handshakeState = state
// If handshake completed, update last interaction // If handshake completed, update last interaction
@@ -536,10 +651,9 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func clearAllIdentityData() { func clearAllIdentityData() {
SecureLogger.warning("Clearing all identity data", category: .security) SecureLogger.warning("Clearing all identity data", category: .security)
queue.async(flags: .barrier) { queue.sync(flags: .barrier) {
self.cache = IdentityCache() self.cache = IdentityCache()
self.ephemeralSessions.removeAll() self.ephemeralSessions.removeAll()
self.cryptographicIdentities.removeAll()
// Delete from keychain // Delete from keychain
let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey) let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey)
@@ -548,7 +662,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
} }
func removeEphemeralSession(peerID: PeerID) { func removeEphemeralSession(peerID: PeerID) {
queue.async(flags: .barrier) { queue.sync(flags: .barrier) {
self.ephemeralSessions.removeValue(forKey: peerID) self.ephemeralSessions.removeValue(forKey: peerID)
} }
} }
@@ -558,7 +672,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func setVerified(fingerprint: String, verified: Bool) { func setVerified(fingerprint: String, verified: Bool) {
SecureLogger.info("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: .security) SecureLogger.info("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: .security)
queue.async(flags: .barrier) { queue.sync(flags: .barrier) {
if verified { if verified {
self.cache.verifiedFingerprints.insert(fingerprint) self.cache.verifiedFingerprints.insert(fingerprint)
var verifiedAt = self.cache.verifiedAt ?? [:] var verifiedAt = self.cache.verifiedAt ?? [:]
@@ -726,7 +840,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
/// The peer's announce-bound Ed25519 signing key, if seen this session. /// The peer's announce-bound Ed25519 signing key, if seen this session.
func signingPublicKey(forFingerprint fingerprint: String) -> Data? { func signingPublicKey(forFingerprint fingerprint: String) -> Data? {
queue.sync { cryptographicIdentities[fingerprint]?.signingPublicKey } queue.sync { cache.cryptographicIdentities[fingerprint]?.signingPublicKey }
} }
/// Verified fingerprints ordered most recently verified first (entries /// Verified fingerprints ordered most recently verified first (entries
+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>
+11572 -3183
View File
File diff suppressed because it is too large Load Diff
@@ -13,13 +13,6 @@ extension BitchatMessage {
enum Media { enum Media {
case voice(URL) case voice(URL)
case image(URL) case image(URL)
var url: URL {
switch self {
case .voice(let url), .image(let url):
return url
}
}
} }
// Cache the directory lookup to avoid repeated FileManager calls during view rendering // Cache the directory lookup to avoid repeated FileManager calls during view rendering
+1 -3
View File
@@ -7,7 +7,6 @@ struct BitchatPeer: Equatable {
let peerID: PeerID // Hex-encoded peer ID let peerID: PeerID // Hex-encoded peer ID
let noisePublicKey: Data let noisePublicKey: Data
let nickname: String let nickname: String
let lastSeen: Date
let isConnected: Bool let isConnected: Bool
let isReachable: Bool let isReachable: Bool
@@ -77,14 +76,13 @@ struct BitchatPeer: Equatable {
peerID: PeerID, peerID: PeerID,
noisePublicKey: Data, noisePublicKey: Data,
nickname: String, nickname: String,
lastSeen: Date = Date(), lastSeen _: Date = Date(),
isConnected: Bool = false, isConnected: Bool = false,
isReachable: Bool = false isReachable: Bool = false
) { ) {
self.peerID = peerID self.peerID = peerID
self.noisePublicKey = noisePublicKey self.noisePublicKey = noisePublicKey
self.nickname = nickname self.nickname = nickname
self.lastSeen = lastSeen
self.isConnected = isConnected self.isConnected = isConnected
self.isReachable = isReachable self.isReachable = isReachable
+5 -1
View File
@@ -28,6 +28,7 @@ enum CommandInfo: String, Identifiable {
case unfavorite = "unfav" case unfavorite = "unfav"
case ping case ping
case trace case trace
case drop
var id: String { rawValue } var id: String { rawValue }
@@ -41,6 +42,8 @@ enum CommandInfo: String, Identifiable {
return "<" + String(localized: "content.input.group_placeholder") + ">" return "<" + String(localized: "content.input.group_placeholder") + ">"
case .pay: case .pay:
return "<" + String(localized: "content.input.token_placeholder") + ">" return "<" + String(localized: "content.input.token_placeholder") + ">"
case .drop:
return "<" + String(localized: "content.input.note_placeholder") + ">"
case .clear, .help, .who: case .clear, .help, .who:
return nil return nil
} }
@@ -62,11 +65,12 @@ enum CommandInfo: String, Identifiable {
case .unfavorite: String(localized: "content.commands.unfavorite") case .unfavorite: String(localized: "content.commands.unfavorite")
case .ping: String(localized: "content.commands.ping") case .ping: String(localized: "content.commands.ping")
case .trace: String(localized: "content.commands.trace") case .trace: String(localized: "content.commands.trace")
case .drop: String(localized: "content.commands.drop")
} }
} }
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] { static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
var commands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who] var commands: [CommandInfo] = [.block, .unblock, .clear, .drop, .help, .hug, .message, .slap, .who]
// Cashu tokens are bearer instruments: in a public geohash any nearby // Cashu tokens are bearer instruments: in a public geohash any nearby
// stranger can redeem one, so don't *suggest* /pay there (the // stranger can redeem one, so don't *suggest* /pay there (the
// processor still allows it behind an explicit "public" confirm). // processor still allows it behind an explicit "public" confirm).
+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
} }
+1 -1
View File
@@ -723,7 +723,7 @@ final class NoiseHandshakeState {
return messageBuffer return messageBuffer
} }
func readMessage(_ message: Data, expectedPayloadLength: Int = 0) throws -> Data { func readMessage(_ message: Data, expectedPayloadLength _: Int = 0) throws -> Data {
guard currentPattern < messagePatterns.count else { guard currentPattern < messagePatterns.count else {
throw NoiseError.handshakeComplete throw NoiseError.handshakeComplete
+46 -6
View File
@@ -6,27 +6,67 @@
// 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
// Bounds an ordinary initiator whose message 1 or 2 is lost.
static let ordinaryHandshakeTimeout: TimeInterval = 10
// Bounds the receive-only rollback quarantine created by an unauthenticated
// inbound message 1. A lost message 3 must not strand outbound traffic.
static let ordinaryResponderHandshakeTimeout: TimeInterval = 20
// A released client may immediately retry after both crossed initiators
// yielded. Give that unilateral retry a brief head start before the
// patched side spends its one bounded recovery.
static let handshakeCollisionRecoveryDelay: TimeInterval = 0.2
// Rate-limited recovery remains actionable without spinning.
static let handshakeRateLimitRecoveryDelay: TimeInterval = 60
// Covers only reordering between a winning message 3 and the losing
// crossed message 1.
static let recentInitiatorCompletionGracePeriod: TimeInterval = 1
// After unauthenticated responder rollback, reject another attempt long
// enough that paced message 1 traffic cannot keep outbound paused. A
// legitimate peer converges through the one manager-owned local retry.
static let ordinaryReconnectRollbackCooldown: TimeInterval = 60
// Session timeout - sessions older than this should be renegotiated // Session timeout - sessions older than this should be renegotiated
static let sessionTimeout: TimeInterval = 86400 // 24 hours static let sessionTimeout: TimeInterval = 86400 // 24 hours
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit) // Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
// Handshake timeout - abandon incomplete handshakes
static let handshakeTimeout: TimeInterval = 60 // 1 minute
// Maximum concurrent sessions per peer
static let maxSessionsPerPeer = 3
// Rate limiting // Rate limiting
static let maxHandshakesPerMinute = 10 static let maxHandshakesPerMinute = 10
static let maxMessagesPerSecond = 100 static let maxMessagesPerSecond = 100
-1
View File
@@ -14,5 +14,4 @@ enum NoiseSecurityError: Error {
case messageTooLarge case messageTooLarge
case invalidPeerID case invalidPeerID
case rateLimitExceeded case rateLimitExceeded
case handshakeTimeout
} }
@@ -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
+4 -1
View File
@@ -66,7 +66,10 @@ class NoiseSession {
// Only initiator writes the first message // Only initiator writes the first message
if role == .initiator { if role == .initiator {
let message = try handshakeState!.writeMessage() guard let handshake = handshakeState else {
throw NoiseSessionError.invalidState
}
let message = try handshake.writeMessage()
sentHandshakeMessages.append(message) sentHandshakeMessages.append(message)
return message return message
} else { } else {
+7
View File
@@ -11,4 +11,11 @@ enum NoiseSessionError: Error, Equatable {
case notEstablished case notEstablished
case sessionNotFound case sessionNotFound
case alreadyEstablished case alreadyEstablished
case peerIdentityMismatch
}
/// The manager owns the exact attempt's one bounded recovery. Packet handling
/// must not launch its historical second, immediate restart for this failure.
struct NoiseManagedHandshakeFailure: Error {
let underlying: Error
} }
File diff suppressed because it is too large Load Diff
+11 -4
View File
@@ -24,8 +24,12 @@ final class SecureNoiseSession: NoiseSession {
throw NoiseSecurityError.sessionExhausted 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
} }
+212 -37
View File
@@ -32,6 +32,23 @@ struct GeoRelayDirectoryDependencies {
var retrySleep: (TimeInterval) async -> Void var retrySleep: (TimeInterval) async -> Void
var activeNotificationName: Notification.Name? var activeNotificationName: Notification.Name?
var autoStart: Bool var autoStart: Bool
var validationPolicy: GeoRelayDirectoryValidationPolicy
}
struct GeoRelayDirectoryValidationPolicy: Sendable {
let maximumBytes: Int
let maximumRows: Int
let maximumEntries: Int
let minimumRemoteEntries: Int
let minimumRetainedFraction: Double
static let live = GeoRelayDirectoryValidationPolicy(
maximumBytes: 512 * 1024,
maximumRows: 5_000,
maximumEntries: 5_000,
minimumRemoteEntries: 50,
minimumRetainedFraction: 0.5
)
} }
private extension GeoRelayDirectoryDependencies { private extension GeoRelayDirectoryDependencies {
@@ -44,12 +61,16 @@ private extension GeoRelayDirectoryDependencies {
#else #else
let activeNotificationName: Notification.Name? = nil let activeNotificationName: Notification.Name? = nil
#endif #endif
let validationPolicy = GeoRelayDirectoryValidationPolicy.live
return Self( return Self(
userDefaults: .standard, userDefaults: .standard,
notificationCenter: .default, notificationCenter: .default,
now: Date.init, now: Date.init,
remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!, // Runtime refreshes only from bitchat's reviewed copy. Upstream
// georelays/main is imported by a validator-backed pull request,
// so an upstream mutation cannot immediately retarget clients.
remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/bitchat/refs/heads/main/relays/online_relays_gps.csv")!,
fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds, fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds,
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds, refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds, retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
@@ -58,7 +79,27 @@ private extension GeoRelayDirectoryDependencies {
makeFetchData: { makeFetchData: {
let session = TorURLSession.shared.session let session = TorURLSession.shared.session
return { request in return { request in
let (data, _) = try await session.data(for: request) let (bytes, response) = try await session.bytes(for: request)
guard let response = response as? HTTPURLResponse,
(200...299).contains(response.statusCode),
response.url == request.url else {
throw URLError(.badServerResponse)
}
let maximumBytes = validationPolicy.maximumBytes
guard response.expectedContentLength <= Int64(maximumBytes) else {
throw URLError(.dataLengthExceedsMaximum)
}
var data = Data()
if response.expectedContentLength > 0 {
data.reserveCapacity(Int(response.expectedContentLength))
}
for try await byte in bytes {
guard data.count < maximumBytes else {
throw URLError(.dataLengthExceedsMaximum)
}
data.append(byte)
}
return data return data
} }
}, },
@@ -76,7 +117,11 @@ private extension GeoRelayDirectoryDependencies {
) )
let dir = base.appendingPathComponent("bitchat", isDirectory: true) let dir = base.appendingPathComponent("bitchat", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir.appendingPathComponent("georelays_cache.csv") // v2 ignores caches populated from the old direct-upstream
// trust path and subjects every load to strict validation.
let legacyCache = dir.appendingPathComponent("georelays_cache.csv")
try? FileManager.default.removeItem(at: legacyCache)
return dir.appendingPathComponent("georelays_cache_v2.csv")
} catch { } catch {
return nil return nil
} }
@@ -94,7 +139,8 @@ private extension GeoRelayDirectoryDependencies {
try? await Task.sleep(nanoseconds: nanoseconds) try? await Task.sleep(nanoseconds: nanoseconds)
}, },
activeNotificationName: activeNotificationName, activeNotificationName: activeNotificationName,
autoStart: true autoStart: true,
validationPolicy: validationPolicy
) )
} }
} }
@@ -125,7 +171,7 @@ final class GeoRelayDirectory {
} }
private enum DetachedFetchOutcome: Sendable { private enum DetachedFetchOutcome: Sendable {
case success(entries: [Entry], csv: String) case success(entries: [Entry], csv: Data)
case torNotReady case torNotReady
case invalidData case invalidData
case network(String) case network(String)
@@ -212,6 +258,8 @@ final class GeoRelayDirectory {
) )
let awaitTorReady = dependencies.awaitTorReady let awaitTorReady = dependencies.awaitTorReady
let fetchData = dependencies.makeFetchData() let fetchData = dependencies.makeFetchData()
let validationPolicy = dependencies.validationPolicy
let baselineEntries = Set(entries)
Task { [weak self] in Task { [weak self] in
guard let self else { return } guard let self else { return }
@@ -219,7 +267,9 @@ final class GeoRelayDirectory {
let outcome = await Self.fetchRemoteOutcome( let outcome = await Self.fetchRemoteOutcome(
request: request, request: request,
awaitTorReady: awaitTorReady, awaitTorReady: awaitTorReady,
fetchData: fetchData fetchData: fetchData,
validationPolicy: validationPolicy,
baselineEntries: baselineEntries
) )
switch outcome { switch outcome {
@@ -238,7 +288,9 @@ final class GeoRelayDirectory {
nonisolated private static func fetchRemoteOutcome( nonisolated private static func fetchRemoteOutcome(
request: URLRequest, request: URLRequest,
awaitTorReady: @escaping @Sendable () async -> Bool, awaitTorReady: @escaping @Sendable () async -> Bool,
fetchData: @escaping @Sendable (URLRequest) async throws -> Data fetchData: @escaping @Sendable (URLRequest) async throws -> Data,
validationPolicy: GeoRelayDirectoryValidationPolicy,
baselineEntries: Set<Entry>
) async -> DetachedFetchOutcome { ) async -> DetachedFetchOutcome {
await Task.detached(priority: .utility) { await Task.detached(priority: .utility) {
let ready = await awaitTorReady() let ready = await awaitTorReady()
@@ -246,16 +298,16 @@ final class GeoRelayDirectory {
do { do {
let data = try await fetchData(request) let data = try await fetchData(request)
guard let text = String(data: data, encoding: .utf8) else { guard let parsed = Self.validatedEntries(
from: data,
policy: validationPolicy,
minimumEntries: validationPolicy.minimumRemoteEntries,
baselineEntries: baselineEntries
) else {
return .invalidData return .invalidData
} }
let parsed = Self.parseCSV(text) return .success(entries: parsed, csv: data)
guard !parsed.isEmpty else {
return .invalidData
}
return .success(entries: parsed, csv: text)
} catch { } catch {
return .network(error.localizedDescription) return .network(error.localizedDescription)
} }
@@ -269,7 +321,7 @@ final class GeoRelayDirectory {
} }
@MainActor @MainActor
private func handleFetchSuccess(entries parsed: [Entry], csv: String) { private func handleFetchSuccess(entries parsed: [Entry], csv: Data) {
entries = parsed entries = parsed
persistCache(csv) persistCache(csv)
dependencies.userDefaults.set(dependencies.now(), forKey: lastFetchKey) dependencies.userDefaults.set(dependencies.now(), forKey: lastFetchKey)
@@ -321,9 +373,8 @@ final class GeoRelayDirectory {
cleanupState.retryTask = nil cleanupState.retryTask = nil
} }
private func persistCache(_ text: String) { private func persistCache(_ data: Data) {
guard let url = dependencies.cacheURL() else { return } guard let url = dependencies.cacheURL() else { return }
guard let data = text.data(using: .utf8) else { return }
do { do {
try dependencies.writeData(data, url) try dependencies.writeData(data, url)
} catch { } catch {
@@ -336,9 +387,12 @@ final class GeoRelayDirectory {
// Prefer cached file if present // Prefer cached file if present
if let cache = dependencies.cacheURL(), if let cache = dependencies.cacheURL(),
let data = dependencies.readData(cache), let data = dependencies.readData(cache),
let text = String(data: data, encoding: .utf8) { let entries = Self.validatedEntries(
let arr = Self.parseCSV(text) from: data,
if !arr.isEmpty { return arr } policy: dependencies.validationPolicy,
minimumEntries: 1
) {
return entries
} }
// Try bundled resource(s) // Try bundled resource(s)
@@ -346,36 +400,157 @@ final class GeoRelayDirectory {
for url in bundleCandidates { for url in bundleCandidates {
if let data = dependencies.readData(url), if let data = dependencies.readData(url),
let text = String(data: data, encoding: .utf8) { let entries = Self.validatedEntries(
let arr = Self.parseCSV(text) from: data,
if !arr.isEmpty { return arr } policy: dependencies.validationPolicy,
minimumEntries: 1
) {
return entries
} }
} }
// Try filesystem path (development/test) // Try filesystem path (development/test)
if let cwd = dependencies.currentDirectoryPath(), if let cwd = dependencies.currentDirectoryPath(),
let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")), let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
let text = String(data: data, encoding: .utf8) { let entries = Self.validatedEntries(
return Self.parseCSV(text) from: data,
policy: dependencies.validationPolicy,
minimumEntries: 1
) {
return entries
} }
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session) SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
return [] return []
} }
nonisolated static func parseCSV(_ text: String) -> [Entry] { /// Parses the fixed three-column format as an all-or-nothing trust unit.
var result: Set<Entry> = [] /// One malformed or conflicting row rejects the complete dataset rather
let lines = text.split(whereSeparator: { $0.isNewline }) /// than silently shrinking or partially replacing the current directory.
for (idx, raw) in lines.enumerated() { nonisolated static func validatedEntries(
guard let line = raw.trimmedOrNilIfEmpty else { continue } from data: Data,
if idx == 0 && line.lowercased().contains("relay url") { continue } policy: GeoRelayDirectoryValidationPolicy,
let parts = line.split(separator: ",").map { $0.trimmed } minimumEntries: Int,
guard parts.count >= 3 else { continue } baselineEntries: Set<Entry>? = nil
guard let host = NostrRelayURL.directoryAddress(parts[0]) else { continue } ) -> [Entry]? {
guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue } guard !data.isEmpty, data.count <= policy.maximumBytes,
result.insert(Entry(host: host, lat: lat, lon: lon)) let text = String(data: data, encoding: .utf8),
!text.hasPrefix("\u{feff}") else {
return nil
} }
return Array(result)
let lines = text.split(whereSeparator: { $0.isNewline })
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
guard let header = lines.first,
lines.count - 1 <= policy.maximumRows else {
return nil
}
let headerParts = header
.split(separator: ",", omittingEmptySubsequences: false)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
let supportedHeaders = [
["relay url", "latitude", "longitude"],
["relay url", "lat", "lon"]
]
guard supportedHeaders.contains(headerParts) else {
return nil
}
var entriesByHost: [String: Entry] = [:]
for line in lines.dropFirst() {
let parts = line
.split(separator: ",", omittingEmptySubsequences: false)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
guard parts.count == 3,
let host = validatedDirectoryAddress(parts[0]),
let latitude = Double(parts[1]), latitude.isFinite,
(-90.0...90.0).contains(latitude),
let longitude = Double(parts[2]), longitude.isFinite,
(-180.0...180.0).contains(longitude) else {
return nil
}
let entry = Entry(host: host, lat: latitude, lon: longitude)
if let existing = entriesByHost[host], existing != entry {
// One endpoint cannot truthfully occupy two coordinates. Do
// not let row ordering choose which location clients trust.
return nil
}
entriesByHost[host] = entry
guard entriesByHost.count <= policy.maximumEntries else { return nil }
}
let parsedEntries = Set(entriesByHost.values)
guard parsedEntries.count >= minimumEntries else { return nil }
if let baselineEntries {
guard (0...1).contains(policy.minimumRetainedFraction) else { return nil }
let requiredOverlap = Int(
ceil(Double(baselineEntries.count) * policy.minimumRetainedFraction)
)
guard parsedEntries.intersection(baselineEntries).count >= requiredOverlap else {
return nil
}
}
return parsedEntries.sorted {
($0.host, $0.lat, $0.lon) < ($1.host, $1.lat, $1.lon)
}
}
nonisolated private static func validatedDirectoryAddress(_ rawValue: String) -> String? {
let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty,
value.unicodeScalars.allSatisfy({
$0.isASCII && !CharacterSet.controlCharacters.contains($0)
}) else {
return nil
}
let candidate = value.contains("://") ? value : "wss://\(value)"
guard let components = URLComponents(string: candidate),
let scheme = components.scheme?.lowercased(),
scheme == "wss" || scheme == "https",
components.user == nil,
components.password == nil,
components.query == nil,
components.fragment == nil,
components.path.isEmpty || components.path == "/",
let rawHost = components.host else {
return nil
}
let host = rawHost.lowercased()
guard !host.isEmpty, host.count <= 253,
host.unicodeScalars.allSatisfy({ $0.isASCII }),
!host.hasSuffix("."),
host != "localhost",
!host.hasSuffix(".localhost"),
!host.hasSuffix(".local"),
!host.hasSuffix(".internal") else {
return nil
}
let labels = host.split(separator: ".", omittingEmptySubsequences: false)
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789-")
guard labels.count >= 2,
!labels.allSatisfy({ $0.allSatisfy(\.isNumber) }),
labels.allSatisfy({ label in
(1...63).contains(label.count) &&
label.first != "-" &&
label.last != "-" &&
label.unicodeScalars.allSatisfy { allowed.contains($0) }
}) else {
return nil
}
if let port = components.port {
guard (1...65_535).contains(port) else { return nil }
if port != 443 { return "\(host):\(port)" }
}
return host
} }
// MARK: - Observers & Timers // MARK: - Observers & Timers
+3 -10
View File
@@ -1,19 +1,18 @@
import Foundation import Foundation
import P256K import P256K
/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging /// Manages the secp256k1 identity used by BitChat's Nostr relay features,
/// including the proprietary private-envelope transport.
struct NostrIdentity: Codable { struct NostrIdentity: Codable {
let privateKey: Data let privateKey: Data
let publicKey: Data let publicKey: Data
let npub: String // Bech32-encoded public key let npub: String // Bech32-encoded public key
let createdAt: Date
/// Memberwise initializer /// Memberwise initializer
init(privateKey: Data, publicKey: Data, npub: String, createdAt: Date) { init(privateKey: Data, publicKey: Data, npub: String, createdAt _: Date) {
self.privateKey = privateKey self.privateKey = privateKey
self.publicKey = publicKey self.publicKey = publicKey
self.npub = npub self.npub = npub
self.createdAt = createdAt
} }
/// Generate a new Nostr identity /// Generate a new Nostr identity
@@ -39,12 +38,6 @@ struct NostrIdentity: Codable {
self.privateKey = privateKeyData self.privateKey = privateKeyData
self.publicKey = xOnlyPubkey self.publicKey = xOnlyPubkey
self.npub = try Bech32.encode(hrp: "npub", data: xOnlyPubkey) self.npub = try Bech32.encode(hrp: "npub", data: xOnlyPubkey)
self.createdAt = Date()
}
/// Get signing key for event signatures
func signingKey() throws -> P256K.Signing.PrivateKey {
try P256K.Signing.PrivateKey(dataRepresentation: privateKey)
} }
/// Get Schnorr signing key for Nostr event signatures /// Get Schnorr signing key for Nostr event signatures
+12 -32
View File
@@ -15,7 +15,7 @@ final class NostrIdentityBridge {
private let keychain: KeychainManagerProtocol private let keychain: KeychainManagerProtocol
init(keychain: KeychainManagerProtocol = KeychainManager()) { init(keychain: KeychainManagerProtocol = KeychainManager.makeDefault()) {
self.keychain = keychain self.keychain = keychain
} }
@@ -37,14 +37,6 @@ final class NostrIdentityBridge {
return nostrIdentity return nostrIdentity
} }
/// Associate a Nostr identity with a Noise public key (for favorites)
func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
if let data = nostrPubkey.data(using: .utf8) {
keychain.save(key: key, data: data, service: keychainService, accessible: nil)
}
}
/// Get Nostr public key associated with a Noise public key /// Get Nostr public key associated with a Noise public key
func getNostrPublicKey(for noisePublicKey: Data) -> String? { func getNostrPublicKey(for noisePublicKey: Data) -> String? {
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())" let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
@@ -57,29 +49,10 @@ final class NostrIdentityBridge {
/// Clear all Nostr identity associations and current identity /// Clear all Nostr identity associations and current identity
func clearAllAssociations() { func clearAllAssociations() {
let query: [String: Any] = [ // Must go through the injected keychain, not raw SecItem calls:
kSecClass as String: kSecClassGenericPassword, // under test that keychain is in-memory, and a direct delete here
kSecAttrService as String: keychainService, // would wipe the developer's real Nostr identity on every test run.
kSecMatchLimit as String: kSecMatchLimitAll, keychain.deleteAll(service: keychainService)
kSecReturnAttributes as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess, let items = result as? [[String: Any]] {
for item in items {
var deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService
]
if let account = item[kSecAttrAccount as String] as? String {
deleteQuery[kSecAttrAccount as String] = account
}
SecItemDelete(deleteQuery as CFDictionary)
}
} else if status == errSecItemNotFound {
// nothing persisted; no action needed
}
deviceSeedCache = nil deviceSeedCache = nil
// Also drop the in-memory derived per-geohash identities. These hold the // Also drop the in-memory derived per-geohash identities. These hold the
@@ -113,6 +86,13 @@ final class NostrIdentityBridge {
return seed return seed
} }
/// Derive a deterministic, unlinkable Nostr identity for a mesh-bridge
/// rendezvous cell. Distinct HMAC label keeps it unlinkable from the
/// geohash-chat identity for the same cell string.
func deriveIdentity(forBridgeRendezvous cell: String) throws -> NostrIdentity {
try deriveIdentity(forGeohash: "bridge|" + cell)
}
/// Derive a deterministic, unlinkable Nostr identity for a given geohash. /// Derive a deterministic, unlinkable Nostr identity for a given geohash.
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing /// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
/// if the candidate is not a valid secp256k1 private key. /// if the candidate is not a valid secp256k1 private key.
+269 -82
View File
@@ -7,43 +7,74 @@ import Security
// Note: This file depends on Data extension from BinaryEncodingUtils.swift // Note: This file depends on Data extension from BinaryEncodingUtils.swift
// Make sure BinaryEncodingUtils.swift is included in the target // Make sure BinaryEncodingUtils.swift is included in the target
/// NIP-17 Protocol Implementation for Private Direct Messages /// BitChat's private-envelope protocol transported over Nostr relays.
///
/// This construction is deliberately BitChat-specific and is **not** NIP-17,
/// NIP-44, or NIP-59 compatible, even though it historically reuses those
/// NIPs' kind numbers (1059/13/14) and a `v2:` content prefix. It uses Nostr
/// events and secp256k1 identities, but the XChaCha20-Poly1305 payload layout
/// and key derivation are proprietary and interoperate only with BitChat
/// clients.
struct NostrProtocol { struct NostrProtocol {
/// Nostr event kinds /// Nostr event kinds
enum EventKind: Int { enum EventKind: Int {
case metadata = 0 case metadata = 0
case textNote = 1 case textNote = 1
case dm = 14 // NIP-17 DM rumor kind // BitChat's proprietary private-envelope layers. These reuse the
case seal = 13 // NIP-17 sealed event // NIP-17/NIP-59 kind numbers (14/13/1059) for historical reasons, but
case giftWrap = 1059 // NIP-59 gift wrap // the encrypted payloads are BitChat-specific and not NIP-compatible.
case dm = 14 // unsigned inner message (inside ciphertext)
case seal = 13 // sender-signed seal (inside ciphertext)
case giftWrap = 1059 // public outer envelope (one-time key)
case ephemeralEvent = 20000 case ephemeralEvent = 20000
case geohashPresence = 20001 case geohashPresence = 20001
case deletion = 5 // NIP-09 event deletion request case deletion = 5 // NIP-09 event deletion request
/// Sealed courier envelope parked on relays under its rotating
/// recipient tag (`#x`). Regular (stored) kind so it survives until
/// its NIP-40 expiration the whole point is store-and-forward.
case courierDrop = 1401
} }
/// Create a NIP-17 private message /// Bound work before Base64-decoding either encrypted layer of an inbound
/// private envelope, and before parsing each decrypted nested JSON layer.
/// Real envelopes are normally a few KiB; 64 KiB leaves ample headroom
/// without letting an addressed relay event drive unbounded allocation.
static let maximumPrivateEnvelopeCiphertextBytes = 64 * 1024
/// Create a BitChat private envelope for relay transport (outer kind 1059).
static func createPrivateMessage( static func createPrivateMessage(
content: String, content: String,
recipientPubkey: String, recipientPubkey: String,
senderIdentity: NostrIdentity senderIdentity: NostrIdentity
) throws -> NostrEvent { ) throws -> NostrEvent {
try createPrivateMessage(
content: content,
recipientPubkey: recipientPubkey,
senderIdentity: senderIdentity,
messageTags: []
)
}
// Creating private message private static func createPrivateMessage(
content: String,
// 1. Create the rumor (unsigned event) recipientPubkey: String,
senderIdentity: NostrIdentity,
messageTags: [[String]]
) throws -> NostrEvent {
// 1. Create the rumor (unsigned inner event)
let rumor = NostrEvent( let rumor = NostrEvent(
pubkey: senderIdentity.publicKeyHex, pubkey: senderIdentity.publicKeyHex,
createdAt: Date(), createdAt: Date(),
kind: .dm, // NIP-17: DM rumor kind 14 kind: .dm,
tags: [], tags: messageTags,
content: content content: content
) )
// 2. Seal the rumor (encrypt to recipient) and sign it with the SENDER'S // 2. Seal the rumor (encrypt to recipient) and sign it with the SENDER'S
// real identity key. NIP-17 requires the seal be signed by the sender // real identity key so the recipient can authenticate who sent the
// so the recipient can authenticate who sent the message; signing with // message; signing with a throwaway key leaves DMs
// a throwaway key leaves DMs forgeable/impersonatable. // forgeable/impersonatable.
let senderKey = try senderIdentity.schnorrSigningKey() let senderKey = try senderIdentity.schnorrSigningKey()
let sealedEvent = try createSeal( let sealedEvent = try createSeal(
rumor: rumor, rumor: rumor,
@@ -51,7 +82,7 @@ struct NostrProtocol {
senderKey: senderKey senderKey: senderKey
) )
// 3. Gift wrap the sealed event with a throwaway ephemeral key (the wrap // 3. Wrap the sealed event with a throwaway ephemeral key (the wrap
// layer hides the sender's identity from relays; createGiftWrap mints // layer hides the sender's identity from relays; createGiftWrap mints
// its own ephemeral key internally). // its own ephemeral key internally).
let giftWrap = try createGiftWrap( let giftWrap = try createGiftWrap(
@@ -59,19 +90,30 @@ struct NostrProtocol {
recipientPubkey: recipientPubkey recipientPubkey: recipientPubkey
) )
// Created gift wrap
return giftWrap return giftWrap
} }
/// Decrypt a received NIP-17 message /// Decrypt a received BitChat private envelope.
/// Returns the content, sender pubkey, and the actual message timestamp (not the randomized gift wrap timestamp) /// Returns the content, sender pubkey, and the actual message timestamp (not the randomized outer timestamp)
static func decryptPrivateMessage( static func decryptPrivateMessage(
giftWrap: NostrEvent, giftWrap: NostrEvent,
recipientIdentity: NostrIdentity recipientIdentity: NostrIdentity
) throws -> (content: String, senderPubkey: String, timestamp: Int) { ) throws -> (content: String, senderPubkey: String, timestamp: Int) {
// Starting decryption // 0. Validate the untrusted outer envelope before any decryption work.
// Every BitChat client (released iOS and current Android) publishes
// exactly one outer recipient `p` tag on a validly signed kind-1059
// wrap; anything else is malformed or misbound.
guard giftWrap.content.utf8.count <= maximumPrivateEnvelopeCiphertextBytes else {
SecureLogger.error("❌ Rejecting DM: oversized outer envelope ciphertext", category: .session)
throw NostrError.invalidCiphertext
}
guard giftWrap.kind == EventKind.giftWrap.rawValue,
giftWrap.tags == [["p", recipientIdentity.publicKeyHex]],
giftWrap.isValidSignature() else {
SecureLogger.error("❌ Rejecting DM: malformed or misbound outer envelope", category: .session)
throw NostrError.invalidEvent
}
// 1. Unwrap the gift wrap // 1. Unwrap the gift wrap
let seal: NostrEvent let seal: NostrEvent
@@ -87,10 +129,13 @@ struct NostrProtocol {
} }
// 2. Authenticate the seal. The seal MUST be signed by the sender's real // 2. Authenticate the seal. The seal MUST be signed by the sender's real
// identity key (NIP-17); without this check a DM is forgeable by anyone // identity key; without this check a DM is forgeable by anyone who
// who knows the recipient's npub. Verify the seal's own signature. // knows the recipient's npub. Every BitChat sender emits a tagless
guard seal.isValidSignature() else { // kind-13 seal, so bind the decrypted layer to that exact shape.
SecureLogger.error("❌ Rejecting DM: seal signature is missing or invalid", category: .session) guard seal.kind == EventKind.seal.rawValue,
seal.tags.isEmpty,
seal.isValidSignature() else {
SecureLogger.error("❌ Rejecting DM: seal is malformed or its signature is missing/invalid", category: .session)
throw NostrError.invalidEvent throw NostrError.invalidEvent
} }
@@ -107,11 +152,16 @@ struct NostrProtocol {
throw error throw error
} }
// 4. The sender claimed inside the rumor must match the key that actually // 4. The rumor is intentionally unsigned; sender authentication comes
// signed the seal, otherwise the sender field is unauthenticated and // from the seal. The sender claimed inside the rumor must match the
// spoofable. // key that actually signed the seal, otherwise the sender field is
guard seal.pubkey == rumor.pubkey else { // unauthenticated and spoofable. Also bind the inner kind and tag
SecureLogger.error("❌ Rejecting DM: rumor pubkey does not match seal signer", category: .session) // shape to what BitChat clients actually emit.
guard rumor.kind == EventKind.dm.rawValue,
validInnerMessageTags(rumor.tags, recipientPubkey: recipientIdentity.publicKeyHex),
rumor.sig == nil,
seal.pubkey == rumor.pubkey else {
SecureLogger.error("❌ Rejecting DM: rumor is malformed or does not match seal signer", category: .session)
throw NostrError.invalidEvent throw NostrError.invalidEvent
} }
@@ -119,6 +169,17 @@ struct NostrProtocol {
return (content: rumor.content, senderPubkey: seal.pubkey, timestamp: rumor.created_at) return (content: rumor.content, senderPubkey: seal.pubkey, timestamp: rumor.created_at)
} }
/// Released iOS envelopes use no inner tags, while current Android
/// envelopes place exactly the authenticated recipient's `p` tag on the
/// unsigned inner event. Accept only those two historical shapes;
/// alternate recipients, duplicate tags, and extra tags are rejected.
private static func validInnerMessageTags(
_ tags: [[String]],
recipientPubkey: String
) -> Bool {
tags.isEmpty || tags == [["p", recipientPubkey]]
}
#if DEBUG #if DEBUG
static func createPrivateMessageWithInvalidSealSignatureForTesting( static func createPrivateMessageWithInvalidSealSignatureForTesting(
content: String, content: String,
@@ -161,6 +222,23 @@ struct NostrProtocol {
) )
return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey) return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey)
} }
/// Reproduces historical wire shapes (current Android places exactly one
/// recipient `p` tag on the unsigned inner event) without making the
/// production encoder depend on that quirk.
static func createPrivateMessageWithInnerTagsForTesting(
content: String,
recipientPubkey: String,
senderIdentity: NostrIdentity,
innerMessageTags: [[String]]
) throws -> NostrEvent {
try createPrivateMessage(
content: content,
recipientPubkey: recipientPubkey,
senderIdentity: senderIdentity,
messageTags: innerMessageTags
)
}
#endif #endif
/// Create a geohash-scoped ephemeral public message (kind 20000) /// Create a geohash-scoped ephemeral public message (kind 20000)
@@ -256,6 +334,94 @@ struct NostrProtocol {
return try event.sign(with: schnorrKey) return try event.sign(with: schnorrKey)
} }
// MARK: - Mesh bridge (rendezvous) events
/// Create a mesh-bridge public message (kind 20000) for a geohash-cell
/// rendezvous. The distinct `r` tag keeps bridge traffic out of geohash
/// channel subscriptions (which filter on `#g`); `m` is
/// `[stable ID, mesh sender ID, wire timestamp in ms]`. Element 1 is the
/// content-stable mesh message ID (`MeshMessageIdentity`) for v1.7.0
/// parsers, which key their dedup on `m[1]` unconditionally and need it
/// per-message-unique. Current parsers key bridge rows by the authenticated
/// event ID and recompute elements 2-3 only as a radio-copy hint; the mesh
/// coordinates are public and cannot authenticate the Nostr signer.
static func createBridgeMeshEvent(
content: String,
cell: String,
senderIdentity: NostrIdentity,
nickname: String? = nil,
meshSenderID: String? = nil,
meshTimestampMs: UInt64? = nil
) throws -> NostrEvent {
var tags = [["r", cell]]
if let nickname = nickname?.trimmedOrNilIfEmpty {
tags.append(["n", nickname])
}
if let meshSenderID = meshSenderID?.trimmedOrNilIfEmpty, let meshTimestampMs {
let stableID = MeshMessageIdentity.stableID(
senderIDHex: meshSenderID,
timestampMs: meshTimestampMs,
content: content
)
tags.append(["m", stableID, meshSenderID, String(meshTimestampMs)])
}
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .ephemeralEvent,
tags: tags,
content: content
)
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
}
/// Create a mesh-bridge presence heartbeat (kind 20001) on a rendezvous
/// cell: empty content, `r` tag only the bridge analogue of geohash
/// presence, counted into "people across the bridge".
static func createBridgePresenceEvent(
cell: String,
senderIdentity: NostrIdentity
) throws -> NostrEvent {
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .geohashPresence,
tags: [["r", cell]],
content: ""
)
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
}
/// Create a courier drop (kind 1401): an opaque sealed courier envelope
/// parked on relays. `x` is the hex recipient tag the recipient (or a
/// gateway acting for them) subscribes for; the NIP-40 expiration tracks
/// the envelope expiry so honoring relays garbage-collect the drop. The
/// signing identity should be a throwaway the envelope authenticates
/// its sender internally via Noise-X, and linking drops to a stable
/// publisher key would leak courier traffic patterns.
static func createCourierDropEvent(
envelope: Data,
recipientTagHex: String,
expiresAt: Date,
senderIdentity: NostrIdentity
) throws -> NostrEvent {
let tags = [
["x", recipientTagHex],
["expiration", String(Int(expiresAt.timeIntervalSince1970))]
]
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .courierDrop,
tags: tags,
content: envelope.base64EncodedString()
)
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
}
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash. /// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
/// An optional `expiresAt` adds a NIP-40 expiration tag so honoring relays /// An optional `expiresAt` adds a NIP-40 expiration tag so honoring relays
/// drop the note in step with a bridged board post's expiry. /// drop the note in step with a bridged board post's expiry.
@@ -264,7 +430,8 @@ struct NostrProtocol {
geohash: String, geohash: String,
senderIdentity: NostrIdentity, senderIdentity: NostrIdentity,
nickname: String? = nil, nickname: String? = nil,
expiresAt: Date? = nil expiresAt: Date? = nil,
urgent: Bool = false
) throws -> NostrEvent { ) throws -> NostrEvent {
var tags = [["g", geohash]] var tags = [["g", geohash]]
if let nickname = nickname?.trimmedOrNilIfEmpty { if let nickname = nickname?.trimmedOrNilIfEmpty {
@@ -273,6 +440,9 @@ struct NostrProtocol {
if let expiresAt { if let expiresAt {
tags.append(["expiration", String(Int(expiresAt.timeIntervalSince1970))]) tags.append(["expiration", String(Int(expiresAt.timeIntervalSince1970))])
} }
if urgent {
tags.append(["t", "urgent"])
}
let event = NostrEvent( let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex, pubkey: senderIdentity.publicKeyHex,
createdAt: Date(), createdAt: Date(),
@@ -372,6 +542,11 @@ struct NostrProtocol {
recipientKey: recipientKey recipientKey: recipientKey
) )
// Check UTF-8 size before allocating Data or invoking the general
// JSON parser on attacker-influenced plaintext.
guard decrypted.utf8.count <= maximumPrivateEnvelopeCiphertextBytes else {
throw NostrError.invalidCiphertext
}
guard let data = decrypted.data(using: .utf8), guard let data = decrypted.data(using: .utf8),
let sealDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { let sealDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw NostrError.invalidEvent throw NostrError.invalidEvent
@@ -394,6 +569,9 @@ struct NostrProtocol {
recipientKey: recipientKey recipientKey: recipientKey
) )
guard decrypted.utf8.count <= maximumPrivateEnvelopeCiphertextBytes else {
throw NostrError.invalidCiphertext
}
guard let data = decrypted.data(using: .utf8), guard let data = decrypted.data(using: .utf8),
let rumorDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { let rumorDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw NostrError.invalidEvent throw NostrError.invalidEvent
@@ -402,7 +580,11 @@ struct NostrProtocol {
return try NostrEvent(from: rumorDict) return try NostrEvent(from: rumorDict)
} }
// MARK: - Encryption (NIP-44 v2) // MARK: - BitChat private-envelope encryption
//
// Not NIP-44: the `v2:` prefix, base64url(nonce24 || ciphertext || tag)
// layout, XChaCha20-Poly1305 cipher, and HKDF parameters are all
// BitChat-specific.
private static func encrypt( private static func encrypt(
plaintext: String, plaintext: String,
@@ -414,21 +596,24 @@ struct NostrProtocol {
throw NostrError.invalidPublicKey throw NostrError.invalidPublicKey
} }
// Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned)
// Derive shared secret // Derive shared secret
let sharedSecret = try deriveSharedSecret( let sharedSecret = try deriveSharedSecret(
privateKey: senderKey, privateKey: senderKey,
publicKey: recipientPubkeyData publicKey: recipientPubkeyData
) )
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info) // Derive the BitChat private-envelope symmetric key (HKDF-SHA256)
let key = try deriveNIP44V2Key(from: sharedSecret) let key = try derivePrivateEnvelopeKey(from: sharedSecret)
// 24-byte random nonce for XChaCha20-Poly1305 // 24-byte random nonce for XChaCha20-Poly1305
var nonce24 = Data(count: 24) var nonce24 = Data(count: 24)
_ = nonce24.withUnsafeMutableBytes { ptr in let randomStatus = nonce24.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!) SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
} }
// Never encrypt with an unrandomized nonce: nonce reuse under the same
// key breaks XChaCha20-Poly1305 confidentiality and authenticity.
guard randomStatus == errSecSuccess else {
throw NostrError.cryptographicFailure
}
let pt = Data(plaintext.utf8) let pt = Data(plaintext.utf8)
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24) let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24)
@@ -446,8 +631,12 @@ struct NostrProtocol {
senderPubkey: String, senderPubkey: String,
recipientKey: P256K.Schnorr.PrivateKey recipientKey: P256K.Schnorr.PrivateKey
) throws -> String { ) throws -> String {
// Expect NIP-44 v2 format // Expect BitChat's historical `v2:` private-envelope framing, and
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext } // bound work before Base64 decoding attacker-sized input.
guard ciphertext.utf8.count <= maximumPrivateEnvelopeCiphertextBytes,
ciphertext.hasPrefix("v2:") else {
throw NostrError.invalidCiphertext
}
let encoded = String(ciphertext.dropFirst(3)) let encoded = String(ciphertext.dropFirst(3))
guard let data = Base64URLCoding.decode(encoded), guard let data = Base64URLCoding.decode(encoded),
data.count > (24 + 16), data.count > (24 + 16),
@@ -463,7 +652,7 @@ struct NostrProtocol {
// Try decryption with even-Y then odd-Y when sender pubkey is x-only // Try decryption with even-Y then odd-Y when sender pubkey is x-only
func attemptDecrypt(using pubKeyData: Data) throws -> Data { func attemptDecrypt(using pubKeyData: Data) throws -> Data {
let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData) let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData)
let key = try deriveNIP44V2Key(from: ss) let key = try derivePrivateEnvelopeKey(from: ss)
return try XChaCha20Poly1305Compat.open( return try XChaCha20Poly1305Compat.open(
ciphertext: Data(ct), ciphertext: Data(ct),
tag: Data(tag), tag: Data(tag),
@@ -473,18 +662,25 @@ struct NostrProtocol {
} }
// If 32 bytes (x-only) try both parities, otherwise single try // If 32 bytes (x-only) try both parities, otherwise single try
let plaintext: Data
if senderPubkeyData.count == 32 { if senderPubkeyData.count == 32 {
let even = Data([0x02]) + senderPubkeyData let even = Data([0x02]) + senderPubkeyData
if let pt = try? attemptDecrypt(using: even) { if let pt = try? attemptDecrypt(using: even) {
return String(data: pt, encoding: .utf8) ?? "" plaintext = pt
}
let odd = Data([0x03]) + senderPubkeyData
let pt = try attemptDecrypt(using: odd)
return String(data: pt, encoding: .utf8) ?? ""
} else { } else {
let pt = try attemptDecrypt(using: senderPubkeyData) let odd = Data([0x03]) + senderPubkeyData
return String(data: pt, encoding: .utf8) ?? "" plaintext = try attemptDecrypt(using: odd)
} }
} else {
plaintext = try attemptDecrypt(using: senderPubkeyData)
}
// Authenticated plaintext that is not valid UTF-8 is a malformed
// envelope, not an empty message.
guard let decoded = String(data: plaintext, encoding: .utf8) else {
throw NostrError.invalidCiphertext
}
return decoded
} }
private static func deriveSharedSecret( private static func deriveSharedSecret(
@@ -544,38 +740,8 @@ struct NostrProtocol {
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) } let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
// ECDH shared secret derived // ECDH shared secret derived
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key // Return raw ECDH shared secret; HKDF is applied by
return sharedSecretData // derivePrivateEnvelopeKey
}
// Direct version that doesn't try to add prefixes
private static func deriveSharedSecretDirect(
privateKey: P256K.Schnorr.PrivateKey,
publicKey: Data
) throws -> Data {
// Direct shared secret calculation
// Convert Schnorr private key to KeyAgreement private key
let keyAgreementPrivateKey = try P256K.KeyAgreement.PrivateKey(
dataRepresentation: privateKey.dataRepresentation
)
// Use the public key as-is (should already have prefix)
let keyAgreementPublicKey = try P256K.KeyAgreement.PublicKey(
dataRepresentation: publicKey,
format: .compressed
)
// Perform ECDH
let sharedSecret = try keyAgreementPrivateKey.sharedSecretFromKeyAgreement(
with: keyAgreementPublicKey,
format: .compressed
)
// Convert SharedSecret to Data
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
return sharedSecretData return sharedSecretData
} }
@@ -636,6 +802,10 @@ struct NostrEvent: Codable {
throw NostrError.invalidEvent throw NostrError.invalidEvent
} }
guard Self.isWithinInboundTagLimits(tags) else {
throw NostrError.invalidEvent
}
self.id = dict["id"] as? String ?? "" self.id = dict["id"] as? String ?? ""
self.pubkey = pubkey self.pubkey = pubkey
self.created_at = createdAt self.created_at = createdAt
@@ -645,6 +815,21 @@ struct NostrEvent: Codable {
self.sig = dict["sig"] as? String self.sig = dict["sig"] as? String
} }
/// Bounds untrusted relay tag arrays so attackers cannot force large
/// allocations or expensive joins on the inbound hot path.
static func isWithinInboundTagLimits(_ tags: [[String]]) -> Bool {
guard tags.count <= TransportConfig.nostrMaxEventTags else { return false }
for tag in tags {
guard tag.count <= TransportConfig.nostrMaxEventTagValues else { return false }
guard tag.allSatisfy({ $0.utf8.count <= TransportConfig.nostrMaxEventTagValueBytes }) else {
return false
}
}
return true
}
func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent { func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent {
let (eventId, eventIdHash) = try calculateEventId() let (eventId, eventIdHash) = try calculateEventId()
@@ -708,17 +893,19 @@ struct NostrEvent: Codable {
enum NostrError: Error { enum NostrError: Error {
case invalidPublicKey case invalidPublicKey
case invalidPrivateKey
case invalidEvent case invalidEvent
case invalidCiphertext case invalidCiphertext
case signingFailed case cryptographicFailure
case encryptionFailed
} }
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305) // MARK: - BitChat private-envelope key derivation
private extension NostrProtocol { private extension NostrProtocol {
static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data { /// The HKDF info string retains the historical "nip44-v2" label for wire
/// compatibility with deployed clients, but this is not the NIP-44 key
/// schedule: NIP-44 derives a conversation key via HKDF-extract with that
/// label as the *salt* and uses ChaCha20 with per-message expanded keys.
static func derivePrivateEnvelopeKey(from sharedSecretData: Data) throws -> Data {
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey( let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: sharedSecretData), inputKeyMaterial: SymmetricKey(data: sharedSecretData),
salt: Data(), salt: Data(),
+450 -42
View File
@@ -48,7 +48,14 @@ private struct URLSessionAdapter: NostrRelaySessionProtocol {
let base: URLSession let base: URLSession
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol { func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol {
URLSessionWebSocketTaskAdapter(base: base.webSocketTask(with: url)) let task = base.webSocketTask(with: url)
// Byte bound per inbound frame; without it the per-relay buffer cap
// (nostrInboundPerRelayBufferCap) bounds FRAMES but not BYTES, and a
// hostile relay could pile up cap × 1 MiB (URLSession default) per
// connection. See TransportConfig.nostrInboundMaxFrameBytes for the
// sizing rationale. Oversized frames fail the receive with an error.
task.maximumMessageSize = TransportConfig.nostrInboundMaxFrameBytes
return URLSessionWebSocketTaskAdapter(base: task)
} }
} }
@@ -106,7 +113,10 @@ private extension NostrRelayManagerDependencies {
@MainActor @MainActor
final class NostrRelayManager: ObservableObject { final class NostrRelayManager: ObservableObject {
static let shared = NostrRelayManager() static let shared = NostrRelayManager()
// Track gift-wraps (kind 1059) we initiated so we can log OK acks at info // Track gift-wraps (kind 1059) we initiated so we can log OK acks at info.
// Entries are removed only on OK acks (or panic wipe); relays that never
// ack leave entries behind for the process lifetime. Observability-only
// state, bounded in practice by outbound DM volume.
private(set) static var pendingGiftWrapIDs = Set<String>() private(set) static var pendingGiftWrapIDs = Set<String>()
static func registerPendingGiftWrap(id: String) { static func registerPendingGiftWrap(id: String) {
pendingGiftWrapIDs.insert(id) pendingGiftWrapIDs.insert(id)
@@ -117,7 +127,6 @@ final class NostrRelayManager: ObservableObject {
let url: String let url: String
var isConnected: Bool = false var isConnected: Bool = false
var lastError: Error? var lastError: Error?
var lastConnectedAt: Date?
var messagesSent: Int = 0 var messagesSent: Int = 0
var messagesReceived: Int = 0 var messagesReceived: Int = 0
var reconnectAttempts: Int = 0 var reconnectAttempts: Int = 0
@@ -184,11 +193,26 @@ final class NostrRelayManager: ObservableObject {
} }
private var subscriptionRequestState: [String: SubscriptionRequestState] = [:] private var subscriptionRequestState: [String: SubscriptionRequestState] = [:]
// Track EOSE per subscription to signal when initial stored events are done // Track EOSE per subscription to signal when initial stored events are
// done. Completion is scoped to relays the REQ actually reached: targets
// still mid-connect must not hold the callback hostage until the fallback
// timer (a dead relay of five used to pin "loading" for the full 10s).
private struct EOSETracker { private struct EOSETracker {
var pendingRelays: Set<String> /// Targets the REQ has not been delivered to yet (still connecting).
var awaitingSend: Set<String>
/// Relays that received the REQ and have not sent EOSE yet.
var awaitingEOSE: Set<String>
/// True once any relay received the REQ (or answered with EOSE)
/// completion with zero sends would mean "done" without ever asking.
var didSend = false
var callback: () -> Void var callback: () -> Void
let epoch: Int let epoch: Int
/// Done when every relay that got the REQ has resolved, provided at
/// least one did or when every target dropped out entirely.
var isComplete: Bool {
(didSend && awaitingEOSE.isEmpty) || (awaitingSend.isEmpty && awaitingEOSE.isEmpty)
}
} }
private var eoseTrackers: [String: EOSETracker] = [:] private var eoseTrackers: [String: EOSETracker] = [:]
private var eoseTrackerEpoch = 0 private var eoseTrackerEpoch = 0
@@ -202,6 +226,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-01 `OK` is the relay's
/// accept/reject acknowledgment.
private struct ConfirmedSendState {
let token: UUID
var awaitingRelays: Set<String>
let completion: (Bool) -> Void
}
private var confirmedSends: [String: ConfirmedSendState] = [:]
// Total pending sends dropped at the queue cap; drives the sampled // 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
@@ -217,6 +250,32 @@ final class NostrRelayManager: ObservableObject {
// Bump generation to invalidate scheduled reconnects when we reset/disconnect // Bump generation to invalidate scheduled reconnects when we reset/disconnect
private var connectionGeneration: Int = 0 private var connectionGeneration: Int = 0
// Per-relay off-main inbound pipeline: raw socket frames are parsed and
// Schnorr-verified in arrival order OFF the main actor (this is the single
// signature verification for the whole inbound path downstream handlers
// receive only verified events), then hop back to the main actor for dedup
// recording and handler dispatch.
//
// Each relay connection owns its OWN AsyncStream + consumer task, so N
// relays verify in parallel while every relay's frames stay in arrival
// order (a single subscription's events for a relay all arrive on that
// relay's socket, so per-relay ordering preserves per-subscription
// ordering). A burst of EVENT frames from one busy/malicious relay only
// blocks that relay's own verification backlog DMs, OKs, EOSEs, and
// events from every other relay keep flowing on their own pipelines.
//
// Each stream is bounded (`.bufferingNewest`) so a relay flooding faster
// than its verification drains sheds its own oldest frames instead of
// growing memory without bound; it can never starve other relays.
//
// Continuations live in a lock-guarded, `Sendable` router (see
// `InboundFrameRouter` at file scope) so the raw socket receive callback
// (which is NOT main-actor isolated) can route a frame to the right relay
// stream without a per-frame main hop, while the main actor owns pipeline
// creation/teardown. The expensive work (Schnorr verify) is what runs
// off-main; the yield stays cheap.
private let inboundRouter = InboundFrameRouter()
init() { init() {
self.dependencies = .live() self.dependencies = .live()
hasMutualFavorites = dependencies.hasMutualFavorites() hasMutualFavorites = dependencies.hasMutualFavorites()
@@ -271,6 +330,69 @@ final class NostrRelayManager: ObservableObject {
.store(in: &cancellables) .store(in: &cancellables)
} }
deinit {
inboundRouter.finishAll()
}
/// Ensure a serial off-main consumer pipeline exists for a relay. Called on
/// the main actor when a socket is (re)armed for receiving. Idempotent.
///
/// Ordering within the relay is deliberate and security/performance-critical:
/// 1. `precheckInboundEvent` (main hop): per-relay stats plus a cheap
/// duplicate LOOKUP duplicate fan-in from multiple relays dominates
/// real traffic and must never pay for Schnorr verification.
/// 2. `isValidSignature()` runs here, off the main actor the ONLY
/// signature verification on the inbound path (JSON re-serialization +
/// SHA-256 + secp256k1 Schnorr per event).
/// 3. `deliverVerifiedInboundEvent` (main hop): authoritative
/// check-and-RECORD plus handler dispatch. Recording only after
/// verification means a forged-signature copy can never poison the
/// dedup cache and suppress the genuine event.
private func ensureRelayInboundPipeline(for relayUrl: String) {
let started = inboundRouter.startPipeline(for: relayUrl) { [weak self] stream in
Task.detached(priority: .userInitiated) {
for await frame in stream {
guard let parsed = ParsedInbound(frame.message) else { continue }
guard let self else { return }
switch parsed {
case .event(let subId, let event):
guard await self.precheckInboundEvent(
subscriptionID: subId,
eventID: event.id,
relayUrl: relayUrl
) else {
continue
}
guard event.isValidSignature() else {
SecureLogger.warning(
"⚠️ Dropped invalid Nostr event id=\(event.id.prefix(16))… sub=\(subId) relay=\(relayUrl)",
category: .session
)
continue
}
await self.deliverVerifiedInboundEvent(subscriptionID: subId, event: event, from: relayUrl)
case .eose, .ok, .notice:
await self.handleParsedMessage(parsed, from: relayUrl)
}
}
}
}
if started {
SecureLogger.debug("🧵 Started inbound verify pipeline for \(relayUrl)", category: .session)
}
}
/// Tear down a relay's inbound pipeline (socket gone or state wiped). The
/// consumer drains any already-buffered frames before finishing, so
/// in-flight verified events are still delivered.
private func teardownRelayInboundPipeline(for relayUrl: String) {
inboundRouter.finishPipeline(for: relayUrl)
}
private func teardownAllRelayInboundPipelines() {
inboundRouter.finishAll()
}
/// Connect to all configured relays /// Connect to all configured relays
func connect() { func connect() {
// Global network policy gate // Global network policy gate
@@ -285,6 +407,8 @@ final class NostrRelayManager: ObservableObject {
task.cancel(with: .goingAway, reason: nil) task.cancel(with: .goingAway, reason: nil)
} }
connections.removeAll() connections.removeAll()
// Sockets are gone; drop every relay's inbound verify pipeline.
teardownAllRelayInboundPipelines()
markRelaySocketsClosed(resetState: false) markRelaySocketsClosed(resetState: false)
// Sockets are gone, so per-relay subscription state is cleared but // Sockets are gone, so per-relay subscription state is cleared but
// durable intent (subscriptionRequestState, messageHandlers, parked // durable intent (subscriptionRequestState, messageHandlers, parked
@@ -298,6 +422,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
@@ -314,6 +441,7 @@ final class NostrRelayManager: ObservableObject {
task.cancel(with: .goingAway, reason: nil) task.cancel(with: .goingAway, reason: nil)
} }
connections.removeAll() connections.removeAll()
teardownAllRelayInboundPipelines()
markRelaySocketsClosed(resetState: true) markRelaySocketsClosed(resetState: true)
subscriptions.removeAll() subscriptions.removeAll()
pendingSubscriptions.removeAll() pendingSubscriptions.removeAll()
@@ -331,6 +459,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()
@@ -347,7 +476,6 @@ final class NostrRelayManager: ObservableObject {
relays[index].nextReconnectTime = nil relays[index].nextReconnectTime = nil
if resetState { if resetState {
relays[index].lastError = nil relays[index].lastError = nil
relays[index].lastConnectedAt = nil
relays[index].lastDisconnectedAt = nil relays[index].lastDisconnectedAt = nil
relays[index].messagesSent = 0 relays[index].messagesSent = 0
relays[index].messagesReceived = 0 relays[index].messagesReceived = 0
@@ -406,6 +534,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-01
/// `OK`. A successful WebSocket write alone is not durable acceptance.
/// Unlike `sendEvent`, this never enters the process-local pending queue;
/// callers use it when success unlocks durable state or user-visible
/// delivery progress.
func sendEventImmediately(
_ event: NostrEvent,
to relayUrls: [String]? = nil,
completion: @escaping (Bool) -> Void
) {
guard dependencies.activationAllowed() else {
completion(false)
return
}
guard !(shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady()) else {
completion(false)
return
}
let requestedRelays = relayUrls ?? Self.defaultRelays
let targetRelays = allowedRelayList(from: requestedRelays)
let connectedTargets = targetRelays.compactMap { relayUrl -> (String, NostrRelayConnectionProtocol)? in
guard let connection = connectedConnection(for: relayUrl) else { return nil }
return (relayUrl, connection)
}
guard !connectedTargets.isEmpty else {
completion(false)
return
}
let token = UUID()
let eventID = event.id
if let replaced = confirmedSends.removeValue(forKey: eventID) {
replaced.completion(false)
}
confirmedSends[eventID] = ConfirmedSendState(
token: token,
awaitingRelays: Set(connectedTargets.map(\.0)),
completion: completion
)
dependencies.scheduleAfter(TransportConfig.nostrConfirmedSendAckTimeoutSeconds) { [weak self] in
Task { @MainActor [weak self] in
self?.timeoutConfirmedSend(eventID: eventID, token: token)
}
}
for (relayUrl, connection) in connectedTargets {
sendToRelay(event: event, connection: connection, relayUrl: relayUrl) { [weak self] succeeded in
guard let self else { return }
// Success only means the bytes reached the socket; wait for
// the matching relay OK. A failed write settles this target
// as rejected because no OK can arrive for it.
if !succeeded {
self.resolveConfirmedSend(
eventID: eventID,
relayURL: relayUrl,
accepted: false,
token: token
)
}
}
}
}
private func 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))
@@ -564,6 +783,7 @@ final class NostrRelayManager: ObservableObject {
connection.cancel(with: .goingAway, reason: nil) connection.cancel(with: .goingAway, reason: nil)
} }
connections.removeValue(forKey: url) connections.removeValue(forKey: url)
teardownRelayInboundPipeline(for: url)
subscriptions.removeValue(forKey: url) subscriptions.removeValue(forKey: url)
pendingSubscriptions.removeValue(forKey: url) pendingSubscriptions.removeValue(forKey: url)
} }
@@ -795,7 +1015,7 @@ final class NostrRelayManager: ObservableObject {
private func startEOSETracking(id: String, relayURLs: Set<String>, callback: @escaping () -> Void) { private func startEOSETracking(id: String, relayURLs: Set<String>, callback: @escaping () -> Void) {
eoseTrackerEpoch += 1 eoseTrackerEpoch += 1
let epoch = eoseTrackerEpoch let epoch = eoseTrackerEpoch
eoseTrackers[id] = EOSETracker(pendingRelays: relayURLs, callback: callback, epoch: epoch) eoseTrackers[id] = EOSETracker(awaitingSend: relayURLs, awaitingEOSE: [], callback: callback, epoch: epoch)
// Fallback timeout to avoid hanging if a relay never sends EOSE. // Fallback timeout to avoid hanging if a relay never sends EOSE.
dependencies.scheduleAfter(TransportConfig.nostrSubscriptionEOSEFallbackSeconds) { [weak self] in dependencies.scheduleAfter(TransportConfig.nostrSubscriptionEOSEFallbackSeconds) { [weak self] in
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
@@ -904,12 +1124,17 @@ final class NostrRelayManager: ObservableObject {
connections[urlString] = task connections[urlString] = task
task.resume() task.resume()
// Bring up this relay's own serial verify pipeline before arming the
// socket, so inbound frames have somewhere to land.
ensureRelayInboundPipeline(for: urlString)
// Start receiving messages // Start receiving messages
receiveMessage(from: task, relayUrl: urlString) receiveMessage(from: task, relayUrl: urlString)
// 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)
@@ -919,7 +1144,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
)
} }
} }
} }
@@ -935,8 +1164,19 @@ final class NostrRelayManager: ObservableObject {
toSend[id] = state.messageString toSend[id] = state.messageString
} }
for (id, messageString) in toSend { for (id, messageString) in toSend {
if self.subscriptions[relayUrl]?.contains(id) == true { continue } if self.subscriptions[relayUrl]?.contains(id) == true {
// Already subscribed on this relay (e.g. a tracker promoted
// after an earlier flush): its EOSE is coming, count it.
markEOSESubscribed(id: id, relayUrl: relayUrl)
continue
}
startPendingEOSETrackingIfNeeded(id: id) startPendingEOSETrackingIfNeeded(id: id)
// Mark at send *initiation*, not in the async completion: a fast
// relay's EOSE could otherwise complete the tracker while this
// relay REQ already on the wire still sat in awaitingSend.
// If the send fails the socket is going down with it, and the
// disconnect settle (or the fallback timer) releases the wait.
markEOSESubscribed(id: id, relayUrl: relayUrl)
connection.send(.string(messageString)) { [weak self, weak connection] error in connection.send(.string(messageString)) { [weak self, weak connection] error in
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }
@@ -962,22 +1202,23 @@ final class NostrRelayManager: ObservableObject {
switch result { switch result {
case .success(let message): case .success(let message):
// Parse off-main to reduce UI jank, then hop back for state updates // Hand the raw frame to this relay's serial inbound pipeline:
Task.detached(priority: .utility) { // parsing and signature verification run off-main, in arrival
guard let parsed = ParsedInbound(message) else { return } // order, independently of every other relay's pipeline. Routing
await MainActor.run { // through the lock-guarded router keeps this off the main actor
self.handleParsedMessage(parsed, from: relayUrl) // (no per-frame main hop).
} self.inboundRouter.yield(InboundFrame(message: message), to: 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)
} }
} }
} }
@@ -987,20 +1228,30 @@ final class NostrRelayManager: ObservableObject {
// Note: declared at file scope below to avoid MainActor isolation inside this class // Note: declared at file scope below to avoid MainActor isolation inside this class
// and keep parsing off the main actor. // and keep parsing off the main actor.
// Handle parsed message on MainActor (state updates and handlers) /// First main-actor hop for an inbound EVENT: per-relay stats plus a cheap
private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) { /// duplicate LOOKUP (no recording) so duplicate fan-in from multiple
switch parsed { /// relays never pays for Schnorr verification. Recording happens only
case .event(let subId, let event): /// after the signature verifies (`deliverVerifiedInboundEvent`), so a
if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) { /// forged-signature copy can never poison the dedup cache and suppress
self.relays[index].messagesReceived += 1 /// the genuine event.
private func precheckInboundEvent(subscriptionID: String, eventID: String, relayUrl: String) -> Bool {
if let index = relays.firstIndex(where: { $0.url == relayUrl }) {
relays[index].messagesReceived += 1
} }
guard event.isValidSignature() else { guard !eventID.isEmpty else { return true }
SecureLogger.warning( let key = InboundEventKey(subscriptionID: subscriptionID, eventID: eventID)
"⚠️ Dropped invalid Nostr event id=\(event.id.prefix(16))… sub=\(subId) relay=\(relayUrl)", if recentInboundEventKeys.contains(key) {
category: .session recordDuplicateInboundEventDrop(subscriptionID: subscriptionID)
) return false
return
} }
return true
}
/// Second main-actor hop, after off-main signature verification:
/// authoritative check-and-record (the serial pipeline means the same
/// event is never in flight twice, but the record must stay atomic with
/// delivery) and handler dispatch.
private func deliverVerifiedInboundEvent(subscriptionID subId: String, event: NostrEvent, from relayUrl: String) {
guard shouldDeliverInboundEvent(subscriptionID: subId, eventID: event.id) else { guard shouldDeliverInboundEvent(subscriptionID: subId, eventID: event.id) else {
return return
} }
@@ -1016,10 +1267,24 @@ final class NostrRelayManager: ObservableObject {
} else { } else {
SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session) SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session)
} }
}
// Handle parsed non-EVENT messages on MainActor (state updates and handlers)
private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) {
switch parsed {
case .event:
// Events flow through the serial inbound pipeline (precheck
// off-main signature verification deliverVerifiedInboundEvent)
// and never reach this fallback.
assertionFailure("inbound EVENT bypassed the verified pipeline")
case .eose(let subId): case .eose(let subId):
if var tracker = eoseTrackers[subId] { if var tracker = eoseTrackers[subId] {
tracker.pendingRelays.remove(relayUrl) // An EOSE proves the relay received the REQ even if the local
if tracker.pendingRelays.isEmpty { // send completion hasn't run yet.
tracker.awaitingSend.remove(relayUrl)
tracker.awaitingEOSE.remove(relayUrl)
tracker.didSend = true
if tracker.isComplete {
eoseTrackers.removeValue(forKey: subId) eoseTrackers.removeValue(forKey: subId)
tracker.callback() tracker.callback()
} else { } else {
@@ -1027,6 +1292,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)
@@ -1043,7 +1309,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 {
@@ -1056,17 +1327,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)
} }
} }
@@ -1075,7 +1349,6 @@ final class NostrRelayManager: ObservableObject {
relays[index].isConnected = isConnected relays[index].isConnected = isConnected
relays[index].lastError = error relays[index].lastError = error
if isConnected { if isConnected {
relays[index].lastConnectedAt = dependencies.now()
relays[index].reconnectAttempts = 0 // Reset on successful connection relays[index].reconnectAttempts = 0 // Reset on successful connection
relays[index].nextReconnectTime = nil relays[index].nextReconnectTime = nil
} else { } else {
@@ -1100,9 +1373,11 @@ final class NostrRelayManager: ObservableObject {
/// callbacks; treat it as done and let the remaining relays (or the /// callbacks; treat it as done and let the remaining relays (or the
/// fallback timeout) drive completion. /// fallback timeout) drive completion.
private func settleEOSETrackers(droppingRelay relayUrl: String) { private func settleEOSETrackers(droppingRelay relayUrl: String) {
for (id, var tracker) in eoseTrackers where tracker.pendingRelays.contains(relayUrl) { for (id, var tracker) in eoseTrackers
tracker.pendingRelays.remove(relayUrl) where tracker.awaitingSend.contains(relayUrl) || tracker.awaitingEOSE.contains(relayUrl) {
if tracker.pendingRelays.isEmpty { tracker.awaitingSend.remove(relayUrl)
tracker.awaitingEOSE.remove(relayUrl)
if tracker.isComplete {
eoseTrackers.removeValue(forKey: id) eoseTrackers.removeValue(forKey: id)
tracker.callback() tracker.callback()
} else { } else {
@@ -1111,9 +1386,39 @@ final class NostrRelayManager: ObservableObject {
} }
} }
private func handleDisconnection(relayUrl: String, error: Error) { /// Whether any of `relayUrls` currently holds a live connection. Lets
/// subscribers distinguish "loaded, empty" from "never reached a relay"
/// when an EOSE fallback fires.
func isAnyRelayConnected(among relayUrls: [String]) -> Bool {
let targets = Set(relayUrls)
return relays.contains { targets.contains($0.url) && $0.isConnected }
}
/// Marks the REQ as delivered to `relayUrl`: EOSE completion now waits on
/// this relay instead of the never-connected remainder.
private func markEOSESubscribed(id: String, relayUrl: String) {
guard var tracker = eoseTrackers[id],
tracker.awaitingSend.remove(relayUrl) != nil else { return }
tracker.awaitingEOSE.insert(relayUrl)
tracker.didSend = true
eoseTrackers[id] = tracker
}
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)
teardownRelayInboundPipeline(for: 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
@@ -1201,6 +1506,7 @@ final class NostrRelayManager: ObservableObject {
if let connection = connections[normalizedRelayUrl] { if let connection = connections[normalizedRelayUrl] {
connection.cancel(with: .goingAway, reason: nil) connection.cancel(with: .goingAway, reason: nil)
connections.removeValue(forKey: normalizedRelayUrl) connections.removeValue(forKey: normalizedRelayUrl)
teardownRelayInboundPipeline(for: normalizedRelayUrl)
} }
// Attempt immediate reconnection // Attempt immediate reconnection
@@ -1295,6 +1601,77 @@ final class NostrRelayManager: ObservableObject {
// MARK: - Off-main inbound parsing helpers (file scope, non-isolated) // MARK: - Off-main inbound parsing helpers (file scope, non-isolated)
/// A single raw socket frame awaiting off-main parse + Schnorr verification.
private struct InboundFrame: Sendable {
let message: URLSessionWebSocketTask.Message
}
/// Lock-guarded registry of per-relay inbound streams.
///
/// The raw WebSocket receive callback is not main-actor isolated, so it needs a
/// `Sendable` path to route a frame to the correct relay's stream without a
/// per-frame hop onto the main actor. Pipeline lifecycle (start/finish) is
/// driven from the main actor; frame delivery (`yield`) can come from any
/// thread. All access is serialized by a single lock contention is negligible
/// because the guarded critical section is only a dictionary lookup + yield.
private final class InboundFrameRouter: @unchecked Sendable {
private let lock = NSLock()
private var continuations: [String: AsyncStream<InboundFrame>.Continuation] = [:]
private var tasks: [String: Task<Void, Never>] = [:]
/// Start a relay's stream + consumer if one does not already exist.
/// Returns true when a new pipeline was created. The bounded
/// `.bufferingNewest` policy makes a single relay shed its OWN oldest
/// frames under a flood, never other relays' frames. Buffered memory per
/// relay is bounded (not eliminated) at the frame cap times the per-frame
/// byte cap (`maximumMessageSize`) see TransportConfig.
func startPipeline(
for relayUrl: String,
makeConsumer: (AsyncStream<InboundFrame>) -> Task<Void, Never>
) -> Bool {
lock.lock()
defer { lock.unlock() }
if continuations[relayUrl] != nil { return false }
let (stream, continuation) = AsyncStream<InboundFrame>.makeStream(
bufferingPolicy: .bufferingNewest(TransportConfig.nostrInboundPerRelayBufferCap)
)
continuations[relayUrl] = continuation
tasks[relayUrl] = makeConsumer(stream)
return true
}
/// Route a frame to a relay's stream. No-op if the relay has no live
/// pipeline (socket already torn down) the frame is simply dropped, which
/// is safe for best-effort Nostr inbound.
func yield(_ frame: InboundFrame, to relayUrl: String) {
lock.lock()
let continuation = continuations[relayUrl]
lock.unlock()
continuation?.yield(frame)
}
/// Finish a relay's stream. The consumer drains any already-buffered frames
/// before exiting, so in-flight verified events are still delivered.
func finishPipeline(for relayUrl: String) {
lock.lock()
let continuation = continuations.removeValue(forKey: relayUrl)
tasks.removeValue(forKey: relayUrl)
lock.unlock()
continuation?.finish()
}
func finishAll() {
lock.lock()
let allContinuations = continuations
continuations.removeAll()
tasks.removeAll()
lock.unlock()
for continuation in allContinuations.values {
continuation.finish()
}
}
}
private enum ParsedInbound { private enum ParsedInbound {
case event(subId: String, event: NostrEvent) case event(subId: String, event: NostrEvent)
case ok(eventId: String, success: Bool, reason: String) case ok(eventId: String, success: Bool, reason: String)
@@ -1302,7 +1679,7 @@ private enum ParsedInbound {
case notice(String) case notice(String)
init?(_ message: URLSessionWebSocketTask.Message) { init?(_ message: URLSessionWebSocketTask.Message) {
guard let data = message.data, guard let data = message.dataWithinInboundLimit,
let array = try? JSONSerialization.jsonObject(with: data) as? [Any], let array = try? JSONSerialization.jsonObject(with: data) as? [Any],
array.count >= 2, array.count >= 2,
let type = array[0] as? String else { let type = array[0] as? String else {
@@ -1347,11 +1724,19 @@ private enum ParsedInbound {
} }
private extension URLSessionWebSocketTask.Message { private extension URLSessionWebSocketTask.Message {
var data: Data? { /// Prefer rejecting oversized frames before UTF-8/Data materialization
/// where we can (string length), and always before JSON parse.
var dataWithinInboundLimit: Data? {
let maxBytes = TransportConfig.nostrMaxInboundMessageBytes
switch self { switch self {
case .string(let text): text.data(using: .utf8) case .string(let text):
case .data(let data): data guard text.utf8.count <= maxBytes else { return nil }
@unknown default: nil return text.data(using: .utf8)
case .data(let data):
guard data.count <= maxBytes else { return nil }
return data
@unknown default:
return nil
} }
} }
} }
@@ -1463,6 +1848,29 @@ struct NostrFilter: Encodable {
filter.limit = limit filter.limit = limit
return filter return filter
} }
// For the mesh bridge: rendezvous messages (kind 20000) and presence
// (kind 20001) tagged `#r` with one or more cells (own + neighbors).
static func bridgeRendezvous(_ cells: [String], since: Date? = nil, limit: Int = 200) -> NostrFilter {
var filter = NostrFilter()
filter.kinds = [20000, 20001]
filter.since = since?.timeIntervalSince1970.toInt()
filter.tagFilters = ["r": cells]
filter.limit = limit
return filter
}
// For courier drops: sealed envelopes (kind 1401) parked under rotating
// recipient tags (`#x`, hex). Callers pass every candidate tag (adjacent
// UTC days x recipients) in one filter.
static func courierDrops(recipientTagsHex: [String], since: Date? = nil, limit: Int = 100) -> NostrFilter {
var filter = NostrFilter()
filter.kinds = [NostrProtocol.EventKind.courierDrop.rawValue]
filter.since = since?.timeIntervalSince1970.toInt()
filter.tagFilters = ["x": recipientTagsHex]
filter.limit = limit
return filter
}
} }
// Dynamic coding key for tag filters // Dynamic coding key for tag filters
-9
View File
@@ -39,13 +39,4 @@ enum NostrRelayURL {
return components.string return components.string
} }
static func directoryAddress(_ rawValue: String) -> String? {
guard var normalized = normalized(rawValue, defaultScheme: "wss") else { return nil }
for prefix in ["wss://", "ws://"] where normalized.hasPrefix(prefix) {
normalized.removeFirst(prefix.count)
break
}
return normalized
}
} }
+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>
+35
View File
@@ -77,12 +77,37 @@ enum NoisePayloadType: UInt8 {
// Private groups (0x04/0x05 reserved by other features) // Private groups (0x04/0x05 reserved by other features)
case groupInvite = 0x06 // Creator-signed group state (invite) case groupInvite = 0x06 // Creator-signed group state (invite)
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)
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"
@@ -90,6 +115,9 @@ enum NoisePayloadType: UInt8 {
case .delivered: return "delivered" case .delivered: return "delivered"
case .groupInvite: return "groupInvite" case .groupInvite: return "groupInvite"
case .groupKeyUpdate: return "groupKeyUpdate" case .groupKeyUpdate: return "groupKeyUpdate"
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"
@@ -127,6 +155,9 @@ protocol BitchatDelegate: AnyObject {
// Encrypted group broadcast (opaque envelope; decrypted by the group coordinator) // Encrypted group broadcast (opaque envelope; decrypted by the group coordinator)
func didReceiveGroupMessage(payload: Data, timestamp: Date) func didReceiveGroupMessage(payload: Data, timestamp: Date)
// Public live-voice burst packet (signature-verified by the transport)
func didReceivePublicVoiceFrame(from peerID: PeerID, nickname: String, payload: Data, timestamp: Date)
// Bluetooth state updates for user notifications // Bluetooth state updates for user notifications
func didUpdateBluetoothState(_ state: CBManagerState) func didUpdateBluetoothState(_ state: CBManagerState)
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
@@ -150,6 +181,10 @@ extension BitchatDelegate {
// Default empty implementation // Default empty implementation
} }
func didReceivePublicVoiceFrame(from peerID: PeerID, nickname: String, payload: Data, timestamp: Date) {
// Default empty implementation
}
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) { func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
// Default empty implementation // Default empty implementation
} }
-8
View File
@@ -10,14 +10,6 @@ enum Geohash {
return map return map
}() }()
/// Validates a geohash string for building-level precision (8 characters).
/// - Parameter geohash: The geohash string to validate
/// - Returns: true if valid 8-character base32 geohash, false otherwise
static func isValidBuildingGeohash(_ geohash: String) -> Bool {
guard geohash.count == 8 else { return false }
return geohash.lowercased().allSatisfy { base32Map[$0] != nil }
}
/// Validates a geohash string at any channel precision (1-12 characters). /// Validates a geohash string at any channel precision (1-12 characters).
/// - Parameter geohash: The geohash string to validate /// - Parameter geohash: The geohash string to validate
/// - Returns: true if a non-empty base32 geohash of at most 12 characters /// - Returns: true if a non-empty base32 geohash of at most 12 characters
@@ -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))
}
}
@@ -32,6 +32,14 @@ struct NostrCarrierPacket: Equatable {
enum Direction: UInt8 { enum Direction: UInt8 {
case toGateway = 0x01 case toGateway = 0x01
case fromGateway = 0x02 case fromGateway = 0x02
/// Mesh-bridge uplink: a mesh-only peer asks a bridge gateway to
/// publish its signed rendezvous event. Directed, like `toGateway`.
case toBridge = 0x03
/// Mesh-bridge downlink: a bridge gateway rebroadcasts a rendezvous
/// event from a remote island. Broadcast, like `fromGateway`.
/// Old clients fail the Direction decode on 0x03/0x04 and drop the
/// carrier quietly bridge traffic degrades to invisible, not junk.
case fromBridge = 0x04
} }
let direction: Direction let direction: Direction
+107 -2
View File
@@ -9,19 +9,25 @@ struct AnnouncementPacket {
let signingPublicKey: Data // Ed25519 public key for signing let signingPublicKey: Data // Ed25519 public key for signing
let directNeighbors: [Data]? // 8-byte peer IDs let directNeighbors: [Data]? // 8-byte peer IDs
let capabilities: PeerCapabilities? // advertised feature bits; nil when absent (old clients) let capabilities: PeerCapabilities? // advertised feature bits; nil when absent (old clients)
/// Rendezvous geohash cell this peer bridges, when advertising `.bridge`.
/// Coarse (cell-level) by design; lets mesh-only peers compose correctly
/// tagged rendezvous events without their own location fix.
let bridgeGeohash: String?
init( init(
nickname: String, nickname: String,
noisePublicKey: Data, noisePublicKey: Data,
signingPublicKey: Data, signingPublicKey: Data,
directNeighbors: [Data]?, directNeighbors: [Data]?,
capabilities: PeerCapabilities? = nil capabilities: PeerCapabilities? = nil,
bridgeGeohash: String? = nil
) { ) {
self.nickname = nickname self.nickname = nickname
self.noisePublicKey = noisePublicKey self.noisePublicKey = noisePublicKey
self.signingPublicKey = signingPublicKey self.signingPublicKey = signingPublicKey
self.directNeighbors = directNeighbors self.directNeighbors = directNeighbors
self.capabilities = capabilities self.capabilities = capabilities
self.bridgeGeohash = bridgeGeohash
} }
private enum TLVType: UInt8 { private enum TLVType: UInt8 {
@@ -30,6 +36,7 @@ struct AnnouncementPacket {
case signingPublicKey = 0x03 case signingPublicKey = 0x03
case directNeighbors = 0x04 case directNeighbors = 0x04
case capabilities = 0x05 case capabilities = 0x05
case bridgeGeohash = 0x06
} }
func encode() -> Data? { func encode() -> Data? {
@@ -74,6 +81,15 @@ struct AnnouncementPacket {
data.append(capabilityBytes) data.append(capabilityBytes)
} }
// TLV for bridge rendezvous cell (optional; old clients skip it)
if let bridgeGeohash = bridgeGeohash,
let cellData = bridgeGeohash.data(using: .utf8),
!cellData.isEmpty, cellData.count <= 12 {
data.append(TLVType.bridgeGeohash.rawValue)
data.append(UInt8(cellData.count))
data.append(cellData)
}
return data return data
} }
@@ -84,6 +100,7 @@ struct AnnouncementPacket {
var signingPublicKey: Data? var signingPublicKey: Data?
var directNeighbors: [Data]? var directNeighbors: [Data]?
var capabilities: PeerCapabilities? var capabilities: PeerCapabilities?
var bridgeGeohash: String?
while offset + 2 <= data.count { while offset + 2 <= data.count {
let typeRaw = data[offset] let typeRaw = data[offset]
@@ -116,6 +133,10 @@ struct AnnouncementPacket {
} }
case .capabilities: case .capabilities:
capabilities = PeerCapabilities(encoded: Data(value)) capabilities = PeerCapabilities(encoded: Data(value))
case .bridgeGeohash:
if length <= 12 {
bridgeGeohash = String(data: value, encoding: .utf8)
}
} }
} else { } else {
// Unknown TLV; skip (tolerant decoder for forward compatibility) // Unknown TLV; skip (tolerant decoder for forward compatibility)
@@ -129,7 +150,91 @@ struct AnnouncementPacket {
noisePublicKey: noisePublicKey, noisePublicKey: noisePublicKey,
signingPublicKey: signingPublicKey, signingPublicKey: signingPublicKey,
directNeighbors: directNeighbors, directNeighbors: directNeighbors,
capabilities: capabilities capabilities: capabilities,
bridgeGeohash: bridgeGeohash
)
}
}
/// 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
) )
} }
} }
@@ -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]
} }
+220
View File
@@ -0,0 +1,220 @@
//
// VoiceBurstPacket.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import Security
/// Audio codec of a live voice burst. START packets carry it so receivers can
/// reject bursts they can't decode instead of feeding garbage to the decoder.
enum VoiceBurstCodec: UInt8 {
/// AAC-LC, 16 kHz, mono, ~16 kbps matches the voice-note recorder, so
/// the finalized `.m4a` and the live frames come from the same encoder
/// settings.
case aacLC16kMono = 0x01
}
/// One packet of a live push-to-talk voice burst (the inner payload of
/// `NoisePayloadType.voiceFrame`, and for public mesh bursts the payload
/// of `MessageType.voiceFrame`).
///
/// Wire format:
/// ```
/// [burstID: 8][seq: UInt16 BE][flags: UInt8][payload]
/// ```
/// - flags 0x01 (START): payload = [codec: UInt8]
/// - flags 0x02 (END): payload = [totalDataPackets: UInt16 BE][durationMs: UInt32 BE]
/// - flags 0x04 (CANCELED): empty payload; receivers discard the burst
/// - flags 0x00 (data): payload = repeated [length: UInt16 BE][AAC frame]
struct VoiceBurstPacket: Equatable {
enum Kind: Equatable {
case start(codec: VoiceBurstCodec)
case frames([Data])
case end(totalDataPackets: UInt16, durationMs: UInt32)
case canceled
}
static let burstIDSize = 8
private static let headerSize = burstIDSize + 2 + 1
/// Sanity cap on frames per packet; real packets carry 1-2 frames.
static let maxFramesPerPacket = 8
private enum Flags {
static let start: UInt8 = 0x01
static let end: UInt8 = 0x02
static let canceled: UInt8 = 0x04
}
let burstID: Data
let seq: UInt16
let kind: Kind
init?(burstID: Data, seq: UInt16, kind: Kind) {
guard burstID.count == Self.burstIDSize else { return nil }
if case .frames(let frames) = kind {
guard !frames.isEmpty,
frames.count <= Self.maxFramesPerPacket,
frames.allSatisfy({ !$0.isEmpty && $0.count <= Int(UInt16.max) })
else { return nil }
}
self.burstID = burstID
self.seq = seq
self.kind = kind
}
func encode() -> Data {
var data = Data(capacity: Self.headerSize + payloadSize)
data.append(burstID)
data.append(UInt8((seq >> 8) & 0xFF))
data.append(UInt8(seq & 0xFF))
switch kind {
case .start(let codec):
data.append(Flags.start)
data.append(codec.rawValue)
case .frames(let frames):
data.append(0)
for frame in frames {
let length = UInt16(frame.count)
data.append(UInt8((length >> 8) & 0xFF))
data.append(UInt8(length & 0xFF))
data.append(frame)
}
case .end(let totalDataPackets, let durationMs):
data.append(Flags.end)
data.append(UInt8((totalDataPackets >> 8) & 0xFF))
data.append(UInt8(totalDataPackets & 0xFF))
for shift in stride(from: 24, through: 0, by: -8) {
data.append(UInt8((durationMs >> UInt32(shift)) & 0xFF))
}
case .canceled:
data.append(Flags.canceled)
}
return data
}
static func decode(_ data: Data) -> VoiceBurstPacket? {
// Work on a re-based copy so subscripting is offset-safe.
let data = Data(data)
guard data.count >= headerSize else { return nil }
let burstID = data.prefix(burstIDSize)
let seq = (UInt16(data[burstIDSize]) << 8) | UInt16(data[burstIDSize + 1])
let flags = data[burstIDSize + 2]
let payload = data.dropFirst(headerSize)
let kind: Kind
switch flags {
case Flags.start:
guard let codecByte = payload.first,
let codec = VoiceBurstCodec(rawValue: codecByte)
else { return nil }
kind = .start(codec: codec)
case Flags.end:
guard payload.count >= 6 else { return nil }
let bytes = Array(payload)
let total = (UInt16(bytes[0]) << 8) | UInt16(bytes[1])
let duration = bytes[2...5].reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
kind = .end(totalDataPackets: total, durationMs: duration)
case Flags.canceled:
kind = .canceled
case 0:
var frames: [Data] = []
var offset = payload.startIndex
while offset < payload.endIndex {
guard payload.distance(from: offset, to: payload.endIndex) >= 2 else { return nil }
let length = (Int(payload[offset]) << 8) | Int(payload[payload.index(after: offset)])
offset = payload.index(offset, offsetBy: 2)
guard length > 0,
payload.distance(from: offset, to: payload.endIndex) >= length,
frames.count < maxFramesPerPacket
else { return nil }
let end = payload.index(offset, offsetBy: length)
frames.append(Data(payload[offset..<end]))
offset = end
}
guard !frames.isEmpty else { return nil }
kind = .frames(frames)
default:
return nil
}
return VoiceBurstPacket(burstID: Data(burstID), seq: seq, kind: kind)
}
static func makeBurstID() -> Data {
var bytes = Data(count: burstIDSize)
let result = bytes.withUnsafeMutableBytes {
SecRandomCopyBytes(kSecRandomDefault, burstIDSize, $0.baseAddress!)
}
guard result == errSecSuccess else {
return Data((0..<burstIDSize).map { _ in UInt8.random(in: .min ... .max) })
}
return bytes
}
private var payloadSize: Int {
switch kind {
case .start: return 1
case .frames(let frames): return frames.reduce(0) { $0 + 2 + $1.count }
case .end: return 6
case .canceled: return 0
}
}
}
/// Greedy packetizer for outgoing bursts: batches encoded frames into
/// `VoiceBurstPacket`s without exceeding the byte budget that keeps each
/// packet in a single BLE frame after Noise encryption and padding.
/// Not thread-safe confine to one queue.
struct VoiceBurstPacketizer {
let burstID: Data
private let budget: Int
private var pendingFrames: [Data] = []
private var pendingSize = 0
/// seq 0 is reserved for START; data packets start at 1.
private(set) var nextSeq: UInt16 = 1
private(set) var dataPacketCount: UInt16 = 0
init(burstID: Data, budget: Int = TransportConfig.pttMaxBurstContentBytes) {
self.burstID = burstID
self.budget = budget
}
/// Adds one encoded frame, returning any packets that became full.
/// Frames larger than the budget are dropped (the encoder's ~130-byte
/// frames never hit this; it guards against misconfiguration looping).
mutating func add(_ frame: Data) -> [Data] {
let frameCost = 2 + frame.count
guard VoiceBurstPacket.burstIDSize + 3 + frameCost <= budget else { return [] }
var packets: [Data] = []
if !pendingFrames.isEmpty,
VoiceBurstPacket.burstIDSize + 3 + pendingSize + frameCost > budget
|| pendingFrames.count >= VoiceBurstPacket.maxFramesPerPacket {
packets.append(contentsOf: flush())
}
pendingFrames.append(frame)
pendingSize += frameCost
return packets
}
/// Emits any buffered frames as a final data packet.
mutating func flush() -> [Data] {
guard !pendingFrames.isEmpty,
let packet = VoiceBurstPacket(burstID: burstID, seq: nextSeq, kind: .frames(pendingFrames))
else {
pendingFrames = []
pendingSize = 0
return []
}
pendingFrames = []
pendingSize = 0
nextSeq &+= 1
dataPacketCount &+= 1
return [packet.encode()]
}
}
@@ -11,13 +11,6 @@ import Foundation
/// Manages autocomplete functionality for chat /// Manages autocomplete functionality for chat
final class AutocompleteService { final class AutocompleteService {
private let mentionRegex = try? NSRegularExpression(pattern: "@([\\p{L}0-9_]*)$", options: []) private let mentionRegex = try? NSRegularExpression(pattern: "@([\\p{L}0-9_]*)$", options: [])
private let commandRegex = try? NSRegularExpression(pattern: "^/([a-z]*)$", options: [])
private let commands = [
"/msg", "/who", "/clear",
"/hug", "/slap", "/fav", "/unfav",
"/block", "/unblock"
]
/// Get autocomplete suggestions for current text /// Get autocomplete suggestions for current text
func getSuggestions(for text: String, peers: [String], cursorPosition: Int) -> (suggestions: [String], range: NSRange?) { func getSuggestions(for text: String, peers: [String], cursorPosition: Int) -> (suggestions: [String], range: NSRange?) {
@@ -73,26 +66,6 @@ final class AutocompleteService {
return suggestions.isEmpty ? nil : (Array(suggestions), fullRange) return suggestions.isEmpty ? nil : (Array(suggestions), fullRange)
} }
private func getCommandSuggestions(_ text: String) -> ([String], NSRange)? {
guard let regex = commandRegex else { return nil }
let nsText = text as NSString
let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: nsText.length))
guard let match = matches.last else { return nil }
let fullRange = match.range(at: 0)
let captureRange = match.range(at: 1)
let prefix = nsText.substring(with: captureRange).lowercased()
let suggestions = commands
.filter { $0.hasPrefix("/\(prefix)") }
.sorted()
.prefix(5)
return suggestions.isEmpty ? nil : (Array(suggestions), fullRange)
}
private func needsArgument(command: String) -> Bool { private func needsArgument(command: String) -> Bool {
switch command { switch command {
case "/who", "/clear": case "/who", "/clear":
+77 -10
View File
@@ -14,22 +14,39 @@ struct BLEAnnounceHandlerEnvironment {
let messageTTL: UInt8 let messageTTL: UInt8
/// Current time source. /// Current time source.
let now: () -> Date let now: () -> Date
/// Noise public key already recorded for the peer, if any (registry read). /// Noise and signing public keys already recorded for the peer, if any
let existingNoisePublicKey: (PeerID) -> Data? /// (single registry read so both come from one consistent snapshot).
let existingPeerKeys: (PeerID) -> (noisePublicKey: Data?, signingPublicKey: Data?)
/// Signing key from the persisted cryptographic identity for the peer, if
/// any. Registry pins do not survive app restarts or offline-peer
/// eviction; this fallback keeps the TOFU signing-key pin effective for
/// returning peers.
let persistedSigningPublicKey: (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.
/// Returns `nil` when the registry refuses the announce because it carries
/// a signing key different from the one already pinned for this peer.
/// Must only be called from inside `withRegistryBarrier`. /// Must only be called from inside `withRegistryBarrier`.
let upsertVerifiedAnnounce: ( let upsertVerifiedAnnounce: (
_ peerID: PeerID, _ peerID: PeerID,
_ announcement: AnnouncementPacket, _ announcement: AnnouncementPacket,
_ isConnected: Bool, _ isConnected: Bool,
_ now: Date _ now: Date
) -> BLEPeerAnnounceUpdate ) -> BLEPeerAnnounceUpdate?
/// Debounced reconnect-log decision. /// Debounced reconnect-log decision.
/// Must only be called from inside `withRegistryBarrier`. /// Must only be called from inside `withRegistryBarrier`.
let shouldEmitReconnectLog: (_ peerID: PeerID, _ now: Date) -> Bool let shouldEmitReconnectLog: (_ peerID: PeerID, _ now: Date) -> Bool
@@ -109,7 +126,16 @@ final class BLEAnnounceHandler {
// Suppress announce logs to reduce noise // Suppress announce logs to reduce noise
// Precompute signature verification outside barrier to reduce contention // Precompute signature verification outside barrier to reduce contention
let existingNoisePublicKey = env.existingNoisePublicKey(peerID) var existingPeerKeys = env.existingPeerKeys(peerID)
if existingPeerKeys.signingPublicKey == nil {
// The registry entry (and its signing-key pin) is dropped on app
// restart and offline-peer eviction, but the persisted
// cryptographic identity survives both. Fall back to it so a
// returning peer is not treated as first contact otherwise an
// attacker could replay the peer's noiseKey/peerID with their own
// signing key and re-pin the identity (TOFU downgrade).
existingPeerKeys.signingPublicKey = env.persistedSigningPublicKey(peerID)
}
let hasSignature = packet.signature != nil let hasSignature = packet.signature != nil
let signatureValid: Bool let signatureValid: Bool
if hasSignature { if hasSignature {
@@ -123,18 +149,49 @@ final class BLEAnnounceHandler {
let trustDecision = BLEAnnounceTrustPolicy.evaluate( let trustDecision = BLEAnnounceTrustPolicy.evaluate(
hasSignature: hasSignature, hasSignature: hasSignature,
signatureValid: signatureValid, signatureValid: signatureValid,
existingNoisePublicKey: existingNoisePublicKey, existingNoisePublicKey: existingPeerKeys.noisePublicKey,
announcedNoisePublicKey: announcement.noisePublicKey announcedNoisePublicKey: announcement.noisePublicKey,
existingSigningPublicKey: existingPeerKeys.signingPublicKey,
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)
} }
let verifiedAnnounce = trustDecision.isVerified if case .reject(.signingKeyMismatch) = trustDecision {
SecureLogger.warning("🚨 Announce signing-key mismatch for \(peerID.id.prefix(8))… — refusing to replace pinned signing key (possible impersonation attempt)", category: .security)
}
if case .reject(.authenticatedSigningKeyMismatch) = trustDecision {
SecureLogger.warning(
"⚠️ Announce signing-key replacement rejected for Noise-authenticated peer \(peerID.id.prefix(8))",
category: .security
)
}
var 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
@@ -149,12 +206,22 @@ final class BLEAnnounceHandler {
return return
} }
let update = env.upsertVerifiedAnnounce( // The registry re-checks the signing-key pin inside the barrier.
// The pre-barrier trust check reads the registry outside the
// barrier, so this closes the race where two announces for the
// same peer are evaluated concurrently.
guard let update = env.upsertVerifiedAnnounce(
peerID, peerID,
announcement, announcement,
isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription, hasPeripheralConnection || hasCentralSubscription || (isDirectAnnounce && !linkBoundToOtherPeer),
now now
) ) else {
SecureLogger.warning("🚨 Registry refused announce for \(peerID.id.prefix(8))… — signing key differs from pinned key", category: .security)
verifiedAnnounce = false
isNewPeer = false
isReconnectedPeer = false
return
}
isNewPeer = update.isNewPeer isNewPeer = update.isNewPeer
isReconnectedPeer = update.wasDisconnected isReconnectedPeer = update.wasDisconnected
@@ -56,6 +56,8 @@ enum BLEAnnounceTrustRejection: Equatable {
case missingSignature case missingSignature
case invalidSignature case invalidSignature
case keyMismatch case keyMismatch
case signingKeyMismatch
case authenticatedSigningKeyMismatch
} }
enum BLEAnnounceTrustDecision: Equatable { enum BLEAnnounceTrustDecision: Equatable {
@@ -72,12 +74,34 @@ enum BLEAnnounceTrustPolicy {
hasSignature: Bool, hasSignature: Bool,
signatureValid: Bool, signatureValid: Bool,
existingNoisePublicKey: Data?, existingNoisePublicKey: Data?,
announcedNoisePublicKey: Data announcedNoisePublicKey: Data,
existingSigningPublicKey: Data? = nil,
authenticatedSigningPublicKey: Data? = nil,
announcedSigningPublicKey: Data
) -> BLEAnnounceTrustDecision { ) -> BLEAnnounceTrustDecision {
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey { if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
return .reject(.keyMismatch) return .reject(.keyMismatch)
} }
// Strongest binding first: an Ed25519 key bound to this Noise identity
// inside an authenticated Noise session can never be replaced by a
// merely self-signed announce.
if let authenticatedSigningPublicKey,
announcedSigningPublicKey != authenticatedSigningPublicKey {
return .reject(.authenticatedSigningKeyMismatch)
}
// TOFU signing-key pinning. The packet signature only proves the
// announce is self-consistent it is verified against the Ed25519 key
// carried *inside the same announce*. Since peerIDs derive from the
// broadcast (public) noise key, an attacker can replay a victim's
// peerID+noiseKey with their own signing key and a valid
// self-signature. Once we have bound a signing key to this peer,
// refuse to silently replace it.
if let existingSigningPublicKey, existingSigningPublicKey != announcedSigningPublicKey {
return .reject(.signingKeyMismatch)
}
guard hasSignature else { guard hasSignature else {
return .reject(.missingSignature) return .reject(.missingSignature)
} }
+13 -4
View File
@@ -1,6 +1,13 @@
import Foundation import Foundation
struct BLEAnnounceThrottle { /// Thread-safe announce admission state.
///
/// Announce requests originate from the Bluetooth delegate queue, the
/// concurrent message queue, and the maintenance timer. Keeping the timestamp
/// behind a lock makes admission and maintenance snapshots atomic when those
/// request sources race.
final class BLEAnnounceThrottle: @unchecked Sendable {
private let lock = NSLock()
private var lastSent: Date private var lastSent: Date
private let normalMinimumInterval: TimeInterval private let normalMinimumInterval: TimeInterval
private let forcedMinimumInterval: TimeInterval private let forcedMinimumInterval: TimeInterval
@@ -16,12 +23,13 @@ struct BLEAnnounceThrottle {
} }
func elapsed(since now: Date) -> TimeInterval { func elapsed(since now: Date) -> TimeInterval {
now.timeIntervalSince(lastSent) lock.withLock { now.timeIntervalSince(lastSent) }
} }
mutating func shouldSend(force: Bool, now: Date) -> Bool { func shouldSend(force: Bool, now: Date) -> Bool {
lock.withLock {
let minimumInterval = force ? forcedMinimumInterval : normalMinimumInterval let minimumInterval = force ? forcedMinimumInterval : normalMinimumInterval
guard elapsed(since: now) >= minimumInterval else { guard now.timeIntervalSince(lastSent) >= minimumInterval else {
return false return false
} }
@@ -29,3 +37,4 @@ struct BLEAnnounceThrottle {
return true return true
} }
} }
}
+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 {
+127 -24
View File
@@ -14,6 +14,10 @@ struct BLEFileTransferHandlerEnvironment {
let localNickname: () -> String let localNickname: () -> String
/// Snapshot of known peers keyed by ID (registry read). /// Snapshot of known peers keyed by ID (registry read).
let peersSnapshot: () -> [PeerID: BLEPeerInfo] let peersSnapshot: () -> [PeerID: BLEPeerInfo]
/// Verifies a packet's signature against a candidate signing key (registry path).
let verifyPacketSignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
/// Local signing key used to authenticate our own gossip-sync replays.
let localSigningPublicKey: () -> Data
/// Resolves a display name from a verified packet signature for peers missing from the registry. /// 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.
@@ -44,47 +48,105 @@ final class BLEFileTransferHandler {
self.environment = environment self.environment = environment
} }
func handle(_ packet: BitchatPacket, from peerID: PeerID) { /// Returns `false` when the raw packet fails sender authentication (or is
/// a live self-echo) and must not be relayed onward. Authentication runs
/// before the routing decision, so a forged directed packet cannot use a
/// node that is not its recipient as an unsigned forwarding hop.
@discardableResult
func handle(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
let env = environment let env = environment
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return } let localPeerID = env.localPeerID()
let peersSnapshot = env.peersSnapshot() let peersSnapshot = env.peersSnapshot()
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
peerID: peerID, guard let senderNickname = authenticatedRawSenderNickname(
localPeerID: env.localPeerID(), packet: packet,
localNickname: env.localNickname(), from: peerID,
peers: peersSnapshot, peers: peersSnapshot,
allowConnectedUnverified: true env: env
) ?? env.signedSenderDisplayName(packet, peerID) 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 return false
} }
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: env.localPeerID()) else { if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: localPeerID) {
return 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 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 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 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 return false
} }
// BCH-01-002: Enforce storage quota before saving // BCH-01-002: Enforce storage quota before saving
@@ -97,27 +159,68 @@ final class BLEFileTransferHandler {
mime.defaultExtension, mime.defaultExtension,
mime.category.rawValue mime.category.rawValue
) else { ) else {
return 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
// defaults private messages to .sending, which the media views
// render as an in-flight send (empty reveal mask, disabled tap).
deliveryStatus: isPrivate
? .delivered(to: env.localNickname(), at: timestamp)
: nil
) )
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session) SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
env.deliverMessage(message) env.deliverMessage(message)
return true
}
/// Every remaining raw file transfer is signed, regardless of whether it
/// is broadcast, addressed to us, or merely passing through. Registry
/// signing keys are preferred; persisted identities cover peers that have
/// rotated or are not currently present in the registry.
private func authenticatedRawSenderNickname(
packet: BitchatPacket,
from peerID: PeerID,
peers: [PeerID: BLEPeerInfo],
env: BLEFileTransferHandlerEnvironment
) -> String? {
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(
peerID: peerID,
localPeerID: localPeerID,
localNickname: env.localNickname(),
peers: peers,
// The packet signature authenticates the announced peer; the old
// connected-but-unsigned leniency is not involved.
allowConnectedUnverified: true
) ?? signedDisplayName ?? BLEPeerSenderDisplayName.anonymousNickname(for: peerID)
} }
} }
@@ -61,7 +61,6 @@ struct BLEFragmentAssemblyBuffer {
} }
private struct Metadata { private struct Metadata {
let type: UInt8
let total: Int let total: Int
let timestamp: Date let timestamp: Date
let isBroadcast: Bool let isBroadcast: Bool
@@ -150,7 +149,6 @@ struct BLEFragmentAssemblyBuffer {
fragmentsByKey[header.key] = [:] fragmentsByKey[header.key] = [:]
metadataByKey[header.key] = Metadata( metadataByKey[header.key] = Metadata(
type: header.originalType,
total: header.total, total: header.total,
timestamp: now, timestamp: now,
isBroadcast: header.isBroadcastFragment, isBroadcast: header.isBroadcastFragment,
@@ -203,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
} }
+256 -8
View File
@@ -2,17 +2,235 @@ import BitLogger
import BitFoundation import BitFoundation
import Foundation import Foundation
struct BLEIncomingFileStore { struct PanicRecoveryIntent {
private static let quotaBytes: Int64 = 100 * 1024 * 1024 let fileMarkerEstablished: Bool
let externalMarkerEstablished: Bool
private let fileManager: FileManager var hasDurableMarker: Bool {
fileMarkerEstablished || externalMarkerEstablished
}
}
/// Small, dependency-injectable transaction surface used by ChatViewModel.
/// Production persists the same intent in two independent locations before
/// any application state is erased. Tests can inject an ephemeral operation
/// set without touching the developer's Application Support directory.
struct PanicRecoveryOperations {
let isPending: () throws -> Bool
let begin: () -> PanicRecoveryIntent
let wipeMedia: (PanicRecoveryIntent) throws -> Void
let complete: () throws -> Void
static func ephemeral(
wipeMedia: @escaping () throws -> Void = {}
) -> PanicRecoveryOperations {
PanicRecoveryOperations(
isPending: { false },
begin: {
PanicRecoveryIntent(
fileMarkerEstablished: false,
externalMarkerEstablished: false
)
},
wipeMedia: { _ in try wipeMedia() },
complete: {}
)
}
static func live(
fileStore: BLEIncomingFileStore = BLEIncomingFileStore(),
defaults: UserDefaults = .standard
) -> PanicRecoveryOperations {
let defaultsKey = "bitchat.panicResetPending"
return PanicRecoveryOperations(
isPending: {
if defaults.bool(forKey: defaultsKey) {
return true
}
return try fileStore.isPanicRecoveryPending()
},
begin: {
defaults.set(true, forKey: defaultsKey)
let externalMarkerEstablished =
defaults.synchronize()
&& defaults.bool(forKey: defaultsKey)
let fileMarkerEstablished: Bool
do {
try fileStore.markPanicRecoveryPending()
fileMarkerEstablished = true
} catch {
fileMarkerEstablished = false
SecureLogger.error(
"Failed to persist file panic-recovery marker: \(error)",
category: .security
)
}
return PanicRecoveryIntent(
fileMarkerEstablished: fileMarkerEstablished,
externalMarkerEstablished: externalMarkerEstablished
)
},
wipeMedia: { intent in
try fileStore.panicWipe(
hasDurablePendingMarker: intent.hasDurableMarker
)
},
complete: {
// Keep the independent defaults latch until the file marker
// has definitely cleared. Any failure therefore remains
// visible to the next launch.
try fileStore.completePanicRecovery()
defaults.removeObject(forKey: defaultsKey)
guard defaults.synchronize(),
!defaults.bool(forKey: defaultsKey) else {
throw BLEIncomingFileStore.PanicRecoveryError
.externalMarkerCommitFailed
}
}
)
}
}
struct BLEIncomingFileStore {
enum PanicRecoveryError: Error {
case externalMarkerCommitFailed
case markerWriteFailed(Error)
case markerWriteAndMediaWipeFailed(
markerError: Error,
mediaError: Error
)
}
private static let quotaBytes: Int64 = 100 * 1024 * 1024
/// Kept outside `files/` so deleting the media tree cannot erase the
/// fail-closed startup decision before the full panic has committed.
private static let panicRecoveryPendingMarkerFileName =
".panic-recovery-pending"
/// Compatibility with a short-lived development build that used the
/// media-specific name for the same full-transaction latch.
private static let legacyPanicRecoveryPendingMarkerFileName =
".panic-media-wipe-pending"
private static let mediaSubdirectories = [
"voicenotes/incoming",
"voicenotes/outgoing",
"images/incoming",
"images/outgoing",
"files/incoming",
"files/outgoing"
]
/// Name prefix of in-flight live voice captures (progressively written by
/// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern
/// deleting one mid-stream unlinks the inode under an open `FileHandle`
/// and kills playback and the coordinator's startup sweep deletes any
/// orphans a previous session left behind.
static let liveCapturePrefix = "voice_live_"
/// Exposed so callers that write progressively into the store's
/// directories (live voice captures) share the same file manager.
let fileManager: FileManager
private let baseDirectory: URL? private let baseDirectory: URL?
private let dateProvider: () -> Date private let dateProvider: () -> Date
private let panicMarkerWriter: (Data, URL) throws -> Void
init(fileManager: FileManager = .default, baseDirectory: URL? = nil, dateProvider: @escaping () -> Date = Date.init) { init(
fileManager: FileManager = .default,
baseDirectory: URL? = nil,
dateProvider: @escaping () -> Date = Date.init,
panicMarkerWriter: @escaping (Data, URL) throws -> Void = {
try $0.write(to: $1, options: .atomic)
}
) {
self.fileManager = fileManager self.fileManager = fileManager
self.baseDirectory = baseDirectory self.baseDirectory = baseDirectory
self.dateProvider = dateProvider self.dateProvider = dateProvider
self.panicMarkerWriter = panicMarkerWriter
}
/// Panic-wipe every managed incoming and outgoing media artifact before
/// returning. Recreating the directory tree keeps later capture/receive
/// paths usable without allowing a detached cleanup task to race them.
///
/// Marker persistence and deletion are deliberately separate error
/// domains: even when both durable marker channels fail, deletion is
/// still attempted before this method reports the marker failure.
func panicWipe(
hasDurablePendingMarker: Bool = false
) throws {
let markerError: Error?
do {
try markPanicRecoveryPending()
markerError = nil
} catch {
markerError = error
SecureLogger.error(
"Could not persist file panic-recovery marker; attempting media deletion anyway: \(error)",
category: .security
)
}
do {
let filesDirectory = try rootDirectory()
.appendingPathComponent("files", isDirectory: true)
if fileManager.fileExists(atPath: filesDirectory.path) {
try fileManager.removeItem(at: filesDirectory)
}
for subdirectory in Self.mediaSubdirectories {
try fileManager.createDirectory(
at: filesDirectory.appendingPathComponent(
subdirectory,
isDirectory: true
),
withIntermediateDirectories: true,
attributes: nil
)
}
} catch {
if let markerError {
throw PanicRecoveryError.markerWriteAndMediaWipeFailed(
markerError: markerError,
mediaError: error
)
}
throw error
}
if let markerError, !hasDurablePendingMarker {
throw PanicRecoveryError.markerWriteFailed(markerError)
}
}
func markPanicRecoveryPending() throws {
let markerURL = try panicRecoveryPendingMarkerURL()
try fileManager.createDirectory(
at: markerURL.deletingLastPathComponent(),
withIntermediateDirectories: true,
attributes: nil
)
try panicMarkerWriter(Data([1]), markerURL)
}
func isPanicRecoveryPending() throws -> Bool {
try panicRecoveryMarkerURLs().contains {
fileManager.fileExists(atPath: $0.path)
}
}
func completePanicRecovery() throws {
for markerURL in try panicRecoveryMarkerURLs()
where fileManager.fileExists(atPath: markerURL.path) {
try fileManager.removeItem(at: markerURL)
}
}
/// Resolves (and creates) an incoming-media directory for callers that
/// write progressively instead of via `save` (live voice captures).
func incomingDirectory(subdirectory: String) throws -> URL {
let directory = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
return directory
} }
func save( func save(
@@ -39,6 +257,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 +295,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
@@ -90,15 +314,39 @@ struct BLEIncomingFileStore {
} }
private func filesDirectory() throws -> URL { private func filesDirectory() throws -> URL {
let root = try baseDirectory ?? fileManager.url( let filesDir = try rootDirectory().appendingPathComponent("files", isDirectory: true)
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
return filesDir
}
private func rootDirectory() throws -> URL {
try baseDirectory ?? fileManager.url(
for: .applicationSupportDirectory, for: .applicationSupportDirectory,
in: .userDomainMask, in: .userDomainMask,
appropriateFor: nil, appropriateFor: nil,
create: true create: true
) )
let filesDir = root.appendingPathComponent("files", isDirectory: true) }
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
return filesDir private func panicRecoveryPendingMarkerURL() throws -> URL {
try rootDirectory().appendingPathComponent(
Self.panicRecoveryPendingMarkerFileName,
isDirectory: false
)
}
private func panicRecoveryMarkerURLs() throws -> [URL] {
let root = try rootDirectory()
return [
root.appendingPathComponent(
Self.panicRecoveryPendingMarkerFileName,
isDirectory: false
),
root.appendingPathComponent(
Self.legacyPanicRecoveryPendingMarkerFileName,
isDirectory: false
)
]
} }
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String { private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
@@ -78,11 +78,22 @@ struct BLEIngressLinkRegistry {
return .failure(.selfLoopback(packetType: packet.type)) return .failure(.selfLoopback(packetType: packet.type))
} }
if let boundPeerID, if let boundPeerID, boundPeerID != claimedSenderID {
boundPeerID != claimedSenderID, if requiresDirectSenderBinding(packet) {
requiresDirectSenderBinding(packet, directAnnounceTTL: directAnnounceTTL) {
return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID)) return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID))
} }
// A direct announce claiming a new sender on a bound link is either
// a spoof or a legitimate peer-ID rotation on a connection that
// outlived the old ID. Attribute it to the claimed sender and let
// it through: announces are self-authenticating, and only a
// signature-verified announce may rebind the link (BLEService).
if isDirectAnnounce(packet, directAnnounceTTL: directAnnounceTTL) {
return .success(BLEIngressPacketContext(
receivedFromPeerID: claimedSenderID,
validationPeerID: claimedSenderID
))
}
}
let receivedFromPeerID = boundPeerID ?? claimedSenderID let receivedFromPeerID = boundPeerID ?? claimedSenderID
let validationPeerID = packet.isRSR ? receivedFromPeerID : claimedSenderID let validationPeerID = packet.isRSR ? receivedFromPeerID : claimedSenderID
@@ -98,12 +109,15 @@ struct BLEIngressLinkRegistry {
return "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)" return "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
} }
private static func requiresDirectSenderBinding(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool { private static func requiresDirectSenderBinding(_ packet: BitchatPacket) -> Bool {
// REQUEST_SYNC is never relayed, so on a bound link the claimed sender // REQUEST_SYNC is never relayed, so on a bound link the claimed sender
// must be the link peer it elicits a full store replay, and the // must be the link peer it elicits a full store replay, and the
// response is addressed to whoever the sender claims to be. // response is addressed to whoever the sender claims to be.
if packet.type == MessageType.requestSync.rawValue { return true } packet.type == MessageType.requestSync.rawValue
return packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL }
static func isDirectAnnounce(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool {
packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL
} }
private static func isSelfAuthoredSyncResponse(_ packet: BitchatPacket) -> Bool { private static func isSelfAuthoredSyncResponse(_ packet: BitchatPacket) -> Bool {
+36 -4
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
@@ -203,17 +214,38 @@ final class BLELinkStateStore {
func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) { func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) {
assertOwned() assertOwned()
if updatePeripheral(peripheralUUID, { $0.peerID = peerID }) != nil { var previousPeerID: PeerID?
peerToPeripheralUUID[peerID] = peripheralUUID let updated = updatePeripheral(peripheralUUID) {
previousPeerID = $0.peerID
$0.peerID = peerID
} }
guard updated != nil else { return }
// Rebinding (peer-ID rotation): drop the retired ID's reverse mapping
// so the old peer no longer claims this link.
if let previousPeerID, previousPeerID != peerID,
peerToPeripheralUUID[previousPeerID] == peripheralUUID {
peerToPeripheralUUID.removeValue(forKey: previousPeerID)
}
peerToPeripheralUUID[peerID] = peripheralUUID
} }
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
} }
@@ -0,0 +1,55 @@
import BitFoundation
import Foundation
struct BLELocalIdentitySnapshot: Equatable, Sendable {
let peerID: PeerID
let peerIDData: Data
let nickname: String
}
/// Lock-backed local identity state shared by the transport's message,
/// Bluetooth, maintenance, and main-actor entry points.
///
/// `peerID` and its binary wire representation must change as one unit during
/// panic rotation. A snapshot also gives announce construction one consistent
/// view of the nickname and identity instead of reading three independently
/// mutable properties across queues.
final class BLELocalIdentityStateStore: @unchecked Sendable {
private let lock = NSLock()
private var state: BLELocalIdentitySnapshot
init(
peerID: PeerID = PeerID(str: ""),
nickname: String = "anon"
) {
state = BLELocalIdentitySnapshot(
peerID: peerID,
peerIDData: Data(hexString: peerID.id) ?? Data(),
nickname: nickname
)
}
func snapshot() -> BLELocalIdentitySnapshot {
lock.withLock { state }
}
func setNickname(_ nickname: String) {
lock.withLock {
state = BLELocalIdentitySnapshot(
peerID: state.peerID,
peerIDData: state.peerIDData,
nickname: nickname
)
}
}
func replacePeerIdentity(with peerID: PeerID) {
lock.withLock {
state = BLELocalIdentitySnapshot(
peerID: peerID,
peerIDData: Data(hexString: peerID.id) ?? Data(),
nickname: state.nickname
)
}
}
}

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