Compare commits

..
Author SHA1 Message Date
14e7b428d9 Add a real security policy (#1489)
* Add a real security policy

Two drive-by template PRs (#1118, #1482) tried to fill this gap with
unedited boilerplate. This is the actual policy: private vulnerability
reporting (now enabled on the repo) as the channel, honest expectations
for a volunteer project, and a scope section that separates the
properties the app promises from the documented design behaviors that
keep getting reported as vulnerabilities.

Closes #1081

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

* Address Codex review: name the Nostr envelope format precisely

'Gift-wrapped' reads as NIP-59, and the scope section is exactly where
a researcher calibrates expectations. Say what it is: bitchat's own
private-envelope scheme, explicitly not NIP-17/44/59.

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-26 21:33:59 +02:00
c671e3df66 Keep the composer focused after sending with the return key (#1490)
* Keep the composer focused after sending with the return key

On iOS, return sends and then drops the keyboard, so every message in a
back-and-forth costs an extra tap to reopen it. sendMessage() is the
onSubmit handler; reasserting the FocusState there keeps the keyboard up
between messages. macOS already refocuses on appear and is unaffected.

Fixes #457

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

* Address Codex review: refocus only on return-key submission

The reassert moves from sendMessage() into the TextField's onSubmit, so
the send button no longer reopens a deliberately dismissed keyboard on
iOS and never moves focus on macOS.

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-26 21:19:12 +02:00
132120a88e Close the three holes the #1486 review found (#1488)
* Fix the three follow-ups from the #1486 review

Three defects shipped with the censorship-resilience merge, all
confirmed against main:

1. Source-manifest verification silently accepted added files.
   shasum -c checks only the files the manifest lists, and the Xcode
   project compiles every source file present in the tree — so a
   hostile mirror could pass verification by adding a file rather than
   modifying one. The manifest header and VERIFYING-A-BUILD.md now
   require the completeness check (git status --porcelain, or a path
   diff for tarballs) alongside the hash check.

2. A relay removed while Tor was bootstrapping reconnected anyway.
   dropRelays never subtracted from pendingTorConnectionURLs, and a
   custom relay passes the allow-list filter, so draining the pending
   queue resurrected a relay someone had explicitly deleted.

3. Turning Tor off mid-bootstrap read as 'network may be blocking tor'.
   shutdownCompletely left the detached 75s poll loop running, which
   then stamped bootstrapDidStall over the clean shutdown state; and
   the stall handler guarded on torEnforced, which is compile-time true
   in release, instead of the runtime preference. The poll loop is now
   generation-fenced (shutdown, dormancy, and restart each invalidate
   it) and the handler consults persistedTorPreference().

Both app-side fixes carry regression tests proven to fail pre-fix.

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

* Address Codex review: ignored files and manifest placement

git status --porcelain omits ignored paths, and .gitignore covers
build/ — a planted bitchat/build/Evil.swift would compile via the
synchronized group while the documented check stayed silent. The
checkout check now uses --ignored.

The downloaded manifest also has to live outside the tree, or it trips
the completeness checks itself; the doc now says so and references it
at /tmp throughout.

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-26 21:01:38 +02:00
c1ce9029d8 Keep working when the network is hostile, and make the app verifiable (#1486)
Tier 3 of a protest-hardening review. The BLE mesh is already properly
fenced off from network reachability and needs nothing here; every gap is
on the internet side, or in how someone gets a build they can trust.

Say when Tor is blocked. The bootstrap poll loop simply ended at its
75-second deadline, leaving isStarting true with no further state, so a
network that blocks Tor was indistinguishable from a slow one and the UI
said "starting tor…" indefinitely. TorManager now exposes
bootstrapDidStall and posts .TorBootstrapDidStall, and the app reports
that mesh messaging still works while internet delivery is paused. It is
cleared on each new start or restart, so a later attempt can report again.

Let relays be added by hand. The four built-in relays are well-known
clearnet hostnames, which is four names for a censor to block and no
recourse short of a new build. NostrRelaySettings persists up to eight
additional relays, normalized, .onion accepted, with a settings editor.
They join the same target set as the built-ins and are subject to the
same activation policy. Removal reconciles against the previous set:
the teardown path iterates the current targets, so without that a removed
relay's socket and queued sends would linger — covered by a test that
fails without it. The merged list is cached rather than recomputed,
because allowedRelayList consults it once per candidate URL and would
otherwise read UserDefaults inside that loop.

Stop stranding people who denied location. The activation gate required
location permission or a mutual favorite, but teleporting into a geohash
requires neither, so someone with no permission and no favorites could
sit in a channel that never connected while Tor and the relays stayed
suppressed and nothing said why. Being in a location channel is now a
third arm of the gate, in both the activation service and the relay
manager's copy of the policy, and leaving the channel closes it again.

Stop burning the Tor timeout when Tor is off. GeoRelayDirectory awaited
Tor readiness unconditionally, but with the preference off TorManager has
been shut down, so every refresh spent the full bootstrap deadline and the
directory froze on its cached copy. It now keys on the preference, not on
live readiness: Tor wanted but unavailable must still skip the fetch
rather than fall back to clearnet.

Say what turning Tor off costs. The toggle's copy described it as
hiding your IP "for location channels", understating both scope and
consequence. It now names private messages too, and while the toggle is
off the settings screen states that every relay can see the device IP.

Make builds verifiable. There was no release verification of any kind:
no signatures, no checksums, no documented procedure. Post-takedown that
is the acute gap, because mirrors appear and people install whatever they
can find during a shutdown. source-manifest.yml publishes a per-tag
SHA-256 manifest with a provenance attestation, self-checking before it
publishes, and docs/VERIFYING-A-BUILD.md explains how to verify source and
states plainly that compiled builds from anywhere but the App Store cannot
be verified. It also records the gaps honestly: no published signing key,
no reproducible build, no non-GitHub mirror.

docs/TOR-INTEGRATION.md was substantially stale — it documented a
torrc, SOCKSPort and ControlPort that the in-process Arti client does not
use, and claimed there are no user-visible settings — so it is rewritten,
including the deferred gap below.

Deferred: no Tor bridges or pluggable transports. arti-client is built
without pt-client or bridge-client and bootstraps from stock config, so
in a country that blocks Tor outright there is still no circumvention
path — only a clear report that there isn't. Closing it needs the Rust
features, bridge config through the FFI, and an xcframework rebuild under
the pinned toolchain with a provenance update, which is its own change.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 19:45:37 +02:00
c079d2ab5d Harden what a locked or seized device gives away (#1484)
* Harden what a locked or seized device gives away

The realistic compromise for many of the people this app is built for is
not interception but a phone taken, and often unlocked under coercion.
Content encryption is in good shape; these are the gaps around it.

Hide notification previews, by default. Notification content is rendered
by the system on the lock screen, so it was readable without unlocking:
DM alerts carried the sender's nickname and the full message body, and
geohash alerts put the geohash in the title. Alerts now state that a DM,
mention, or location-channel activity arrived and withhold the rest until
the app is opened. userInfo still carries the routing peer ID and deep
link, neither of which the system displays, so taps land where they did.
A settings toggle restores full previews for anyone who wants them.
Default-on is the deliberate part: a phone face-up on a table should not
narrate conversations, and someone who wants previews can say so.

Cover the window on willResignActive, so the snapshot iOS stores for the
app switcher shows a placeholder rather than an open conversation. Opaque
rather than blurred, because blurred large text stays partly legible and
the snapshot goes to disk. Added synchronously from a UIKit notification
with queue: nil, since the capture follows shortly after and an
OperationQueue hop or a SwiftUI state change can lose that race. Panic
wipe already deleted snapshots already on disk; this stops new ones from
being worth deleting.

Bound media by age as well as size. The 100 MB quota only ever considered
incoming files, so outgoing media had no lifetime at all and a received
photo could outlive its conversation indefinitely. A launch-time sweep now
deletes managed media older than seven days, incoming and outgoing, with
the same exemptions quota eviction honors: in-flight live captures and
files reserved by a delivery or deletion in progress.

Make /clear tell the truth on the mesh timeline. It recorded an echo
watermark and left the gossip archive on disk for up to 6 hours, so
someone who cleared before a police stop had deleted nothing. Clearing now
erases the archive too. The watermark still matters: it suppresses
pre-clear messages this device hears again from peers. The cost is that
the device stops serving recent public backlog until it hears fresh
traffic, which is a fair reading of what clearing a timeline means.

Documented in PRIVACY_POLICY.md and the privacy assessment, including a
new section on what is deliberately NOT addressed: there is still no
duress mechanism of any kind (no decoy passphrase, no wipe-on-failed-auth,
no app lock), macOS gets no file-protection classes, and media is not
sealed at the app layer. The duress question is a product decision as much
as an engineering one, since in some jurisdictions destroying data on
demand is itself an offence and hiding may protect someone better than
destroying, so it is called out rather than guessed at.

Three findings from the audit that prompted this work turned out to be
already fixed on main and are not included: keychain accessibility is
AfterFirstUnlockThisDeviceOnly with a retrying migration, the panic media
wipe uses a two-location durable marker transaction, and panic already
discards staged share-extension content.

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

* Inject the previews preference instead of reading shared defaults

CI caught a real problem this introduced. `NotificationServiceTests`
already asserted the full-preview title and body, and both it and the new
redaction tests read `NotificationPrivacySettings` from
`UserDefaults.standard` in the same process. Whichever ran second
depended on the other's cleanup, so it passed locally and failed in CI:

  XCTAssertEqual failed: ("🔒 new dm") is not equal to ("🔒 DM from Alice")

Fixed at the source rather than by ordering or serialization.
`NotificationService` now takes a `hidePreviewsProvider`, defaulting to
the real preference, so each test states which behavior it asserts. The
pre-existing test asks for previews shown and gains a redacted
counterpart; the redaction tests no longer touch the shared store.

`NotificationPrivacySettings` also gained store-injecting accessors so
the default-value and round-trip assertions can use an isolated suite
rather than mutating preferences other tests read.

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

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 19:26:29 +02:00
229a41557e docs: correct inaccurate privacy and metadata claims (#1485)
* docs: correct inaccurate privacy and metadata claims

Several documented guarantees did not match the implementation. These
matter more than ordinary doc drift: someone deciding whether to carry
this phone to a protest reads these sentences as the threat model.

- Peer IDs were described as "short ephemeral IDs derived per session"
  that "rotate periodically" and "prevent tracking". They are the first
  8 bytes of the Noise static key fingerprint, stable across sessions
  and reboots, and replaced only by a panic wipe. Corrected in the
  whitepaper (§3, §8), IdentityModels, and BitchatProtocol, whose
  header notes claimed "no persistent identifiers in protocol headers"
  while every header carries exactly one.
- "No plaintext message content is ever written to disk" was false for
  accepted media, which is stored unsealed under the platform's
  data-protection class. Narrowed to what actually holds.
- Padding was described as applying to all packets but fragments. Only
  noiseEncrypted and noiseHandshake frames are padded; the pad bytes
  equal the pad length rather than being random; and because that
  length must fit one byte, a frame needing over 255 bytes of padding
  is emitted unpadded. Documented in the whitepaper (§4.1) and
  MessagePadding.
- The gossip archive window is 6 hours in production, not the
  15 minutes claimed in PRIVACY_POLICY.md and the privacy assessment.
  The 15-minute figure is the struct default that BLEService overrides.
- The privacy assessment credited iOS BLE address randomization without
  noting that stable app-layer identifiers defeat it.

The whitepaper's future-work list now names the changes these
corrections imply: rotating on-air identity, padding for non-Noise
types, and making the announce neighbor list optional.

No behavior change; comments and documentation only.

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

* docs: correct the same claims in the README

The README repeats two of the claims corrected elsewhere in this PR, and
it is the document people actually read before deciding to trust the app.

- "no persistent identifiers" is the inverse of what the mesh does; it
  now points at the whitepaper's identity and metadata sections.
- "end-to-end encryption with forward secrecy" holds for live Noise
  sessions but not for sealed store-and-forward mail, which the
  whitepaper already flags as its main cryptographic trade-off.

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

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 19:02:26 +02:00
jackandGitHub 934b2cd2d3 Harden iOS-sim CI: destination fallback + Noise reconnect test determinism (#1483)
Two CI-robustness fixes for the iOS-simulator job seen on main:

1. The destination picker no longer dies on runner images with placeholder/unavailable simulator lists (macos-26-arm64 20260720.0258): it filters error/unavailable rows, falls back to xcrun simctl's available-device list, and as a last resort creates a simulator from the newest installed iOS runtime's own supported iPhone device types. Each path logs which route was taken.

2. Deflakes NoiseEncryptionServiceTests' quarantine-restore test: the injected 20ms responder timeout also armed during the SETUP handshake, so a loaded runner tore down the half-open responder session before the scenario began (production quarantine/restore verified correct — the failure signature was a fresh msg2 to a session-less peer). Timeout raised to 1s and the fixed 100ms sleep replaced with a bounded waitUntil on the restore condition. No assertion weakened; 10/10 sequential + 3/3 under full-core CPU load.
2026-07-26 16:13:04 +02:00
jackandGitHub a1711bd399 Retry unacknowledged DMs after Noise replacement (#1462)
DMs sent through an established Noise session are retained in the durable MessageRouter outbox until an authenticated delivery/read ack, and retried under the same message ID when the peer's replacement handshake authenticates — fixing DMs silently lost into stale local sessions after a remote app restart. Mesh ack handling is peer-scoped end-to-end (alias-scoped delivery status, peer-bound markDelivered, PeerMessageKey retry state), so a colliding message ID from another conversation can no longer clear or promote foreign state; bridge-drop dedup keys are recipient-scoped with hashed persistence.

Includes review fix: acks arriving after a relaunch (durable outbox restored, conversation not) now clear the peer-scoped router entry unconditionally — only the UI status transition is gated on conversation presence — so a delivered message can no longer re-send to the attempt cap and be marked failed despite delivery. Regression test simulates the relaunch through real store persistence; the dead allowedPeerIDs parameter (unscoped-tombstone trap) is removed.
2026-07-26 15:37:50 +02:00
jackandGitHub a4d294015a Keep DMs canonical across transport aliases (#1461)
Joins the two authenticated aliases of one account — the full noise-key conversation ID used by the Nostr path and its derived 16-hex mesh short ID — into one canonical conversation, migrating messages and handing off an open DM sheet (short ID while connected, stable ID offline). Delivery/read-ack outbox removal is scoped to the authenticated peer's aliases with per-peer tombstones, so a colliding message ID queued for a different peer survives, including across protected-data cold-load recovery. Geohash DMs never enter the alias path (their conversation keys carry no noise key).
2026-07-26 15:03:58 +02:00
jackandGitHub 660632ef6b Keep open DMs alive across Bluetooth settings (#1460)
Presenting the root Bluetooth alert while the people/DM sheet was up forced SwiftUI to dismiss the sheet, whose binding setter called endConversation() — destroying the open DM. The root alert is now gated behind a modal-presentation guard (scene active, no competing modal), a second copy presents from inside the sheet, and the sheet-dismissal setter only ends the conversation when the dismissal is genuinely explicit.

Includes review fix: the voice-recording error alert (mic permission denied inside an open DM) destroyed the DM through the exact same path — it now participates in the same guard and gating pattern at both root and sheet level.
2026-07-26 14:50:03 +02:00
jackandGitHub c72bb4ca2e Make private-media deletion transactional (#1468)
Receiver-side private-media deletion (per-bubble delete and /clear) becomes a write-ahead transaction: a deletion journal in BLEPrivateMediaReceiptStore is the atomic commit point, materialization (tombstones + payload unlinks) is idempotent and retried on recovery, path reservations in BLEIncomingFileStore prevent delete racing an in-flight arrival, and overlapping /clear operations are serialized with panic-generation invalidation. Integrates with the receipt quarantine (a pending journal entry outranks quarantine; materialization never resurrects a quarantined ID).

Review fixes included: (1) /clear no longer deletes outgoing media mirrored into another conversation (alias protection now mirrors the incoming path, with a regression test that fails pre-fix); (2) explicit delete of legacy incoming media actually unlinks the decrypted payload when unreferenced — gated on pending-delivery/reservation state, restoring main's delete semantics safely instead of leaving plaintext for quota cleanup; (3) refused deletions surface a localized system message in the affected chat instead of failing silently (30-locale key). Full local suite 1876 green.
2026-07-26 14:33:12 +02:00
jackandGitHub d6bd4f0681 Retry confirmed private media after reconnect (#1467)
Sender-side retry of private media whose local BLE fragment completion never received a remote delivery receipt: the encrypted file packet is retained in memory (bounded: 8 packets / 4MB / 120s / 2 retries per message / 2 per reconnect) and re-sent on peer reconnect or re-authentication, gated at four boundaries on capability bit 9 plus the exact Noise session generation, so legacy or bit-8-only peers can never receive a retry and the receiver's stable-ID ledger dedups anything re-sent. Remote delivery/read receipts release retention; limits and expiry end in a visible failed status.

Includes review fix: a dropped policy-resolution callback can no longer wedge retries for a peer — the pending resolution is cleared on peer disconnect (with a regression test proving recovery).
2026-07-26 14:15:12 +02:00
jackandGitHub fb451bc6d0 Persist authenticated private-media delivery receipts (#1466)
Adds a durable receiver-side ledger (BLEPrivateMediaReceiptStore) mapping a deterministic private-media message ID — hash-bound to sender, recipient, and entropy-bearing filename — to the stored file before UI delivery and before the Noise-encrypted delivery ACK, so sender retries and relaunches cannot create duplicate bubbles or files. Receipts are unforgeable without the Noise session; capability bit 9 rides the existing announce bitfield (no wire-format change; rolling upgrade safe both directions).

Includes review fixes: corrupt receipt records are quarantined per-record (bytes preserved at <id>.json.corrupt, only that ID fail-closed — previously one bad record silently disabled ALL inbound private media forever); the panic reset now reaches BLEService's own store instance via completePanicReset with a production-wiring test; and both content.delivery.reason.* strings ship with full 30-locale coverage.
2026-07-26 14:00:21 +02:00
jackandGitHub a9ceddab21 Deliver live DM transport events synchronously (#1465)
Removes the nested-Task deferral when typed transport events reach ChatViewModel: didReceiveTransportEvent now dispatches to @MainActor *Synchronously coordinator methods inline within the single notifyUI main-actor hop, fixing a real ordering race where a delivery-status update emitted right after a message could apply before the message existed in the ConversationStore and be dropped. Adds SynchronousMessageTransportEventDelegate so BLEService.deliverTransportEvent returns a documented accepted/unconfirmed verdict for .messageReceived (consumed by the private-media receipt layer above). Intra-main-actor only — no cross-queue sync added; the bleQueue-never-blocks-on-main invariant is preserved.
2026-07-26 13:47:14 +02:00
jackandGitHub e3e97d51ec Preserve early Noise ciphertext across reconnect promotion (#1464)
Companion to #1463 (they were built as a unit): adds a bounded deferral buffer (4/peer, 32 global, byte-budgeted, responder-timeout lifetime) for ciphertext that arrives before XX message 3 or before BLE installs generation-bound transport state, retried exactly once after the serialized authentication/restore callback. Reclassifies malformed/forged/replayed/oversized/rate-limited ciphertext as drop-only, so attacker bytes can no longer evict an established session (on main, a 1-byte forgery evicts the session via the old clearSession + re-handshake path). Serializes Noise packet reception as messageQueue barriers.

Honors #1463's timeout-restore deferral through the same-generation ready path (verified by negative test: without the gate, parked DMs drain under discarded keys). Full local suite 1789 tests green.
2026-07-26 13:30:06 +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
jackandClaude Fable 5 3f677776bb Mark AckPacer @unchecked Sendable
Its mutable state is confined to the serial pacer queue; the annotation
silences the capture-of-non-Sendable warning in the scheduler callback
(NostrTransport.swift:123) under the app target's concurrency checking.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 21:13:48 +02:00
jackandClaude Fable 5 5dffb04912 Bump version to 1.6.0
BLE background presence (wake-on-proximity pending connects), keychain
AfterFirstUnlock for locked-device operation, cross-launch gift-wrap dedup,
paced Nostr acks, GeoDM inbound dedup, and self-fragment sync fixes
(#1396-#1398).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 21:09:17 +02:00
81c45e9649 Reconnect hygiene: paced acks, persistent gift-wrap dedup, self-fragment sync fix (#1398)
* Reconnect hygiene: paced acks, persistent gift-wrap dedup, self-fragment sync fix

Remaining findings from the July 7 locked-phone test sessions, all rooted
in reconnect/relaunch behavior:

- All Nostr acks (READ and DELIVERED, direct and geohash) now flow through
  the paced queue that previously only throttled direct READ receipts.
  Reconnect redelivery produced 8 DELIVERED acks in under a second, which
  damus rejects ("noting too much").

- Processed gift-wrap event IDs persist across launches
  (NostrProcessedEventStore, wired through MessageDeduplicationService,
  debounced writes, wiped on the existing clear/panic paths). NIP-59
  randomizes gift-wrap timestamps, so the 24h-lookback DM subscriptions
  redeliver the same events every launch; without a cross-launch record
  each relaunch reprocessed old PMs and acks — the re-ack bursts and the
  "delivered ack for unknown mid" warnings (now debug: a stale ack is
  expected occasionally and not actionable).

- Own fragments handed back by sync replay (the deliberate RSR ttl=0
  restore path) now re-enter the gossip sync store before the self-drop.
  The fragment store is not archived, so after a relaunch our sync filter
  did not cover our own fragments and peers re-offered them every 30s
  round indefinitely; recording them stops the redelivery after one round
  while keeping assembly skipped.

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

* Address Codex review on #1398: shared ack pacer, transient clears keep disk record

- Geohash acks are sent through short-lived NostrTransport instances
  (makeGeohashNostrTransport creates one per ack), so the per-instance ack
  queue never paced a burst. Acks now flow through a pacer shared across
  instances: Dependencies.live wires the process-wide sharedAckPacer, and
  the default Dependencies init builds an isolated pacer from the same
  injected scheduleAfter so tests keep stepping the throttle manually.

- clearNostrCaches() runs on every geohash channel switch, so it no longer
  wipes the persisted gift-wrap record (that stays on the clearAll/panic
  path). Persistence is now append-merge instead of snapshot-overwrite —
  serialized on the store's IO queue — so a transient in-memory clear
  between debounced flushes can't shrink the on-disk record either.

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-07 21:08:10 +02:00
963d3a089f Device-test findings: keychain lock access, GeoDM dedup, sync log clarity (#1397)
Three findings from the July 7 locked-phone test sessions:

- Keychain items move from WhenUnlocked to AfterFirstUnlock: the mesh keeps
  running while the device is locked (identity-cache saves failed with
  -25308 throughout testing), and a wake-on-proximity relaunch via BLE
  state restoration must read the noise keys before the user unlocks. A
  one-time SecItemUpdate migration upgrades existing items (retried on next
  launch if the device is locked); backup semantics unchanged.

- GeoDM inbound messages dedup by message ID at the handler: outbox retries
  re-wrap the same message in fresh gift-wrap events, so relay-level
  event-ID dedup can't catch them and every copy ran full processing
  (3-6x per message observed). DELIVERED acks still go through
  markGeoDeliveryAckSent first, so re-sent copies from a lost ack are
  still answered.

- Periodic gossip sync logs now name the type group ("message+fragment").
  The five per-type schedules log identical lines when several fire in one
  maintenance tick, which reads as duplicated sends (misdiagnosed twice
  during testing).

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 20:09:01 +02:00
f79c2e3e9f BLE background presence: pending-connect wake-on-proximity + wake-window maintenance (#1396)
* BLE background presence: pending-connect wake-on-proximity + wake-window maintenance (#1395)

iOS cancels nothing for us: pending CBCentralManager connects never expire,
complete whenever the peer reappears in range, and relaunch the app via the
existing state-restoration path. Use that as the wake-on-proximity mechanism:

- BLERecentPeripheralCache: retains handles to recently seen/dropped
  peripherals (LRU 16, 15 min max age to respect BLE address rotation)
- On backgrounding, arm indefinite pending connects to cached peripherals
  within a slot budget (2 of 6 central slots reserved for live background
  discovery); armed entries carry lastConnectionAttempt == nil so a quick
  background/foreground bounce can't strand them as connecting
- The 8s app-level connect timeout defers while backgrounded so
  discovery-driven background connects also stay pending
- Foreground return cancels stale pending connects (including connecting
  entries rebuilt by state restoration after a relaunch) and hands control
  back to the scanner/scheduler
- A link dropped while backgrounded re-arms after the disconnect-settle
  window, so a peer walking away and returning wakes us again
- Packet ingress while backgrounded triggers a catch-up maintenance pass
  (announce/flush/drain) since the maintenance timer is suspended with the
  app; rate-limited to the normal 5s cadence

Battery cost ~0: pending connects live in the controller's allowlist (no
scanning, no app CPU), and the catch-up pass only runs inside wake windows
the radio already granted.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Restore: seed wake-on-proximity cache and resume service discovery

Field finding from on-device testing: after a state-restoration relaunch,
the recent-peripheral cache starts empty, so backgrounding shortly after a
restore armed no pending connects. Seed the cache from the restored
peripherals — they are the freshest proximity candidates we have.

Also resume service discovery for peripherals restored as connected with no
characteristic: the CBCharacteristic reference dies with the old process,
and without rediscovery the link sits connected-but-unusable until the peer
drops it.

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

* Defer restored-link service rediscovery until poweredOn

Field finding: CBPeripheral.discoverServices issued inside willRestoreState
fires before the central manager reaches poweredOn — CoreBluetooth drops the
command with an API MISUSE warning, leaving restored-connected links
characteristic-less after all. Move the rediscovery to
centralManagerDidUpdateState(.poweredOn), which restoration guarantees runs
afterwards.

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

* Let disconnect re-arms use the freed slot (Codex P2 on #1396)

The background-entry arm reserves 2 of 6 central slots for live discovery,
but the disconnect re-arm path shared that budget: with 4+ links remaining
the budget hit zero and the just-dropped peer was never armed — defeating
walk-away/walk-back re-arming in dense meshes. The disconnect path now arms
with no reserve, consuming the slot the disconnect itself freed.

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-07 19:54:10 +02:00
jack 0c3136282e Revert "BLE background presence: pending-connect wake-on-proximity + wake-window maintenance (#1395)"
This reverts commit b043324035.
2026-07-07 18:21:49 +02:00
b043324035 BLE background presence: pending-connect wake-on-proximity + wake-window maintenance (#1395)
iOS cancels nothing for us: pending CBCentralManager connects never expire,
complete whenever the peer reappears in range, and relaunch the app via the
existing state-restoration path. Use that as the wake-on-proximity mechanism:

- BLERecentPeripheralCache: retains handles to recently seen/dropped
  peripherals (LRU 16, 15 min max age to respect BLE address rotation)
- On backgrounding, arm indefinite pending connects to cached peripherals
  within a slot budget (2 of 6 central slots reserved for live background
  discovery); armed entries carry lastConnectionAttempt == nil so a quick
  background/foreground bounce can't strand them as connecting
- The 8s app-level connect timeout defers while backgrounded so
  discovery-driven background connects also stay pending
- Foreground return cancels stale pending connects (including connecting
  entries rebuilt by state restoration after a relaunch) and hands control
  back to the scanner/scheduler
- A link dropped while backgrounded re-arms after the disconnect-settle
  window, so a peer walking away and returning wakes us again
- Packet ingress while backgrounded triggers a catch-up maintenance pass
  (announce/flush/drain) since the maintenance timer is suspended with the
  app; rate-limited to the normal 5s cadence

Battery cost ~0: pending connects live in the controller's allowlist (no
scanning, no app CPU), and the catch-up pass only runs inside wake windows
the radio already granted.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 18:18:41 +02:00
bbe5e1ef4e Fix reachability-gate races flagged by Codex on #1389 (#1394)
P1: TorManager.shutdownCompletely() resets didStart asynchronously
(after Arti has actually stopped, up to ~5s later). A brief
offline->online flap could call startIfNeeded() inside that window;
the guard on didStart dropped it, and nothing reevaluated afterwards,
so Tor stayed down while activationAllowed was true. Track shutdowns
in flight and record a deferred start, honored when the last shutdown
finishes (still gated on allowAutoStart/foreground at that point).

P2: NWPathReachabilityMonitor.ingest() cancelled and rescheduled the
flush a full debounce interval from "now" on every observation, even
duplicates (e.g. interface detail changes while still unsatisfied).
ReachabilityDebounce already preserves the original pending.since, so
schedule the flush for the remaining time to the true deadline instead
of restarting the window.

Tests: debounce deadline preservation (pure) + a monitor-level timing
test that a mid-window duplicate does not postpone the offline commit.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 18:09:30 +02:00
28ffc53f3f i18n: fill all locale gaps so no string falls back to English (#1393)
Follow-up to #1391 — the Codex review flagged that the new feature-string
batch stopped at vi, omitting fil, pt-BR, zh-Hans, and zh-Hant.

- Translate the 137 feature strings into fil, pt-BR, zh-Hans, zh-Hant (548 entries)
- Translate fingerprint.message.vouched_by (plural) into the 16 locales it
  was missing, with CLDR-correct plural categories per language
- Add the 13 locales missing from the share extension catalog (78 entries),
  bringing it to the same 29 locales as the main app
- Mark the '#%@' channel-hashtag format as shouldTranslate=false like '%@'
- Add LocalizationCoverageTests: fails if any translatable key in either
  catalog is missing any supported locale, or if the share extension
  supports fewer locales than the main app

Machine translations marked needs_review, matching #1391. Verified no
existing translation was altered; full suite (1348 tests) green.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:57:05 +02:00
8415c52913 Unified notices: one pin, one sheet for board pins + location notes (#1392)
* Unified notices: merge board pins and location notes into one sheet

One pin icon in the header now opens a single Notices sheet with a
geo/mesh scope toggle, replacing the separate board, location-notes,
and mesh-only note buttons:

- geo tab: current geohash's notices — mesh-synced board posts merged
  and deduped with Nostr kind-1 location notes, with per-item mesh/net
  source badges. Scope follows the selected location channel, or the
  device's building geohash when chatting on mesh.
- mesh tab: mesh-local board only (fully offline).
- One composer: geo posts go to the board and bridge to Nostr (existing
  bridge), so mesh and internet see the same notice.
- Merged delete: tombstoning an own board post now also retracts the
  bridged Nostr copy via NIP-09 (new createDeleteEvent, bridged event
  ids tracked in BoardManager); own Nostr-only notes are deletable too.
- LocationNotesManager accepts any channel-precision geohash (1-12
  chars), not just building-level.

BoardView and LocationNotesView are superseded by NoticesView.

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

* Notices round 2: honest composer, friendlier copy, new-pin chat alerts

- Urgent + expiry controls now appear on the mesh tab only: the bridged
  Nostr copy of a geo post carries neither, so relay-side readers would
  never see them. Geo posts default to non-urgent with 7-day expiry, and
  the bridged note now gets a NIP-40 expiration tag so honoring relays
  drop it in step with the board copy.
- Geo tab explainer reuses the original location-notes description
  (keeps its 29 existing translations); mesh tab gets a new plain-
  language description.
- New-pin chat alerts, fully local (no wire traffic): BoardStore fires
  postArrivals for posts newly accepted from the wire; BoardAlertsModel
  filters own posts, dedups by postID, and for urgent pins created
  within the last 30 minutes emits one system line into the matching
  timeline (geo pin -> that geohash's chat, mesh pin -> mesh chat),
  collapsing simultaneous arrivals into a count line.
- Routine pins light up the header: the pin icon tints orange whenever
  the current scope has notices at all, and fills (pin.fill) while
  unseen new pins are waiting; opening the sheet clears them.

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

* i18n: translate the unified-notices strings into all 28 non-English locales

Adds the 13 new notices keys (sheet title, geo/mesh tabs, mesh
description, source badges, urgent alert lines, button tooltip and
accessibility strings) to the string catalog with translations for
every locale the app ships. The geo tab already reuses the fully
translated location_notes.description; this covers the rest. Insertion
preserves the catalog's case-insensitive key order, so the diff is
purely additive.

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

* Address Codex review: panic-wipe reset, scoped badge clear, geohash-aware dedupe

- BoardStore.wipe() now emits didWipe; BoardAlertsModel subscribes and
  resets, so a panic wipe drops pending urgent lines (which could
  otherwise re-append pre-wipe content into chat after the collapse
  flush), unseen badge scopes, and handled-post history.
- Opening the notices sheet clears unseen badges only for the scopes it
  actually shows (mesh + current geo scope); pins for other geohash
  channels keep their badge until visited.
- LocationNotesManager.Note now retains the matched g tag, and the
  bridged-copy dedupe requires the note's geohash to equal the board
  post's — a same-text note from a neighboring cell is no longer
  swallowed as a duplicate.

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-07 17:40:12 +02:00
a0c517c018 i18n: machine-translate feature strings into all 28 non-English locales (#1391)
Fills the translation gaps for the strings the feature program added
(capability UI, /ping /trace, board, vouch, prekeys, gateway, groups,
Cashu, Wi-Fi bulk, Tor-offline). 3300 (key,language) pairs added across
28 locales; Spanish was already fully translated, the rest land as
`needs_review` for native-speaker review before shipping.

Purely additive: main's key set (336) and existing translations are
authoritative and untouched. Verified programmatically — 0 removed,
0 changed, exactly 3300 added. (The git diff-stat shows large deletion
counts, but that's line-alignment churn from inserting into a 38k-line
JSON; no content is removed.)

Machine translation only — every `needs_review` entry needs a native
speaker before it can be trusted.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:16:35 +02:00
81a10f73f0 Private groups: creator-managed encrypted group chat over the mesh (#1383)
* Add capability bits to announce TLV

Announces now carry an optional capabilities TLV (0x05): a little-endian
bitfield with named bits for upcoming features (prekeys, wifiBulk,
gateway, groups, board, vouch, meshDiagnostics). Old clients skip the
unknown TLV; peers without it decode as nil so features can distinguish
"legacy peer" from "advertises nothing".

PeerCapabilities lives in BitFoundation with a minimal-length encoding
that preserves unknown bits for forward compatibility. Peer capabilities
are stored in the BLE peer registry on verified announce and exposed via
BLEService.peerCapabilities(_:). The local advertisement set is empty
until each feature ships its bit.

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

* Private groups: creator-managed encrypted group chat over the mesh

Small encrypted crews (hard cap 16) between public broadcast and 1:1 DMs:

Protocol
- MessageType.groupMessage = 0x25: broadcast packets with a cleartext
  16-byte group ID + epoch, ChaCha20-Poly1305 ciphertext (epoch bound as
  AEAD AAD), inner Ed25519 sender signature over
  "bitchat-group-msg-v1"|groupID|messageID|timestamp|content
- NoisePayloadType.groupInvite = 0x06 / .groupKeyUpdate = 0x07:
  creator-signed group state (key, epoch, roster) 1:1 over Noise; signature
  over "bitchat-group-v1"|groupID|epoch|key-hash|roster-hash and the Noise
  session peer must BE the creator
- SyncTypeFlags bit 10 (groupMessage): variable-length LE bitfield widens
  1 -> 2 bytes inside the length-prefixed REQUEST_SYNC TLV; old clients
  ignore unknown bits and answer with types they know
- PeerCapabilities.localSupported now advertises .groups

Storage
- GroupStore: symmetric keys in the keychain, roster/name/epoch as
  protected JSON in Application Support; wiped in panicClearAllData()

Behavior
- Non-members relay 0x25 like any broadcast but cannot read it; group
  messages join gossip-sync backfill with the public-message window
- Receivers drop wrong-epoch envelopes, bad sender signatures, and
  senders missing from the creator-signed roster
- Fire-and-flood delivery (no per-member acks in v1)

UI
- Groups open as chat windows through the private-chat sheet (virtual
  "group_" peer IDs); groups section in the people sheet; /group
  create/invite/remove/leave/list commands; invitees get a system message
  + notification and the group appears in their people sheet

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

* Private groups: fix TLV truncation, roster downgrade, removal notice, block, media, signable bytes

Addresses the Codex review and adversarial-review findings on #1383:

- TLV encoding now throws GroupTLVError.valueTooLong instead of clamping to
  65535 and truncating, so an oversize group message fails to seal and
  surfaces send_failed rather than shipping ciphertext recipients drop.
- Roster nicknames truncate on a Character boundary (never mid-scalar), so a
  multi-byte nickname can no longer make the whole signed roster undecodable.
- Invites now bump the epoch (rotate the key) like removals, giving every
  roster change a strictly-increasing epoch so out-of-order invite states no
  longer last-writer-wins a just-added member back out.
- Removing a member now sends them a creator-signed roster-without-them under
  a throwaway all-zero key (never the rotated key), so their client
  deactivates the group and surfaces "removed" instead of going silently dark.
- /block is enforced in the group receive path: a blocked member's messages
  are dropped from display and notifications, consistent with every other
  inbound path.
- Media affordances are disabled in group chats (both computed sites) so the
  composer can't strand a media placeholder that never sends; media-in-groups
  is a documented v2 item.
- Creator signature now covers the group name and the sender signature covers
  the epoch (wire-format-affecting; needs Android parity before ship).
- Explicit isGroup guard in markPrivateMessagesAsRead so read/delivered
  receipts can never leak into group conversations under a future refactor.

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-07 15:59:55 +02:00
87910541ef Prekey bundles: forward-secret async first contact for courier mail (#1381)
* Add capability bits to announce TLV

Announces now carry an optional capabilities TLV (0x05): a little-endian
bitfield with named bits for upcoming features (prekeys, wifiBulk,
gateway, groups, board, vouch, meshDiagnostics). Old clients skip the
unknown TLV; peers without it decode as nil so features can distinguish
"legacy peer" from "advertises nothing".

PeerCapabilities lives in BitFoundation with a minimal-length encoding
that preserves unknown bits for forward compatibility. Peer capabilities
are stored in the BLE peer registry on verified announce and exposed via
BLEService.peerCapabilities(_:). The local advertisement set is empty
until each feature ships its bit.

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

* Prekey bundles: forward-secret async first contact for courier mail

Courier envelopes were sealed with one-way Noise X to the recipient's
long-lived static key, so a later compromise of that key exposed every
envelope captured in transit. This adds one-time prekey bundles:

- PrekeyBundle (MessageType 0x24): 8 one-time Curve25519 public prekeys
  bound to the owner's Noise static key by an Ed25519 signature over
  "bitchat-prekey-bundle-v1" canonical bytes; gossiped mesh-wide on its
  own 60s sync round (SyncTypeFlags bit 9, 200-peer cap, 24h freshness)
  and verified against the announce-bound signing key before caching.
- Sealed envelope v2: Noise X where the responder static is the one-time
  prekey, prologue "bitchat-prekey-v1" || prekeyID. Sender identity rides
  encrypted inside and is authenticated exactly like v1 (blocked-sender
  check included). CourierEnvelope gains an optional prekeyID TLV that
  v1 decoders skip as unknown.
- Local prekeys live in the Keychain; consumed privates survive a 48h
  grace window for spray-and-wait redeliveries, then are deleted (the
  forward-secrecy clock starts at deletion). The batch tops back up and
  re-gossips when unconsumed count drops below 3, and everything is
  wiped in panic mode.
- Routing: courier sealing picks a cached verified bundle when one
  exists (one prekey per message, reused across deposit retries), with
  the advertised .prekeys capability as a veto for on-mesh peers, and
  falls back to static sealing otherwise.

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

* Prekeys: authenticate bundle packets, fix consume-republish, deflake CI

Fixes the prekey-bundle PR review + CI failure:

- CI root cause: the receive queue (mesh.message) is concurrent, so a
  gossiped prekey bundle can be processed before the announce that binds
  its owner's signing key. The old handler dropped such bundles outright,
  so under CI parallel load the bundle was permanently lost and the
  cache/gossip tests flaked (verifiedBundleEntersGossipStore,
  prekeySealedMailTravelsViaCourierAndOpens). Bundles that arrive before
  their binding are now retained per-owner (bounded) and re-attempted when
  the verified announce lands, atomically to avoid a check-then-act race.

- Authenticate the OUTER prekey-bundle packet (Codex P2 / review MEDIUM):
  require senderID == PeerID(bundle.noiseStaticPublicKey) and verify the
  packet's Ed25519 signature (covers senderID + timestamp) against the
  owner's bound signing key, in addition to the inner bundle signature.
  Stops replay under a fresh timestamp / fake senderID.

- Key the gossip prekey-bundle store/dedup by the bundle's authenticated
  identity (noiseStaticPublicKey), not the unauthenticated packet
  senderID, so one valid bundle sprayed under many fabricated sender IDs
  can't multiply entries and exhaust the 200-owner cap.

- Bump published-bundle generatedAt strictly on consume (Codex P1):
  consuming a prekey shrinks the published bundle, so it now republishes
  with a strictly newer generatedAt and re-gossips, so peers replace the
  cached copy and stop assigning the consumed ID before its 48h grace.

- Guard the panic/clear detached Application Support tree-deletes behind
  TestEnvironment.isRunningTests: the SPM test process shares that tree,
  so the wipe could land mid-test and flake file-dependent tests.

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

* Update sync tests for prekeyBundle as bit 9 / default sync round

Prekeys makes bit 9 (prekeyBundle) a known SyncTypeFlags bit and enables
a prekey sync round by default. That broke tests authored by other PRs
that assumed bit 9 was phantom or that only their own sync round fires:

- SyncTypeFlags(Board)Tests: move the "unknown bits" probes to bits 10+
  (0xFE -> 0xFC / 0xFD), since bit 9 is now assigned.
- GossipSync(Board)Tests + GossipSyncManagerTests: disable the prekey sync
  round in configs that run maintenance (as they already do for message/
  fragment/fileTransfer), so they isolate the behavior under test.

Full app suite (1301 tests) green locally via SPM.

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-07 15:38:33 +02:00
f9032cf2b9 Add mesh diagnostics: /ping, /trace, and topology map (#1377)
* Add mesh diagnostics: /ping, /trace, and topology map

- New protocol types ping=0x26 / pong=0x27 (9-byte payload: 8-byte nonce
  + origin TTL) with per-peer inbound rate limiting (5 per 10s)
- /ping @name reports RTT and hop count, 10s timeout
- /trace @name prints the estimated path from gossiped directNeighbors
- Topology map sheet (circular Canvas layout) reachable from App Info
- Ping/pong ride the deterministic directed-relay path like DMs
- Tests: payload round-trip, hop-count math, command output, edge
  normalization, layout

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

* Fix CI media-wipe race, per-link ping rate limiting, and /ping output routing

Three fixes for PR #1377 review:

1. CI flake (sendImage_privateChatProcessesAndTransfersImage): the
   panicClearAllData / clearCurrentPublicTimeline detached utility-priority
   tasks delete the real ~/Library/Application Support/files tree, which the
   test process shares. The wipe fires at a nondeterministic time and raced
   the sendImage test's JPEG in files/images/outgoing (write then re-read),
   so prepareImagePacket threw and the test timed out. Both wipes are now
   skipped under tests (existing TestEnvironment.isRunningTests pattern);
   this also stops test runs from deleting the developer's real media.

2. Codex P1: ping packets are unsigned, so keying the pong rate limiter on
   packet.senderID let one connected peer rotate forged sender IDs to bypass
   the 5-per-10s budget. The limiter now keys on the ingress link (the
   directly connected peer that delivered the packet); the pong still goes
   to the claimed sender. Regression test proves rotating senders over one
   link exhaust one budget (fails 10 vs 5 pongs on the old code).

3. Codex P2: /ping output arrived up to 10s later and was routed from
   selectedPrivateChatPeer at callback time, misrouting the result after a
   chat switch. The origin conversation is now captured when the command is
   issued (CommandOutputDestination) and deferred output is routed there:
   a DM result lands in the origin chat's history even if deselected, and a
   mesh-timeline result pins to #mesh instead of the active channel.

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

* App Info: move NETWORK section under HOW TO USE and uppercase NETWORK/SYMBOLS headers

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

* Conform DiagnosticsMockContext to sendPublicMessage

CommandContextProvider gained sendPublicMessage (Cashu /pay, #1376) after
this branch forked, so the diagnostics test mock no longer conformed once
main was merged in. Add the no-op stub.

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-07 15:08:22 +02:00
d4f0c49787 Gateway mode: opt-in mesh↔Nostr uplink for geohash channels (#1384)
* Add capability bits to announce TLV

Announces now carry an optional capabilities TLV (0x05): a little-endian
bitfield with named bits for upcoming features (prekeys, wifiBulk,
gateway, groups, board, vouch, meshDiagnostics). Old clients skip the
unknown TLV; peers without it decode as nil so features can distinguish
"legacy peer" from "advertises nothing".

PeerCapabilities lives in BitFoundation with a minimal-length encoding
that preserves unknown bits for forward compatibility. Peer capabilities
are stored in the BLE peer registry on verified announce and exposed via
BLEService.peerCapabilities(_:). The local advertisement set is empty
until each feature ships its bit.

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

* Gateway mode: opt-in mesh↔Nostr uplink for geohash channels

An opt-in "internet gateway" toggle lets one connected phone bridge the
local geohash channel for mesh-only peers: signed kind-20000 events ride
a new nostrCarrier (0x28) packet — directed to the gateway for uplink,
broadcast with TTL for downlink — with Schnorr verification at every
hop, CourierStore-style quotas, and explicit loop-prevention rules.

- BitFoundation: MessageType.nostrCarrier = 0x28
- NostrCarrierPacket: 2-byte-length TLV codec (direction, geohash,
  signed event JSON), 16 KiB cap, tolerant decoder
- GatewayService: closure-injected policy layer — verify gates (sig,
  kind, #g tag, age, size), uplink quotas (10/min/depositor rate limit,
  offline queue of 20 total / 5 per depositor, drop-oldest, flush on
  reconnect), downlink budget (30/min, bounded drop-oldest backlog),
  bounded loop-prevention ID sets
- BLEService: runtime capability bits (advertise .gateway only while
  the toggle is on, re-announce on change), signed directed uplink
  sends, carrier ingress with depositor signature verification
- Mesh-only senders uplink automatically from sendGeohash when no relay
  is connected and a reachable peer advertises .gateway; once-per-
  channel "sent via mesh gateway" notice
- UI: gateway toggle beside the Tor toggle, globe header indicator,
  VoiceOver labels, xcstrings entries

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

* Gateway: harden downlink freshness, uplink verify ordering, and drain

Fixes the confirmed downlink/uplink defects from the PR #1384 review +
Codex findings:

- Downlink age + #g gate (Codex P2 / review #1): rebroadcastRelayEvent
  now drops events outside the same freshness window receivers enforce
  and whose #g tag mismatches the carrier geohash, BEFORE spending any
  budget — so a 1h/200-event channel-resubscribe backfill no longer
  burns the 30/min BLE budget on events every receiver drops.
- Rate-limit + dedup before Schnorr (review #2): handleUplinkDeposit now
  runs cheap structural checks + carried-ID dedup + rate-token consume
  before isValidSignature(), so a replay flood is bounded by cheap work
  instead of unbounded main-actor verifies.
- Quota-dropped deposits not rendered (review #3): enqueueUplink reports
  acceptance and injectInbound only fires for events actually
  published/queued, ending the local-timeline divergence.
- Drain timer + mark-after-send (Codex P2 / review #4): a burst beyond
  budget now arms a timer to drain when the window frees; rebroadcast
  IDs are marked only after an event is actually sent, so overflow-
  dropped events stay retryable.
- Symmetric publish path (review #5): the gateway publish closure now
  refuses when no geo relay is known, matching the local send path
  instead of publishing dead traffic to default relays.
- Loop-rule doc (review #7): softened to reflect that rule 3 is a
  call-site convention with unit-tested backstops; added tests for the
  publishedEventIDs backstop, downlink freshness/mismatch, drain timer,
  and quota-drop non-injection.

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

* Gateway: stop self-echo of uplinked events onto the mesh

Every event a gateway uplinks to the relays comes back through its own
geohash subscription. `rebroadcastRelayEvent` deduped against
`meshBroadcastEventIDs`, `rebroadcastEventIDs`, and `pendingDownlinks`,
but not `publishedEventIDs` — so an event this gateway just published
was downlink-rebroadcast onto the same mesh it originated from, doubling
BLE airtime per uplinked message and able to starve the 30/min downlink
budget on a busy channel (device-confirmed, filed on #1384).

Fix: also skip the downlink rebroadcast when the event id is in
`publishedEventIDs`. That set is already the bounded (drop-oldest,
capacity maxTrackedEventIDs) loop-rule-2 uplink cache, populated only by
`publish()`, so genuine inbound-from-internet events (never published
here) still rebroadcast normally. Reconciles cleanly with the existing
loop-prevention sets — no new state.

Adds a GatewayServiceTests case asserting an uplinked event that echoes
back via the subscription is not rebroadcast, while a genuine inbound
event still is.

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-07 14:55:17 +02:00
2360140760 Transitive verification: vouch for verified peers over Noise (#1380)
* Add capability bits to announce TLV

Announces now carry an optional capabilities TLV (0x05): a little-endian
bitfield with named bits for upcoming features (prekeys, wifiBulk,
gateway, groups, board, vouch, meshDiagnostics). Old clients skip the
unknown TLV; peers without it decode as nil so features can distinguish
"legacy peer" from "advertises nothing".

PeerCapabilities lives in BitFoundation with a minimal-length encoding
that preserves unknown bits for forward compatibility. Peer capabilities
are stored in the BLE peer registry on verified announce and exposed via
BLEService.peerCapabilities(_:). The local advertisement set is empty
until each feature ships its bit.

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

* Transitive verification: vouch for verified peers over Noise

When a Noise session establishes with a peer I verified and that peer
advertises the .vouch capability, send signed attestations (up to 16,
most recently verified first, at most once per peer per 24h) for the
OTHER fingerprints I verified. Receivers accept vouches only from
senders they verified themselves, verify the Ed25519 signature against
the sender's announce-bound signing key, and surface the result as a
new derived trust tier: vouched (unfilled seal) between casual and
trusted.

Protocol:
- NoisePayloadType.vouch = 0x12 carries a batch of TLV attestations:
  voucheeFingerprint (32B), voucheeSigningKey (32B), timestamp
  (uint64 ms BE), Ed25519 signature over
  "bitchat-vouch-v1" | fingerprint | signingKey | timestamp.
  The voucher is implicit in the authenticated session.
- PeerCapabilities.localSupported now advertises .vouch.

Storage (SecureIdentityStateManager / IdentityCache):
- vouches keyed by vouchee, capped at 8 vouchers each; validity is
  recomputed on read (voucher still verified-by-me, < 30 days old), so
  unverifying a voucher retires their vouches without cascade deletes.
- New IdentityCache fields are Optional so pre-existing encrypted
  caches decode cleanly; TrustLevel.vouched is inserted mid-ladder but
  raw values are strings, so persisted values are unaffected (and
  vouched itself is never persisted).
- Panic wipe clears vouch state with the rest of the identity cache.

UI: unfilled checkmark.seal badge in the mesh peer list (filled seal
stays exclusive to verified) and a "vouched for by N people you
verified" section with voucher names in FingerprintView; VoiceOver
labels and xcstrings entries included.

Tests: attestation encode/decode + signature (forged/tampered/expired),
accept-policy gates, batch cap, trust-level derivation incl. voucher
invalidation, persistence compat, and coordinator exchange/accept
policies. Full macOS suite: 1088 tests passing.

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

* Fix CI deadlock in vouch tests and live-refresh the fingerprint sheet on vouch acceptance

Two fixes for PR #1380 review findings:

1. CI "Run Swift Tests (app)" hang (exit 137): the new
   SecureIdentityStateManagerVouchTests suite was nonisolated, so Swift
   Testing ran its tests in parallel on the Swift Concurrency cooperative
   pool. Each test enqueues a queue.async(.barrier) write (setVerified)
   and immediately blocks in queue.sync / queue.sync(.barrier)
   (recordVouch / effectiveTrustLevel). On CI's few-core runners every
   cooperative-pool thread ended up parked behind a pending barrier that
   never got a dispatch worker, deadlocking the whole test process until
   the watchdog SIGKILLed it. The suite is now @MainActor, matching the
   production isolation of the vouch API (ChatVouchCoordinator is
   @MainActor) and keeping blocking syncs off the cooperative pool.

2. Codex P2: an open fingerprint sheet did not refresh its vouched badge
   when a vouch batch was accepted - VerificationModel.bind() never
   observed the trust-change signal. It now subscribes to the
   "peerStatusUpdated" notification that
   ChatVouchCoordinator.notifyPeerTrustChanged() posts (same source
   PeerListModel uses) and forwards it to objectWillChange. Added a
   regression test that pins VerificationModel's own subscription
   (verified to fail without the fix).

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

* Skip media-wipe detached tasks under tests (shared-filesystem race)

panicClearAllData and clearCurrentPublicTimeline delete the real
~/Library/Application Support/files tree in detached utility-priority
tasks. The SPM test process shares that tree and ChatViewModelTests
invoke both methods, so under parallel scheduling the wipe lands at a
nondeterministic time — deleting media a concurrently running test just
wrote (and the developer's real app data with it). Guard both with the
existing TestEnvironment.isRunningTests pattern, mirroring the same fix
on feat/mesh-diagnostics (#1377).

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

* Port vouch capability-race fix to feat/vouching (ports b8adcbe9)

Ports the on-device-confirmed fix from the integration test branch
(commit b8adcbe9) onto feat/vouching so PR #1380 is actually correct.
On-device testing confirmed the transitive vouch propagated once the
send was triggered on verify / announce arrival rather than auth alone.

Vouch attestations only ever sent from peerAuthenticated, gated on the
peer's .vouch capability. That capability arrives via the peer's announce,
processed independently of the Noise handshake, so at auth time the set was
usually empty -> gate failed -> vouch silently skipped and never retried.

- Refactor the send path into a reusable attemptVouch(to:fingerprint:now:).
- Trigger on peer-list updates (peersUpdated): fired after every verified
  announce, so the batch goes out once the .vouch bit actually arrives.
- Trigger on local verification (vouchToConnectedVerifiedPeers): verifying a
  peer runs a vouch pass over connected verified peers, covering the
  verify-while-connected case and propagating the new identity onward.
- Relax the capability gate: treat an empty/unknown set as eligible (the
  Noise 0x12 payload is ignored by non-supporting peers); only skip when a
  non-empty set explicitly lacks .vouch.

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-07 14:48:37 +02:00
70229f0be1 Originate v2 source routes and wire fragmentIdFilter targeted resync (#1378)
* Originate v2 source routes and wire fragmentIdFilter targeted resync

Part A — source-route origination policy:
- Gate route application (BLESourceRouteOriginationPolicy): only packets we
  author, directed at a single peer, with TTL headroom, whose recipient is
  not directly connected. Relays no longer attach routes to (and re-sign)
  packets they merely forward.
- Version-gate paths: MeshTopologyTracker records the highest protocol
  version observed per peer; BFS routes require every intermediate hop and
  the recipient to be v2-observed, capped at 4 intermediate hops.
- Degrade on failure: BLESourceRouteFailureCache marks a routed send that
  sees no inbound traffic from the recipient within 10s as failed and floods
  for 60s before retrying routes.

Part B — REQUEST_SYNC fragmentIdFilter (TLV 0x06):
- Requester: BLEFragmentAssemblyBuffer reports stalled broadcast
  reassemblies (no new fragment for 5s, retried at most every 10s); the
  maintenance pass sends a types=fragment REQUEST_SYNC naming the stalled
  8-byte fragment stream IDs to each connected peer.
- Responder: GossipSyncManager restricts the fragment diff to exactly the
  named streams, bypassing the since-cursor while the GCS filter still
  excludes pieces the requester holds; RSR/TTL-0/rate-limit semantics
  unchanged and REQUEST_SYNC stays link-local.
- Bounds: at most 60 IDs per request (60*17-1 = 1019 bytes <= the 1024-byte
  decoder cap); oversized 0x06 values are ignored, not fatal.

Docs: SOURCE_ROUTING.md gains the iOS origination policy (§8);
REQUEST_SYNC_MANAGER.md documents 0x05/0x06 as implemented.

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

* Fix stall-clock refresh on duplicates and overflow suppression in fragment resync

Two fixes to stalledBroadcastFragmentIDs bookkeeping in
BLEFragmentAssemblyBuffer:

- Duplicate fragments no longer reset the stall clock. Fragment packets
  bypass the packet deduplicator, so relayed duplicates of an
  already-held index arriving every few seconds kept lastFragmentAt
  fresh and suppressed the targeted REQUEST_SYNC indefinitely. Now
  lastFragmentAt only updates when the index is new (actual progress).

- Only the streams that will actually be encoded on the wire are
  rate-limited. Previously every stalled candidate got
  lastResyncRequestAt set, but encodeFragmentIdFilter serializes at most
  RequestSyncPacket.maxFragmentIdFilterCount (60) IDs, so overflow
  streams were suppressed for retryAfter without ever being requested.
  Selection now caps at that shared constant, oldest stall first, so
  overflow stays eligible and rotates fairly on the next pass.

Tests: duplicates arriving periodically still trigger the stall report;
70 stalled streams yield the 60 oldest on the first pass and the
remaining 10 on the next.

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-07 14:42:53 +02:00
cee2bcd535 Fix SyncTypeFlags tests: bit 8 (boardPost) is a known type (#1390)
#1379 (board) mapped bit 8 -> .boardPost in SyncTypeFlags, making it a
known bit that spills the encoded bitfield into a second byte. But the
phantom-bit tests (added by #1373) predate that change and still assert
bit 8 is unknown, so main went red once both landed. Neither PR's CI
caught it — each was green against a main without the other.

The impl is correct (board is a real sync type); the tests were stale.
Update them to treat bits 9+ as phantom, expect the all-known field to
serialize to 2 bytes, and add a regression test that the board bit
survives decode while the phantom high bits are stripped.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:35:51 +02:00
3c610a83cd Cashu ecash chips: detect, render, and redeem tokens + /pay command (#1376)
* Cashu ecash chips: detect, render, and redeem tokens + /pay command

Content-level Cashu support, no wire-protocol changes:

- CashuTokenDecoder: summarizes V3 (cashuA base64url-JSON) tokens —
  amount summed across proofs, unit, mint host, memo — and V4 (cashuB)
  via a minimal bounded CBOR reader. All input is treated as
  adversarial: size caps, depth/item budgets, overflow guards, display
  sanitization; malformed payloads fail closed to a generic chip.
- PaymentChipView: cashu chips now show "500 sat · mint.example.com"
  (+ memo) instead of a generic label; tap opens a cashu: wallet URL
  and falls back to https://redeem.cashu.me when no wallet handles it;
  context menu adds copy token / redeem in wallet / redeem on web.
- extractCashuLinks now returns bare deduplicated bearer strings so the
  chip can decode them (cashu: URIs still detected via the embedded
  token).
- /pay <token>: validates the token decodes, sends it as the message
  body; DMs send directly, public channels require an explicit
  "/pay <token> public" confirm since tokens are bearer instruments.
  Suggested everywhere except public geohash channels.
- Tests: decoder (V3/V4 decode, summation, URI forms, truncation/
  garbage/huge fuzzing, CBOR depth bounds) and /pay command flows.

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

* Cashu: strict decode on the /pay SEND path

The permissive decoder turned any non-empty cashuB… base64 that failed
CBOR parsing into a generic TokenInfo, so the /pay guard accepted base64
junk and truncated V4 tokens and relayed them with a success message.

Add a `strict` flag to CashuTokenDecoder.decode: in strict mode there is
no permissive V4 fallback and the token must resolve to a known version
with a positive amount, else it returns nil. Rendering keeps the
permissive path (an unknown chip is fine for display). /pay now decodes
with strict:true and surfaces "invalid cashu token" instead of sending.

Tests: /pay with truncated cashuB / base64 junk is rejected; valid V3
and valid definite-length V4 still send; decoder strict-mode unit tests.

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-07 14:14:08 +02:00
60be88a4f5 Geohash bulletin board: persistent signed notices over mesh sync (#1379)
* Add geohash bulletin board: persistent signed notices over mesh sync

New MessageType 0x23 carries TLV-encoded board posts and tombstones,
self-signed with the author's Ed25519 key ("bitchat-board-v1" /
"bitchat-board-del-v1" domains) so notices verify without the author
present. BoardStore persists raw signed packets under Application
Support/board/ (200 posts, 5 per author, oldest evicted; expiry sweep;
tombstones retained until the deleted post's original expiry) and is
wiped on panic.

Board packets join gossip sync as bit 8 of the existing variable-length
types bitfield (a second byte old decoders already accept and ignore),
with a 60s round and its own capacity, served straight from the board
store so retention has one owner. Posts relay like broadcasts; urgent
posts get the announce-class TTL cap.

UI: a pin button in the header opens the board for the current channel
(geohash board, or mesh-local board), with urgent-pinned newest-first
listing, compose with urgent toggle and 1/3/7-day expiry, and
swipe-delete on own posts. Geohash posts also publish one-way as
Nostr kind-1 location notes when relays are reachable.

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

* Board: bound orphan tombstones and reject future-dated posts at ingest

Two hardening fixes from Codex review of the geohash bulletin board:

- Orphan tombstones (P1): retention was derived solely from the
  sender-chosen deletedAt, so self-signed tombstones for unseen post IDs
  with far-future deletedAt persisted and re-entered sync unboundedly.
  Retention is now also clamped to receive time (now + 7d + 1h skew --
  no post can outlive that), and orphans are capped at 100 globally and
  5 per author key with oldest-received evicted first. Matched
  tombstones and disk restores keep their existing behavior.

- Future-dated posts (P2): ingest only checked expiresAt > now, letting
  posts dated years ahead sort above honest posts and squat the 200
  global slots without ever pruning. The single ingest chokepoint
  (radio, sync, and disk restore all funnel through it) now rejects
  createdAt > now + 1h skew and expiresAt > now + 7d + 1h skew; the
  decoder's span rule is unchanged.

Adds tests for the skew boundary, far-future expiry, receive-time
tombstone clamping, orphan caps/eviction, and matched-tombstone
exemption.

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-07 14:13:25 +02:00
ede6368296 NIP-13 proof-of-work for geohash channels: mine on send, relax rate limits for PoW senders (#1382)
* NIP-13 proof-of-work for geohash channels: mine on send, relax rate limits for PoW senders

Outgoing kind-20000 geohash messages mine a NIP-13 nonce tag (8 leading
zero bits, ~256 hashes, typically <1 ms) off the main actor before
signing. Mining is hard-capped at 2 s and cancellable (newer send or
channel switch): on cap/cancel the committed target steps down so the
message still ships promptly with an honest commitment - sending is
never blocked and nothing is dropped. The hot loop serializes the
canonical event once and rewrites only the fixed-width nonce bytes.

Inbound kind-20000 events are scored per NIP-13 commitment semantics
(committed target counts; the ID must actually meet it, extra work
earns nothing) and never hard-rejected: validated PoW >= 8 bits skips
the per-sender rate-limit bucket while the per-content flood bucket
still applies, so old non-mining clients keep working under today's
strict limits while bulk spam gets expensive.

Presence heartbeats (kind 20001), kind-1 notes, and DMs are unchanged;
no UI beyond a pow= field in an existing sampled debug log.

Reimplemented from scratch rather than cherry-picking the stale
feature/pow-geohash-mining-ui branch (unbounded loop, hard receive
filtering, mining UI, XCTest, force unwraps).

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

* Geohash: serialize PoW sends so order matches send order

Two location-channel sends back-to-back only cancelled the previous
mining task and started a new one. Cancellation merely *expedites* NIP-13
mining (the target is polled and steps down; it never aborts the send),
so the cancelled task still appended + relayed once mining returned. Both
tasks ran concurrently and the second (shorter to mine) could finish
first, reordering messages in the timeline and on relays.

Chain the mining tasks: each geohash send captures the previous send's
task, cancels it (to expedite, so delays never stack), and awaits its
completion before it echoes and relays. Order is now always send order.
The >2s mining cap is preserved: cancellation expedites the awaited task,
so a send is never blocked beyond NostrPoW.miningTimeCap.

Test: two rapid sends where the first mines longer (larger content) still
land in send order for both the local echo and the relayed events.

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-07 14:12:37 +02:00
276cde44e7 Gate Tor/relay startup on network reachability (#1389)
On a mesh-only/offline device the app used to bootstrap Tor and spin
Nostr relay reconnects forever ("connecting to Tor…"), wasting battery
even when there was provably no network path at all.

Add an NWPathMonitor-backed reachability signal (NetworkReachabilityMonitor)
and fold it into NetworkActivationService's activation gate:

- Tor bootstrap and relay connect/reconnect are now gated on the network
  path being usable. When the path is fully unsatisfied (no interface at
  all) we set autoStart off, shut Tor down, and disconnect relays instead
  of looping. When a usable path returns we resume.
- Conservative policy: only NWPath.Status.unsatisfied counts as offline.
  A flaky-but-present link stays "reachable" (Tor tolerates intermittent
  connectivity); we never tear down on the first hiccup.
- Transitions are debounced (ReachabilityDebounce, ~2.5s) so path flapping
  cannot thrash Tor/relay startup. The debounce is a pure value type,
  unit-tested without the Network framework or real timers.
- Starts optimistic (reachable) so nothing is suppressed before the first
  path evaluation arrives.
- BLE mesh never consults this gate and works fully offline.
- NWPathMonitor's background callback hops to the main actor before
  touching any state.

Surfaces NetworkActivationService.isNetworkReachable for UI to distinguish
"offline" from "connecting to Tor".

Tests: pure debounce (satisfied → allowed, unsatisfied → suppressed after
interval, flap debounced, recover-after-outage) plus service wiring
(unreachable suppresses Tor+relays, recovery resumes, loss disconnects).

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:11:49 +02:00
201dbac49a docs: reconcile protocol docstrings with implementation (#1374)
- BitchatProtocol.swift: stop advertising "timing obfuscation prevents
  traffic analysis" — what exists is randomized relay jitter
  (RelayController, 10-220 ms) and PKCS#7-style padding to
  256/512/1024/2048-byte blocks (MessagePadding); there is no cover
  traffic or per-message timing obfuscation. Also update the stale
  Message Types list (Delivery/Read are Noise payloads, no Version
  negotiation type; add CourierEnvelope/RequestSync/FileTransfer).
- MessageType.swift: header said "6 essential" types; the enum has 9
  cases.

WHITEPAPER.md needed no changes: the #1372 rewrite already replaced the
old Bloom-filter and MessageRetryService claims, and its numbers
(dedup 1000/5min, jitter, outbox 100/peer 24h 8 attempts, courier
16 KiB/24h/40-20-5-2 quotas, spray 4/8, gossip 1000/15s/6h) all match
the code.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:10:58 +02:00
5fdc15d5af Add capability bits to announce TLV (#1375)
Announces now carry an optional capabilities TLV (0x05): a little-endian
bitfield with named bits for upcoming features (prekeys, wifiBulk,
gateway, groups, board, vouch, meshDiagnostics). Old clients skip the
unknown TLV; peers without it decode as nil so features can distinguish
"legacy peer" from "advertises nothing".

PeerCapabilities lives in BitFoundation with a minimal-length encoding
that preserves unknown bits for forward compatibility. Peer capabilities
are stored in the BLE peer registry on verified announce and exposed via
BLEService.peerCapabilities(_:). The local advertisement set is empty
until each feature ships its bit.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:07:52 +02:00
414 changed files with 79471 additions and 13843 deletions
+208 -22
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: permissions:
contents: write contents: read
pull-requests: write
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: jobs:
update-relay-data: propose-relay-data:
name: Validate and propose relay data
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
pull-requests: write
issues: write
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
+112
View File
@@ -0,0 +1,112 @@
name: Source manifest
# Publishes a hash manifest for every tagged release so a copy of the source
# obtained from somewhere other than this repository can be checked against it.
#
# This exists because the repository has been the target of takedown demands.
# When that succeeds, mirrors appear, and without a manifest there is no way to
# tell a faithful mirror from a modified one. The manifest is attested to this
# workflow run, so its own provenance is verifiable with `gh attestation verify`.
#
# Scope, stated plainly: this verifies SOURCE. It does not verify any compiled
# app. See docs/VERIFYING-A-BUILD.md.
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
ref:
description: 'Tag or commit to produce a manifest for'
required: true
permissions:
contents: read
jobs:
manifest:
runs-on: ubuntu-latest
permissions:
contents: write # attach the manifest to the release
id-token: write # provenance attestation
attestations: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.ref || github.ref }}
# Full history so the commit the tag names can be recorded exactly.
fetch-depth: 0
- name: Build manifest
id: build
run: |
set -euo pipefail
ref_name="${{ github.event.inputs.ref || github.ref_name }}"
commit="$(git rev-parse HEAD)"
tree="$(git rev-parse HEAD^{tree})"
# Hash every tracked file, in a stable order, with NUL separation so
# paths containing spaces or newlines cannot shift the columns.
git ls-files -z \
| sort -z \
| xargs -0 sha256sum \
> files.sha256
{
echo "# bitchat source manifest"
echo "#"
echo "# ref: ${ref_name}"
echo "# commit: ${commit}"
echo "# tree: ${tree}"
echo "# files: $(wc -l < files.sha256 | tr -d ' ')"
echo "#"
echo "# Verify a checkout of this ref with:"
echo "# shasum -a 256 -c files.sha256"
echo "# Hash checking alone ignores files this manifest does not list, and"
echo "# the Xcode project compiles any source file present in the tree. So"
echo "# also confirm nothing extra is present:"
echo "# git status --porcelain --ignored # git checkout: must print nothing"
echo "# or, for a tarball, diff this manifest's path list against find(1)."
echo "# Full instructions: docs/VERIFYING-A-BUILD.md"
echo "# The git tree hash above is the single value covering all tracked content:"
echo "# git rev-parse HEAD^{tree}"
echo "#"
} > SOURCE-MANIFEST.txt
cat files.sha256 >> SOURCE-MANIFEST.txt
echo "commit=${commit}" >> "$GITHUB_OUTPUT"
echo "tree=${tree}" >> "$GITHUB_OUTPUT"
- name: Self-check the manifest
run: |
set -euo pipefail
# A manifest that does not validate against the tree it was made from
# is worse than none, so fail loudly rather than publishing it.
grep -v '^#' SOURCE-MANIFEST.txt > check.sha256
sha256sum -c check.sha256 > /dev/null
echo "manifest validates against this checkout"
- name: Attest the manifest
uses: actions/attest-build-provenance@v1
with:
subject-path: SOURCE-MANIFEST.txt
- uses: actions/upload-artifact@v4
with:
name: source-manifest
path: SOURCE-MANIFEST.txt
- name: Attach to release
if: startsWith(github.ref, 'refs/tags/')
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# A tag may be pushed before its release exists; only attach when
# there is a release to attach to, and never fail the run over it.
if gh release view "${{ github.ref_name }}" >/dev/null 2>&1; then
gh release upload "${{ github.ref_name }}" SOURCE-MANIFEST.txt --clobber
else
echo "no release for ${{ github.ref_name }} yet; manifest is available as a workflow artifact"
fi
+133 -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,125 @@ 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
# Some runner images list only placeholder destinations (rows carrying
# "error:" or "unavailable") or ship the selected Xcode without a
# matching iOS simulator runtime, so this walks three paths in order:
# a usable xcodebuild destination, an existing simctl device, and
# finally creating a device from the newest installed iOS runtime.
- name: Select available iPhone simulator
id: destination
run: |
set -uo pipefail
destinations=$(xcodebuild -project bitchat.xcodeproj -scheme "bitchat (iOS)" -showdestinations 2>/dev/null || true)
destination_id=$(awk -F'id:' '
/platform:iOS Simulator/ && /name:iPhone/ \
&& !/error/ && !/unavailable/ && !found {
value=$2
sub(/,.*/, "", value)
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
print value
found=1
}
' <<< "$destinations")
if [ -n "$destination_id" ]; then
echo "Selected destination via xcodebuild -showdestinations: $destination_id"
else
echo "No usable iPhone destination in -showdestinations output; falling back to simctl"
destination_id=$(xcrun simctl list devices available --json | jq -r '
[.devices | to_entries[]
| select(.key | contains("iOS"))
| .value[]
| select(.isAvailable and (.name | startswith("iPhone")))]
| first.udid // empty')
if [ -n "$destination_id" ]; then
echo "Selected existing simctl device: $destination_id"
else
echo "No available iPhone simulator device; creating one"
# Newest installed iOS runtime plus an iPhone device type that
# runtime itself reports as supported, so the pair always match.
create_spec=$(xcrun simctl list runtimes --json | jq -r '
[.runtimes[] | select(.platform == "iOS" and .isAvailable)]
| sort_by(.version | split(".") | map(tonumber))
| last // empty
| .identifier as $runtime
| ([(.supportedDeviceTypes // [])[]
| select(.productFamily == "iPhone"
or (.name // "" | startswith("iPhone")))]
| first.identifier // empty) as $devicetype
| "\($devicetype) \($runtime)"')
read -r devicetype runtime <<< "$create_spec" || true
if [ -z "${devicetype:-}" ] || [ -z "${runtime:-}" ]; then
echo "::error::No iPhone simulator destination found and none creatable (no installed iOS runtime with an iPhone device type)"
exit 1
fi
destination_id=$(xcrun simctl create ci-iphone "$devicetype" "$runtime") || {
echo "::error::simctl create failed for $devicetype on $runtime"
exit 1
}
echo "Created simulator $destination_id ($devicetype, $runtime)"
fi
fi
echo "Using iPhone simulator destination id: $destination_id"
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.5.4 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"
+112 -117
View File
@@ -1,165 +1,160 @@
# 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 6 hours so they can cross mesh partitions and survive a 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. All stored media, sent and received, is also deleted once it is more than seven days old, and immediately by panic wipe or app removal.
- 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: You can add relays yourself in settings, including `.onion` addresses. Added relays are stored locally, are limited in number, and are erased by panic wipe. Tor routing is on by default; while it is off, every relay you connect to can see your IP address, including relays carrying your private messages.
- **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 and Apple Services
bitchat requires Bluetooth permission to function: 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.
- Used only for peer-to-peer communication
- Bluetooth is not used for tracking
- You can revoke this permission at any time in system settings
## Location Permission - Exact coordinates are not included in bitchat mesh or Nostr payloads and are not persisted by bitchat.
- A selected geohash can still reveal an approximate area to peers and relays.
- When bitchat asks the operating system for a friendly place name, Apple's `CLGeocoder` service may process the location under Apple's privacy terms.
- Revoking location permission stops live location sampling. Saved bookmarks remain until you remove them, panic-wipe the app, or remove the app.
Location permission is optional and is used only for location channels: ## Microphone, Camera, and Media Permissions
- Used to compute local geohash channels and display names
- Requested as when-in-use permission - 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.
- Exact coordinates are not shared in messages or stored by bitchat - Voice-note and live-audio files can remain in Application Support under the media retention rules above.
- Selected and bookmarked geohashes may persist locally until you remove them, use panic wipe, or delete the app - Camera access is used to scan peer-verification QR codes. Photo-library access is used when you choose an image to send.
- You can revoke this permission at any time in system settings - These permissions can be revoked in system settings. bitchat does not record microphone or camera input while the related capture UI is inactive.
## Cryptography
Private and public features use different protections:
- Mesh private sessions use Noise XX with X25519, ChaCha20-Poly1305, and SHA-256.
- Private group messages use ChaCha20-Poly1305; group state and relevant mesh packets use Ed25519 signatures.
- Nostr events use secp256k1 Schnorr signatures. BitChat private envelopes use secp256k1 key agreement, HKDF-SHA256, 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 6 hours.
- **Public board posts and tombstones:** until expiry, at most seven days.
- **Media:** seven days, or sooner by quota eviction, panic wipe, or app removal.
- **Groups, favorites, preferences, identity keys, and bookmarks:** until removed by the feature, panic wipe, 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.
- **Notification previews:** Hidden by default, so lock-screen alerts do not show message text, sender names, or geohashes. Full previews can be turned on in settings.
- **Clearing a conversation:** Clearing the mesh timeline also deletes the recent public gossip this device had stored on disk.
- **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")
] ]
) )
] ]
+66 -22
View File
@@ -8,6 +8,12 @@ A decentralized peer-to-peer messaging app with dual transport architecture: loc
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622) 📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
### Getting a copy you can trust
Install from the App Store, or build from source you have verified. A compiled build from anywhere else cannot be verified — see [Verifying bitchat](docs/VERIFYING-A-BUILD.md) for how to check source against the per-release hash manifest, and for what to do if that is the only build you can get.
This matters more than it usually would: this repository has been the target of takedown demands, and when a repository or releases page disappears, mirrors appear that nobody can check.
## License ## License
This project is released into the public domain. See the [LICENSE](LICENSE) file for details. This project is released into the public domain. See the [LICENSE](LICENSE) file for details.
@@ -18,8 +24,8 @@ This project is released into the public domain. See the [LICENSE](LICENSE) file
- **Location-Based Channels**: Geographic chat rooms using geohash coordinates over global Nostr relays - **Location-Based Channels**: Geographic chat rooms using geohash coordinates over global Nostr relays
- **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 servers. Note that the mesh does use a persistent per-device identifier derived from your identity key — see [the whitepaper](WHITEPAPER.md) on identity and metadata for what a nearby radio can observe
- **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
@@ -34,7 +40,7 @@ BitChat uses a **hybrid messaging architecture** with two complementary transpor
- **Local Communication**: Direct peer-to-peer within Bluetooth range - **Local Communication**: Direct peer-to-peer within Bluetooth range
- **Multi-hop Relay**: Messages route through nearby devices (max 7 hops) - **Multi-hop Relay**: Messages route through nearby devices (max 7 hops)
- **No Internet Required**: Works completely offline in disaster scenarios - **No Internet Required**: Works completely offline in disaster scenarios
- **Noise Protocol Encryption**: End-to-end encryption with forward secrecy - **Noise Protocol Encryption**: End-to-end encryption, with forward secrecy for live sessions (store-and-forward mail is sealed without it — see the whitepaper)
- **Binary Protocol**: Compact packet format optimized for Bluetooth LE constraints - **Binary Protocol**: Compact packet format optimized for Bluetooth LE constraints
- **Automatic Discovery**: Peer discovery and connection management - **Automatic Discovery**: Peer discovery and connection management
- **Adaptive Power**: Battery-optimized duty cycling - **Adaptive Power**: Battery-optimized duty cycling
@@ -44,9 +50,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 +92,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)
@@ -93,30 +105,62 @@ 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.
+45
View File
@@ -0,0 +1,45 @@
# Security policy
bitchat is a security-focused messenger, and reports about its security are taken seriously. This page says how to report, what counts as a vulnerability here, and what to expect.
## Reporting a vulnerability
**Use GitHub's private vulnerability reporting:** [Report a vulnerability](https://github.com/permissionlesstech/bitchat/security/advisories/new) (Security tab → "Report a vulnerability").
Please do not open a public issue for anything that could put people at risk before a fix ships. bitchat is used by people in hostile network environments; a public proof-of-concept can be acted on faster than a patch can reach them.
A useful report says what an attacker can do, against which build (App Store version or commit hash), and how to reproduce it. A failing test or a packet capture is worth more than speculation about impact.
## What to expect
This is a volunteer-maintained project. The aim is to acknowledge reports within a week and to move on confirmed vulnerabilities immediately — historically, confirmed protocol and key-handling issues have been fixed within days. You'll be kept in the loop in the advisory thread, and credited in the fix unless you'd rather not be. There is no bug bounty.
## Supported versions
Fixes ship to the latest App Store release and `main`. Older releases are not patched; the fix is to update.
## Scope
In scope — the properties the app promises:
- Confidentiality and integrity of private messages and media (Noise sessions over BLE; over Nostr, bitchat's own ephemeral private-envelope format — a proprietary scheme, *not* NIP-17/NIP-44/NIP-59, see `WHITEPAPER.md`)
- Identity: key handling, verification, impersonation, session binding
- The panic wipe actually destroying what it claims to destroy
- Metadata exposure beyond what the documentation already discloses (see `PRIVACY_POLICY.md` and `docs/privacy-assessment.md`)
- Downgrade paths: anything that silently moves traffic from an encrypted path to a plaintext one
- Tor routing: anything that makes traffic bypass Tor while the Tor preference is on
- Supply-chain integrity of the source and its vendored binaries (see `docs/VERIFYING-A-BUILD.md`)
Out of scope — documented design properties, not vulnerabilities:
- Public visibility of mesh announces and geohash channels: broadcast content, nicknames, and public keys are public by design
- Bluetooth proximity being observable: anyone in radio range can tell a BLE device is present
- Mesh flooding/relay behavior inherent to a broadcast mesh (rate limits exist; the topology is what it is)
- Behavior of third-party Nostr relays
- Denial of service requiring physical proximity, and battery-drain attacks in general
If you're unsure whether something is in scope, report it privately anyway — a false alarm costs a few minutes; a real issue reported publicly can cost much more.
## Verifying what you're running
If your concern is that the app or source you have has been tampered with, that has its own document: `docs/VERIFYING-A-BUILD.md`.
+18 -9
View File
@@ -18,14 +18,14 @@ bitchat is a decentralized, peer-to-peer messaging application for secure, priva
* **Authentication:** peers are identified by cryptographic keys; announcements are signed and verified. * **Authentication:** peers are identified by cryptographic keys; announcements are signed and verified.
* **Resilience:** the network functions in lossy, low-bandwidth, partitioned environments with churning membership. * **Resilience:** the network functions in lossy, low-bandwidth, partitioned environments with churning membership.
* **Eventual delivery:** a message to an out-of-range peer should still arrive — relayed by the mesh, carried by a moving person, or resting on an internet relay — within a bounded retention window. * **Eventual delivery:** a message to an out-of-range peer should still arrive — relayed by the mesh, carried by a moving person, or resting on an internet relay — within a bounded retention window.
* **Ephemerality by default:** no plaintext message content is ever written to disk. Everything the store-and-forward stack persists is either sealed ciphertext or already-public broadcast traffic, and all of it dies with the panic wipe. * **Ephemerality by default:** conversation timelines live in memory only. Everything the store-and-forward stack persists is either sealed ciphertext or already-public broadcast traffic, and all of it dies with the panic wipe. Media is the exception: accepted images and voice notes are written to disk unsealed, protected by the platform's data-protection class rather than by app-layer encryption, and bounded by a storage quota.
## 2. Architecture Overview ## 2. Architecture Overview
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.
@@ -36,13 +36,17 @@ Each device holds two long-term key pairs in the Keychain:
* a **Curve25519 static key** for Noise key agreement — its SHA-256 fingerprint is the peer's stable identity, and * a **Curve25519 static key** for Noise key agreement — its SHA-256 fingerprint is the peer's stable identity, and
* an **Ed25519 signing key** for packet signatures. * an **Ed25519 signing key** for packet signatures.
On the mesh, peers appear under short ephemeral IDs derived per session; favoriting pins the full Noise public key so identity survives across sessions. Mutual favorites also exchange Nostr public keys for the internet path. Optional QR verification binds a nickname to a fingerprint in person. On the mesh, peers appear under a short 8-byte peer ID. That ID is **not ephemeral**: it is the first 8 bytes of the SHA-256 fingerprint of the device's Noise static key, so it is stable across sessions, reboots, and reinstalls that preserve the keychain, and it changes only when the identity itself is replaced by a panic wipe. Favoriting pins the full Noise public key so identity survives across sessions. Mutual favorites also exchange Nostr public keys for the internet path. Optional QR verification binds a nickname to a fingerprint in person.
Signed announcements additionally carry the nickname, the Noise static public key, and the Ed25519 signing public key in cleartext (§4.5), so a passive receiver in radio range can link a device across time and place regardless of the peer ID. Unlinkable presence is not a property this protocol currently provides; see §9.
## 4. BLE Mesh Layer ## 4. BLE Mesh Layer
### 4.1 Packet Format ### 4.1 Packet Format
A compact binary header (version, type, TTL, timestamp, flags) is followed by an 8-byte sender ID, an optional 8-byte recipient ID, the payload, and an optional Ed25519 signature. Version 2 packets may carry an explicit source route. Signatures exclude the TTL byte so relays can decrement it without invalidating them. Packets other than fragments are padded toward uniform sizes. A compact binary header (version, type, TTL, timestamp, flags) is followed by an 8-byte sender ID, an optional 8-byte recipient ID, the payload, and an optional Ed25519 signature. Version 2 packets may carry an explicit source route. Signatures exclude the TTL byte so relays can decrement it without invalidating them.
Only `noiseEncrypted` and `noiseHandshake` packets are padded, toward 256/512/1024/2048-byte buckets; every other type — public messages, announcements, board posts, group messages, fragments, files, and voice frames — goes out at its natural length. Padding is PKCS#7-style with pad bytes equal to the pad length, and because that length must fit one byte, a frame needing more than 255 bytes to reach its bucket is emitted unpadded. Payload length is therefore observable for most traffic.
### 4.2 Flood Control ### 4.2 Flood Control
@@ -78,7 +82,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 +111,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
@@ -122,12 +128,12 @@ Bare local counters (deposits, handovers, sprays, opens, outbox flushes and drop
## 8. Security Considerations ## 8. Security Considerations
* **Relay nodes** cannot read private traffic; they forward padded, opaque ciphertext. * **Relay nodes** cannot read private traffic; they forward opaque ciphertext. Padding applies to Noise frames only (§4.1), so other packet types relay at their natural length.
* **Couriers** are quota-bounded mailbags. A malicious courier can drop mail (redundant copies and deposit retry mitigate this) but cannot read it, link it across days, or amplify it — copy budgets are capped and every envelope is validated against size and lifetime policy on deposit. * **Couriers** are quota-bounded mailbags. A malicious courier can drop mail (redundant copies and deposit retry mitigate this) but cannot read it, link it across days, or amplify it — copy budgets are capped and every envelope is validated against size and lifetime policy on deposit.
* **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 is the weakest part of this design, and the peer ID does not help.** The 8-byte sender ID in every packet header is derived from a never-rotating key (§3), and announcements publish the static keys and nickname in cleartext, so a passive listener can enumerate participants and follow a device between places. Announcements also carry up to ten direct-neighbor IDs (§4.3), which hands a single sniffer the local adjacency graph. Origin packets leave at the default TTL, so hop distance identifies the originator. Daily-rotating courier tags do limit correlation of carried mail, and Nostr traffic can ride Tor. Addressing the radio-layer exposure is future work (§9).
* **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
@@ -135,6 +141,9 @@ Bare local counters (deposits, handovers, sprays, opens, outbox flushes and drop
* Couriered media beyond the 16 KiB text cap. * Couriered media beyond the 16 KiB text cap.
* Probabilistic relay and edge-of-network TTL boosting for very dense and very sparse graphs. * Probabilistic relay and edge-of-network TTL boosting for very dense and very sparse graphs.
* Multi-hop courier routing informed by encounter history. * Multi-hop courier routing informed by encounter history.
* **Rotating on-air identity.** Epoch-rotating peer IDs, with static-key disclosure moved inside the encrypted handshake and mutual favorites recognising each other through a tag derived from their shared secret, so presence stops being linkable across sessions (§3, §8).
* **Padding for non-Noise packet types**, and closing the gap where a frame needing more than 255 bytes of padding is emitted unpadded (§4.1).
* Making the neighbor list in announcements optional, or restricted to authenticated links (§4.3).
--- ---
+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 */
+3 -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
@@ -18,6 +13,8 @@ enum TorLifecycleEvent: String, Sendable, Equatable {
case willRestart case willRestart
case didBecomeReady case didBecomeReady
case preferenceChanged case preferenceChanged
/// Bootstrap ran out its deadline without completing.
case bootstrapDidStall
} }
enum AppEvent: Sendable, Equatable { enum AppEvent: Sendable, Equatable {
@@ -25,7 +22,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 +33,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()
} }
+100 -41
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
@@ -28,6 +26,8 @@ final class AppRuntime: ObservableObject {
let locationChannelsModel: LocationChannelsModel let locationChannelsModel: LocationChannelsModel
let peerListModel: PeerListModel let peerListModel: PeerListModel
let appChromeModel: AppChromeModel let appChromeModel: AppChromeModel
let boardAlertsModel: BoardAlertsModel
let sharedContentImportModel: SharedContentImportModel
private let idBridge: NostrIdentityBridge private let idBridge: NostrIdentityBridge
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
@@ -41,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()
@@ -50,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,
@@ -87,17 +86,48 @@ 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
GeoRelayDirectory.shared.prefetchIfNeeded() self.boardAlertsModel = BoardAlertsModel(
arrivals: BoardStore.shared.postArrivals.eraseToAnyPublisher(),
wipes: BoardStore.shared.didWipe.eraseToAnyPublisher(),
dependencies: BoardAlertsModel.Dependencies(
isOwnPost: { post in
let key = chatViewModel.meshService.noiseSigningPublicKeyData()
return !key.isEmpty && key == post.authorSigningKey
},
emitSystemLine: { content, geohash in
if geohash.isEmpty {
chatViewModel.addMeshOnlySystemMessage(content)
} else {
chatViewModel.addGeohashSystemMessage(content, geohash: geohash)
}
}
)
)
if chatViewModel.networkActivationAllowed {
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
@@ -122,11 +152,21 @@ final class AppRuntime: ObservableObject {
NetworkActivationService.shared.start() NetworkActivationService.shared.start()
GeohashPresenceService.shared.start() GeohashPresenceService.shared.start()
checkForSharedContent() checkForSharedContent()
expireAgedMedia()
record(.launched) record(.launched)
record(.startupCompleted) record(.startupCompleted)
} }
/// Drops media that has outlived the retention window. Off the main thread
/// and best-effort: the sweep walks the media tree, and nothing at launch
/// depends on its result.
private func expireAgedMedia() {
Task(priority: .utility) {
BLEIncomingFileStore().expireAgedMedia()
}
}
func handleOpenURL(_ url: URL) { func handleOpenURL(_ url: URL) {
record(.openedURL(url.absoluteString)) record(.openedURL(url.absoluteString))
@@ -136,12 +176,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()
@@ -160,6 +202,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)
@@ -202,7 +245,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)
@@ -249,6 +302,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()
} }
@@ -257,6 +312,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()
} }
@@ -265,14 +322,28 @@ 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()
} }
.store(in: &cancellables) .store(in: &cancellables)
NotificationCenter.default.publisher(for: .TorBootstrapDidStall)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard self?.chatViewModel.networkActivationAllowed == true
else { return }
self?.record(.torLifecycleChanged(.bootstrapDidStall))
self?.chatViewModel.handleTorBootstrapDidStall()
}
.store(in: &cancellables)
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)
} }
@@ -289,36 +360,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) {
@@ -327,7 +384,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
@@ -349,12 +408,12 @@ private extension AppRuntime {
TorManager.shared.isAutoStartAllowed() { TorManager.shared.isAutoStartAllowed() {
chatViewModel.torStatusAnnounced = true chatViewModel.torStatusAnnounced = true
chatViewModel.addGeohashOnlySystemMessage( chatViewModel.addGeohashOnlySystemMessage(
String(localized: "system.tor.starting", defaultValue: "starting tor...", comment: "System message when Tor is starting") String(localized: "system.tor.starting", comment: "System message when Tor is starting")
) )
} else if !TorManager.shared.torEnforced && !chatViewModel.torStatusAnnounced { } else if !TorManager.shared.torEnforced && !chatViewModel.torStatusAnnounced {
chatViewModel.torStatusAnnounced = true chatViewModel.torStatusAnnounced = true
chatViewModel.addGeohashOnlySystemMessage( chatViewModel.addGeohashOnlySystemMessage(
String(localized: "system.tor.dev_bypass", defaultValue: "development build: Tor bypass enabled.", comment: "System message when Tor bypass is enabled in development") String(localized: "system.tor.dev_bypass", comment: "System message when Tor bypass is enabled in development")
) )
} }
} }
+93 -36
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)
reindex(from: index) if index == 0 {
indexOffset += 1
} else {
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
} }
} }
@@ -409,6 +442,40 @@ final class ConversationStore: ObservableObject {
@discardableResult @discardableResult
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool { func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
guard let ids = conversationIDsByMessageID[messageID] else { return false } guard let ids = conversationIDsByMessageID[messageID] else { return false }
return applyDeliveryStatus(status, forMessageID: messageID, among: ids)
}
/// Applies an authenticated delivery/read receipt only to the supplied
/// direct-conversation aliases. A colliding message ID in another peer's
/// conversation (or a public timeline) must not inherit the receipt.
///
/// Stable and ephemeral aliases can temporarily hold the same message
/// instance during handoff. The shared helper republishes every targeted
/// alias even when the first mutation already changed that instance.
@discardableResult
func setDeliveryStatus(
_ status: DeliveryStatus,
forMessageID messageID: String,
inDirectPeerAliases peerIDs: Set<PeerID>
) -> Bool {
guard !peerIDs.isEmpty,
let indexedIDs = conversationIDsByMessageID[messageID] else {
return false
}
let allowedIDs = Set(peerIDs.map { ConversationID.directPeer($0) })
return applyDeliveryStatus(
status,
forMessageID: messageID,
among: indexedIDs.intersection(allowedIDs)
)
}
private func applyDeliveryStatus(
_ status: DeliveryStatus,
forMessageID messageID: String,
among ids: Set<ConversationID>
) -> Bool {
guard !ids.isEmpty else { return false }
var applied = false var applied = false
var skipped: [ConversationID] = [] var skipped: [ConversationID] = []
for id in ids { for id in ids {
@@ -796,16 +863,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 +894,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 +909,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 +950,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,
+111 -10
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
} }
+100
View File
@@ -0,0 +1,100 @@
//
// PrivacyScreen.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
#if os(iOS)
import UIKit
/// Covers the window while the app is not frontmost, so the snapshot iOS takes
/// for the app switcher shows a placeholder instead of the open conversation.
///
/// The cover is added on `willResignActive` and removed on `didBecomeActive`.
/// Both are deliberately UIKit notifications rather than SwiftUI's `scenePhase`:
/// the snapshot is captured shortly after `willResignActive`, and adding an
/// opaque subview to the window synchronously in that callback is the only way
/// to guarantee it is in the render tree before the capture. A SwiftUI overlay
/// driven by state may not have been laid out yet.
///
/// Panic wipe separately deletes any snapshots already on disk; this keeps new
/// ones from containing anything worth deleting.
final class PrivacyScreen {
static let shared = PrivacyScreen()
private var cover: UIView?
private var observers: [NSObjectProtocol] = []
private init() {}
/// Idempotent: repeated calls do not stack observers.
///
/// `queue: nil` is required, not incidental. Passing an `OperationQueue`
/// would enqueue the handler to run in a later runloop turn, which the
/// snapshot can beat; with no queue the block runs synchronously on the
/// thread that posted the notification the main thread, for UIApplication
/// lifecycle notifications.
func install() {
guard observers.isEmpty else { return }
let center = NotificationCenter.default
observers = [
center.addObserver(
forName: UIApplication.willResignActiveNotification,
object: nil,
queue: nil
) { _ in
PrivacyScreen.shared.show()
},
center.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: nil
) { _ in
PrivacyScreen.shared.hide()
}
]
}
private func show() {
guard cover == nil, let window = Self.activeWindow() else { return }
// Opaque rather than a blur: blurred large text can stay partly
// legible, and the snapshot is stored on disk.
let view = UIView(frame: window.bounds)
view.backgroundColor = .systemBackground
view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
let label = UILabel()
label.text = "bitchat"
label.font = .monospacedSystemFont(ofSize: 22, weight: .medium)
label.textColor = .secondaryLabel
label.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(label)
NSLayoutConstraint.activate([
label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
label.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
window.addSubview(view)
cover = view
}
private func hide() {
cover?.removeFromSuperview()
cover = nil
}
private static func activeWindow() -> UIWindow? {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap(\.windows)
.first { $0.isKeyWindow } ??
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap(\.windows)
.first
}
}
#endif
+6 -6
View File
@@ -250,7 +250,7 @@ final class PrivateConversationModel: ObservableObject {
if let group = chatViewModel.groupStore.group(for: conversationPeerID) { if let group = chatViewModel.groupStore.group(for: conversationPeerID) {
displayName = "#\(group.name) (\(group.members.count))" displayName = "#\(group.name) (\(group.members.count))"
} else { } else {
displayName = String(localized: "common.unknown", defaultValue: "unknown", comment: "Fallback label for unknown peer") displayName = String(localized: "common.unknown", comment: "Fallback label for unknown peer")
} }
return PrivateConversationHeaderState( return PrivateConversationHeaderState(
conversationPeerID: conversationPeerID, conversationPeerID: conversationPeerID,
@@ -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)
@@ -328,7 +328,7 @@ final class PrivateConversationModel: ObservableObject {
} }
} }
return String(localized: "common.unknown", defaultValue: "unknown", comment: "Fallback label for unknown peer") return String(localized: "common.unknown", comment: "Fallback label for unknown peer")
} }
private func resolveAvailability(for headerPeerID: PeerID, peer: BitchatPeer?) -> PrivateConversationAvailability { private func resolveAvailability(for headerPeerID: PeerID, peer: BitchatPeer?) -> PrivateConversationAvailability {
+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
}
}
+1 -7
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,
@@ -186,6 +180,6 @@ final class VerificationModel: ObservableObject {
} }
} }
return String(localized: "common.unknown", defaultValue: "unknown", comment: "Label for an unknown peer") return String(localized: "common.unknown", comment: "Label for an unknown peer")
} }
} }
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

+16 -11
View File
@@ -8,9 +8,6 @@
import SwiftUI import SwiftUI
import UserNotifications import UserNotifications
#if DEBUG
import BitLogger
#endif
@main @main
struct BitchatApp: App { struct BitchatApp: App {
@@ -43,14 +40,11 @@ struct BitchatApp: App {
.environmentObject(runtime.locationChannelsModel) .environmentObject(runtime.locationChannelsModel)
.environmentObject(runtime.peerListModel) .environmentObject(runtime.peerListModel)
.environmentObject(runtime.appChromeModel) .environmentObject(runtime.appChromeModel)
.environmentObject(runtime.boardAlertsModel)
.environmentObject(runtime.sharedContentImportModel)
.onAppear { .onAppear {
appDelegate.runtime = runtime appDelegate.runtime = runtime
runtime.start() runtime.start()
#if DEBUG
// Arm the opt-in UDP log sink from persisted config (no-op
// until a collector host is set in the App Info sheet).
LogNetworkSink.shared.reloadConfiguration()
#endif
} }
.onOpenURL { url in .onOpenURL { url in
runtime.handleOpenURL(url) runtime.handleOpenURL(url)
@@ -80,7 +74,10 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
weak var runtime: AppRuntime? weak var runtime: AppRuntime?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
true // Installed before the first resign-active so the app-switcher snapshot
// never captures an open conversation.
PrivacyScreen.shared.install()
return true
} }
func applicationWillTerminate(_ application: UIApplication) { func applicationWillTerminate(_ application: UIApplication) {
@@ -111,12 +108,20 @@ 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) {
@@ -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,18 +180,81 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
if player == nil { if player == nil {
preparePlayer(for: url) preparePlayer(for: url)
} }
#if os(iOS)
let session = AVAudioSession.sharedInstance()
do {
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
try session.setActive(true, options: [])
} catch {
SecureLogger.error("Failed to activate audio session: \(error)", category: .session)
}
#endif
return player != nil return player != nil
} }
/// All entry points (SwiftUI actions, `pauseForExclusivity`, the
/// delegate's main-queue hop) run on the main thread; the acquire itself
/// suspends while the blocking session IPC runs on the coordinator's
/// queue, and the player starts when it resolves. An acquire failure
/// leaves playback stopped: starting without a registered token would
/// bypass interruption fan-out and the coordinator's refcount. A
/// pause/stop landing mid-acquire hands the token straight back.
private func startPlayerAfterAcquiringSession() {
if sessionToken != nil {
startPreparedPlayer()
return
}
guard !sessionAcquireInFlight else { return }
sessionAcquireInFlight = true
let coordinator = sessionCoordinatorOverride ?? AudioSessionCoordinator.shared
Task { @MainActor [weak self] in
var token: AudioSessionCoordinator.Token?
do {
token = try await coordinator.acquire(.playback) { [weak self] in
self?.pause()
}
} catch {
SecureLogger.error("Failed to activate audio session: \(error)", category: .session)
}
guard let self else {
// The row was discarded while acquiring; deinit had no token
// to release yet.
token.map(coordinator.release)
return
}
self.sessionAcquireInFlight = false
guard self.isPlaying else {
// Paused/stopped while the session was activating.
token.map(coordinator.release)
return
}
guard let token else {
self.failPlaybackStart()
return
}
self.sessionToken = token
self.startPreparedPlayer()
}
}
@discardableResult
private func startPreparedPlayer() -> Bool {
guard let player,
player.prepareToPlay(),
player.play()
else {
SecureLogger.error("Voice note player refused to start " + url.lastPathComponent, category: .session)
failPlaybackStart()
return false
}
return true
}
private func failPlaybackStart() {
player?.pause()
stopTimer()
updateProgress()
isPlaying = false
releaseSession()
exclusivity.deactivate(self)
}
private func releaseSession() {
sessionToken.map((sessionCoordinatorOverride ?? .shared).release)
sessionToken = nil
}
private func startTimer() { private func startTimer() {
if timer != nil { return } if timer != nil { return }
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
@@ -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() {}
func activate(_ controller: VoiceNotePlaybackController) {
if activeController === controller {
return
}
activeController?.pause()
activeController = controller
} }
func deactivate(_ controller: VoiceNotePlaybackController) { private weak var activeController: (any ExclusivePlayback)?
private weak var latestReservedController: (any ExclusivePlayback)?
private var latestReservation = Reservation(generation: 0)
/// 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 {
return true
}
activeController?.pauseForExclusivity()
activeController = controller
return true
}
func isCurrent(_ reservation: Reservation, for controller: any ExclusivePlayback) -> Bool {
latestReservation == reservation && latestReservedController === controller
}
func deactivate(_ controller: any ExclusivePlayback) {
if activeController === controller { if activeController === controller {
activeController = nil activeController = nil
} }
if latestReservedController === controller {
latestReservedController = nil
}
} }
} }
+233 -64
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?
audioRecorder.isMeteringEnabled = true do {
audioRecorder.prepareToRecord() let newURL = try makeOutputURL()
audioRecorder.record(forDuration: maxRecordingDuration) outputURL = newURL
let audioRecorder = try recorderFactory.makeRecorder(url: newURL)
audioRecorder.isMeteringEnabled = true
guard audioRecorder.prepareToRecord() else {
throw RecorderError.failedToStartRecording
}
guard audioRecorder.record(forDuration: maxRecordingDuration) else {
throw RecorderError.failedToStartRecording
}
recorder = audioRecorder 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
try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000)) if activeRecorder.isRecording, paddingInterval > 0 {
if let waitForStopPadding = testingHooks.waitForStopPadding {
recorder.stop() await waitForStopPadding(paddingInterval)
} else {
// A new session may have started during the sleep don't touch its state try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000))
if self.recorder === recorder { }
cleanupSession()
self.recorder = nil
currentURL = nil
} }
// Cancellation or interruption may have run during the padding sleep.
// Only the recorder whose stop began here may be finalized by it.
guard activeOwner === owner,
let recorder = self.recorder,
recorder === activeRecorder
else { return nil }
if activeRecorder.isRecording {
activeRecorder.stop()
}
releaseSessionToken()
self.recorder = nil
currentURL = nil
activeOwner = nil
return sessionURL 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
+73 -24
View File
@@ -14,18 +14,22 @@
/// ///
/// ## Overview /// ## Overview
/// BitChat's identity system separates concerns across three distinct layers: /// BitChat's identity system separates concerns across three distinct layers:
/// 1. **Ephemeral Identity**: Short-lived, rotatable peer IDs for privacy /// 1. **Network Identity**: the 8-byte peer ID seen on air
/// 2. **Cryptographic Identity**: Long-term Noise static keys for security /// 2. **Cryptographic Identity**: Long-term Noise static keys for security
/// 3. **Social Identity**: User-assigned names and trust relationships /// 3. **Social Identity**: assigned names and trust relationships
/// ///
/// This separation allows users to maintain stable cryptographic identities /// The layers are separate concerns, but they are not independent: the network
/// while frequently rotating their network identifiers for privacy. /// identity is *derived* from the cryptographic one, so it does not provide
/// unlinkability. Rotating peer IDs would be a change to this model, not a
/// description of it see the note below.
/// ///
/// ## Three-Layer Architecture /// ## Three-Layer Architecture
/// ///
/// ### Layer 1: Ephemeral Identity /// ### Layer 1: Network Identity
/// - Random 8-byte peer IDs that rotate periodically /// - 8-byte peer ID = first 8 bytes of the Noise static key fingerprint
/// - Provides network-level privacy and prevents tracking /// - **Not ephemeral and not rotating.** It is stable across sessions and
/// reboots, and changes only when the underlying identity is replaced by a
/// panic wipe. A passive observer can use it to track a device.
/// - Changes don't affect cryptographic relationships /// - Changes don't affect cryptographic relationships
/// - Includes handshake state tracking /// - Includes handshake state tracking
/// ///
@@ -33,7 +37,7 @@
/// - Based on Noise Protocol static key pairs /// - Based on Noise Protocol static key pairs
/// - Fingerprint derived from SHA256 of public key /// - Fingerprint derived from SHA256 of public key
/// - Enables end-to-end encryption and authentication /// - Enables end-to-end encryption and authentication
/// - Persists across peer ID rotations /// - The root of the peer ID above, and never rotated on a schedule
/// ///
/// ### Layer 3: Social Identity /// ### Layer 3: Social Identity
/// - User-assigned names (petnames) for contacts /// - User-assigned names (petnames) for contacts
@@ -44,10 +48,13 @@
/// ## Privacy Design /// ## Privacy Design
/// The model is designed with privacy-first principles: /// The model is designed with privacy-first principles:
/// - No mandatory persistent storage /// - No mandatory persistent storage
/// - Optional identity caching with user consent /// - Optional identity caching with explicit consent
/// - Ephemeral IDs prevent long-term tracking
/// - Social mappings stored locally only /// - Social mappings stored locally only
/// ///
/// It does **not** currently prevent long-term tracking by a passive radio
/// observer: the peer ID is stable (Layer 1) and signed announcements carry the
/// static keys and nickname in cleartext.
///
/// ## Trust Model /// ## Trust Model
/// Four levels of trust: /// Four levels of trust:
/// 1. **Unknown**: New or unverified peers /// 1. **Unknown**: New or unverified peers
@@ -56,17 +63,17 @@
/// 4. **Verified**: Cryptographic verification completed /// 4. **Verified**: Cryptographic verification completed
/// ///
/// ## Identity Resolution /// ## Identity Resolution
/// When a peer rotates their ephemeral ID: /// When a peer's ID changes (a panic wipe on their side, or a future rotation):
/// 1. Cryptographic handshake reveals their fingerprint /// 1. Cryptographic handshake reveals their fingerprint
/// 2. System looks up social identity by fingerprint /// 2. System looks up social identity by fingerprint
/// 3. UI seamlessly maintains user relationships /// 3. UI seamlessly maintains existing relationships
/// 4. Historical messages remain properly attributed /// 4. Historical messages remain properly attributed
/// ///
/// ## Conflict Resolution /// ## Conflict Resolution
/// Handles edge cases like: /// Handles edge cases like:
/// - Multiple peers claiming same nickname /// - Multiple peers claiming same nickname
/// - Nickname changes and conflicts /// - Nickname changes and conflicts
/// - Identity rotation during active chats /// - Identity replacement during active chats
/// - Network partitions and rejoins /// - Network partitions and rejoins
/// ///
/// ## Usage Example /// ## Usage Example
@@ -85,11 +92,13 @@ import BitFoundation
// MARK: - Three-Layer Identity Model // MARK: - Three-Layer Identity Model
/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy. /// Represents the network layer of identity the peer ID seen on air, plus the
/// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships. /// handshake state tracked against it.
///
/// Named "ephemeral" for historical reasons; the peer ID is in fact stable,
/// being derived from the Noise static key fingerprint. It does not rotate and
/// does not prevent tracking.
struct EphemeralIdentity { struct EphemeralIdentity {
let peerID: PeerID // 8 random bytes
let sessionStart: Date
var handshakeState: HandshakeState var handshakeState: HandshakeState
} }
@@ -98,19 +107,18 @@ 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.
/// This identity persists across ephemeral ID rotations and enables secure communication. /// This identity outlives any change to a peer's network ID and enables secure communication.
/// The fingerprint serves as the permanent identifier for a peer's cryptographic identity. /// The fingerprint serves as the permanent identifier for a peer's cryptographic identity, and
/// its first 8 bytes are the peer ID broadcast on the mesh.
struct CryptographicIdentity: Codable { struct CryptographicIdentity: Codable {
let fingerprint: String // SHA256 of public key let fingerprint: String // SHA256 of public key
let publicKey: Data // Noise static public key let publicKey: Data // Noise static public key
// 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 +188,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 +202,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 -8
View File
@@ -31,24 +31,20 @@
</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>NSBonjourServices</key>
<array>
<string>_bitchat-bulk._tcp</string>
</array>
<key>NSBluetoothAlwaysUsageDescription</key> <key>NSBluetoothAlwaysUsageDescription</key>
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string> <string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
<key>NSBluetoothPeripheralUsageDescription</key> <key>NSBluetoothPeripheralUsageDescription</key>
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string> <string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
<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>NSLocalNetworkUsageDescription</key>
<string>bitchat uses peer-to-peer Wi-Fi to transfer large photos and voice notes directly between nearby devices.</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>
+22533 -4258
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
+25 -21
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 }
@@ -36,11 +37,13 @@ enum CommandInfo: String, Identifiable {
var placeholder: String? { var placeholder: String? {
switch self { switch self {
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite, .ping, .trace: case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite, .ping, .trace:
return "<" + String(localized: "content.input.nickname_placeholder", defaultValue: "nickname") + ">" return "<" + String(localized: "content.input.nickname_placeholder") + ">"
case .group: case .group:
return "<" + String(localized: "content.input.group_placeholder", defaultValue: "create|invite|leave|list") + ">" return "<" + String(localized: "content.input.group_placeholder") + ">"
case .pay: case .pay:
return "<" + String(localized: "content.input.token_placeholder", defaultValue: "token") + ">" 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
} }
@@ -48,25 +51,26 @@ enum CommandInfo: String, Identifiable {
var description: String { var description: String {
switch self { switch self {
case .block: String(localized: "content.commands.block", defaultValue: "block or list blocked peers") case .block: String(localized: "content.commands.block")
case .clear: String(localized: "content.commands.clear", defaultValue: "clear chat messages") case .clear: String(localized: "content.commands.clear")
case .group: String(localized: "content.commands.group", defaultValue: "create or manage private groups") case .group: String(localized: "content.commands.group")
case .help: String(localized: "content.commands.help", defaultValue: "show available commands") case .help: String(localized: "content.commands.help")
case .hug: String(localized: "content.commands.hug", defaultValue: "send someone a warm hug") case .hug: String(localized: "content.commands.hug")
case .message: String(localized: "content.commands.message", defaultValue: "send private message") case .message: String(localized: "content.commands.message")
case .pay: String(localized: "content.commands.pay", defaultValue: "send a cashu ecash token in this chat") case .pay: String(localized: "content.commands.pay")
case .slap: String(localized: "content.commands.slap", defaultValue: "slap someone with a trout") case .slap: String(localized: "content.commands.slap")
case .unblock: String(localized: "content.commands.unblock", defaultValue: "unblock a peer") case .unblock: String(localized: "content.commands.unblock")
case .who: String(localized: "content.commands.who", defaultValue: "see who's online") case .who: String(localized: "content.commands.who")
case .favorite: String(localized: "content.commands.favorite", defaultValue: "add to favorites") case .favorite: String(localized: "content.commands.favorite")
case .unfavorite: String(localized: "content.commands.unfavorite", defaultValue: "remove from favorites") case .unfavorite: String(localized: "content.commands.unfavorite")
case .ping: String(localized: "content.commands.ping", defaultValue: "measure round-trip time to a mesh peer") case .ping: String(localized: "content.commands.ping")
case .trace: String(localized: "content.commands.trace", defaultValue: "estimate the mesh path to a peer") 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).
@@ -74,11 +78,11 @@ enum CommandInfo: String, Identifiable {
if !isGeoPublic { if !isGeoPublic {
commands.append(.pay) commands.append(.pay)
} }
// The processor rejects favorites, groups and mesh diagnostics in // The processor rejects favorites, groups, and mesh diagnostics in
// geohash contexts, so only suggest them where they actually work: mesh. // geohash contexts, so only suggest them where they work: mesh.
if isGeoPublic || isGeoDM { if isGeoPublic || isGeoDM {
return commands return commands
} }
return commands + [.favorite, .unfavorite, .group, .ping, .trace] return commands + [.favorite, .unfavorite, .ping, .trace, .group]
} }
} }
+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
} }
+225 -38
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,21 +61,57 @@ 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,
retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds, retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds,
awaitTorReady: { await TorManager.shared.awaitReady() }, // Only wait for Tor when Tor is switched on. With it off, the fetch
// is meant to go direct through the same unproxied session the relay
// sockets already use and `TorManager` has been shut down, so
// awaiting readiness would spend the whole bootstrap timeout on
// every refresh and freeze the directory on its cached copy.
//
// Deliberately keyed on the preference rather than live readiness:
// if Tor is wanted but not ready, this must keep returning false so
// the fetch is skipped instead of silently leaking the IP.
awaitTorReady: {
guard NetworkActivationService.persistedTorPreference() else { return true }
return await TorManager.shared.awaitReady()
},
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 +129,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 +151,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 +183,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 +270,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 +279,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 +300,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 +310,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 +333,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 +385,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 +399,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 +412,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.
+293 -81
View File
@@ -7,42 +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
/// 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,
@@ -50,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(
@@ -58,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
@@ -86,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
} }
@@ -106,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
} }
@@ -118,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,
@@ -160,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)
@@ -255,17 +334,115 @@ 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
/// drop the note in step with a bridged board post's expiry.
static func createGeohashTextNote( static func createGeohashTextNote(
content: String, content: String,
geohash: String, geohash: String,
senderIdentity: NostrIdentity, senderIdentity: NostrIdentity,
nickname: String? = nil nickname: String? = 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 {
tags.append(["n", nickname]) tags.append(["n", nickname])
} }
if let expiresAt {
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(),
@@ -277,6 +454,24 @@ struct NostrProtocol {
return try event.sign(with: schnorrKey) return try event.sign(with: schnorrKey)
} }
/// Create a NIP-09 deletion request for one of our own events. Relays that
/// honor NIP-09 drop the referenced event; it must be signed by the same
/// key that signed the original.
static func createDeleteEvent(
ofEventID eventID: String,
senderIdentity: NostrIdentity
) throws -> NostrEvent {
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .deletion,
tags: [["e", eventID]],
content: ""
)
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
}
// MARK: - Private Methods // MARK: - Private Methods
private static func createSeal( private static func createSeal(
@@ -347,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
@@ -369,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
@@ -377,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,
@@ -389,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)
@@ -421,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),
@@ -438,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),
@@ -448,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
} else {
let odd = Data([0x03]) + senderPubkeyData
plaintext = try attemptDecrypt(using: odd)
} }
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) plaintext = try attemptDecrypt(using: senderPubkeyData)
return String(data: pt, encoding: .utf8) ?? ""
} }
// 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(
@@ -519,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
} }
@@ -611,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
@@ -620,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()
@@ -683,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(),
+575 -115
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)
} }
} }
@@ -69,6 +76,18 @@ struct NostrRelayManagerDependencies {
/// Uniform random value in [0, 1) used to jitter reconnect backoff. /// Uniform random value in [0, 1) used to jitter reconnect backoff.
/// Injectable so tests can pin or sweep the jitter deterministically. /// Injectable so tests can pin or sweep the jitter deterministically.
var jitterUnit: () -> Double var jitterUnit: () -> Double
/// Where relay-settings changes are observed. Injectable so a test can use
/// its own center instead of racing the process-wide one.
var notificationCenter: NotificationCenter = .default
/// Relays added by hand, merged with the built-in set. Injectable so tests
/// do not have to write to shared preferences.
var customRelays: () -> [String] = { NostrRelaySettings.customRelays() }
/// Whether a location channel is currently open. Mirrors the third arm of
/// `NetworkActivationService`'s gate: teleporting into a geohash needs no
/// location permission, and without this the relays would stay filtered out
/// for someone who denied location and has no mutual favorites.
var isInLocationChannel: () -> Bool = { false }
var selectedChannelPublisher: AnyPublisher<ChannelID, Never> = Empty().eraseToAnyPublisher()
} }
private extension NostrRelayManagerDependencies { private extension NostrRelayManagerDependencies {
@@ -97,7 +116,12 @@ private extension NostrRelayManagerDependencies {
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action) DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
}, },
now: Date.init, now: Date.init,
jitterUnit: { Double.random(in: 0..<1) } jitterUnit: { Double.random(in: 0..<1) },
isInLocationChannel: {
if case .location = LocationChannelManager.shared.selectedChannel { return true }
return false
},
selectedChannelPublisher: LocationChannelManager.shared.$selectedChannel.eraseToAnyPublisher()
) )
} }
} }
@@ -106,7 +130,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 +144,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
@@ -125,15 +151,40 @@ final class NostrRelayManager: ObservableObject {
var nextReconnectTime: Date? var nextReconnectTime: Date?
} }
// Default relays carry NIP-17 gift wraps, so avoid relays known to reject kind 1059. // Built-in relays carry private-message envelopes, so avoid relays known to
private static let defaultRelays = [ // reject the kinds they use.
private static let builtInRelays = [
"wss://relay.damus.io", "wss://relay.damus.io",
"wss://nos.lol", "wss://nos.lol",
"wss://relay.primal.net", "wss://relay.primal.net",
"wss://offchain.pub" "wss://offchain.pub"
// For local testing, you can add: "ws://localhost:8080" // For local testing, you can add: "ws://localhost:8080"
] ]
private static let defaultRelaySet = Set(defaultRelays.compactMap { NostrRelayURL.normalized($0) }) private static let builtInRelaySet = Set(builtInRelays.compactMap { NostrRelayURL.normalized($0) })
/// The relays private messages target: the built-in set plus any added by
/// hand. Four hardcoded hostnames are four names for a censor to block, so
/// the added ones are what keeps this reachable without a new build.
///
/// Cached rather than computed per access: `allowedRelayList` consults the
/// set once per candidate URL, and recomputing would mean a `UserDefaults`
/// read and a fresh normalize-and-dedupe pass inside that loop. Refreshed
/// from `reloadDefaultRelays()` on construction and whenever the relay
/// settings change.
private var defaultRelays: [String] = []
private var defaultRelaySet: Set<String> = []
private func reloadDefaultRelays() {
var seen = Set<String>()
defaultRelays = (Self.builtInRelays + dependencies.customRelays())
.compactMap { NostrRelayURL.normalized($0) }
.filter { seen.insert($0).inserted }
defaultRelaySet = Set(defaultRelays)
}
/// Exposed so the relay settings UI can reject re-adding a built-in.
/// `nonisolated` because it is an immutable constant with no actor state.
nonisolated static var builtInRelayURLs: Set<String> { builtInRelaySet }
@Published private(set) var relays: [Relay] = [] @Published private(set) var relays: [Relay] = []
@Published private(set) var isConnected = false @Published private(set) var isConnected = false
@@ -184,11 +235,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 +268,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,37 +292,41 @@ 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
init() { // Per-relay off-main inbound pipeline: raw socket frames are parsed and
self.dependencies = .live() // Schnorr-verified in arrival order OFF the main actor (this is the single
hasMutualFavorites = dependencies.hasMutualFavorites() // signature verification for the whole inbound path downstream handlers
hasLocationPermission = dependencies.hasLocationPermission() // receive only verified events), then hop back to the main actor for dedup
applyDefaultRelayPolicy(force: true) // recording and handler dispatch.
// Deterministic JSON shape for outbound requests //
self.encoder.outputFormatting = .sortedKeys // Each relay connection owns its OWN AsyncStream + consumer task, so N
dependencies.mutualFavoritesPublisher // relays verify in parallel while every relay's frames stay in arrival
.receive(on: DispatchQueue.main) // order (a single subscription's events for a relay all arrive on that
.sink { [weak self] favorites in // relay's socket, so per-relay ordering preserves per-subscription
guard let self = self else { return } // ordering). A burst of EVENT frames from one busy/malicious relay only
self.hasMutualFavorites = !favorites.isEmpty // blocks that relay's own verification backlog DMs, OKs, EOSEs, and
self.applyDefaultRelayPolicy() // events from every other relay keep flowing on their own pipelines.
} //
.store(in: &cancellables) // Each stream is bounded (`.bufferingNewest`) so a relay flooding faster
dependencies.locationPermissionPublisher // than its verification drains sheds its own oldest frames instead of
.receive(on: DispatchQueue.main) // growing memory without bound; it can never starve other relays.
.sink { [weak self] state in //
guard let self = self else { return } // Continuations live in a lock-guarded, `Sendable` router (see
let authorized = (state == .authorized) // `InboundFrameRouter` at file scope) so the raw socket receive callback
if authorized == self.hasLocationPermission { return } // (which is NOT main-actor isolated) can route a frame to the right relay
self.hasLocationPermission = authorized // stream without a per-frame main hop, while the main actor owns pipeline
self.applyDefaultRelayPolicy() // creation/teardown. The expensive work (Schnorr verify) is what runs
} // off-main; the yield stays cheap.
.store(in: &cancellables) private let inboundRouter = InboundFrameRouter()
convenience init() {
self.init(dependencies: .live())
} }
internal init(dependencies: NostrRelayManagerDependencies) { internal init(dependencies: NostrRelayManagerDependencies) {
self.dependencies = dependencies self.dependencies = dependencies
hasMutualFavorites = dependencies.hasMutualFavorites() hasMutualFavorites = dependencies.hasMutualFavorites()
hasLocationPermission = dependencies.hasLocationPermission() hasLocationPermission = dependencies.hasLocationPermission()
reloadDefaultRelays()
applyDefaultRelayPolicy(force: true) applyDefaultRelayPolicy(force: true)
// Deterministic JSON shape for outbound requests // Deterministic JSON shape for outbound requests
self.encoder.outputFormatting = .sortedKeys self.encoder.outputFormatting = .sortedKeys
@@ -269,6 +348,91 @@ final class NostrRelayManager: ObservableObject {
self.applyDefaultRelayPolicy() self.applyDefaultRelayPolicy()
} }
.store(in: &cancellables) .store(in: &cancellables)
dependencies.selectedChannelPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.applyDefaultRelayPolicy()
}
.store(in: &cancellables)
// Adding or removing a relay by hand changes the target set, so
// reconcile connections now rather than at the next send.
dependencies.notificationCenter
.publisher(for: NostrRelaySettings.didChangeNotification)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard let self else { return }
// Reconcile against the previous set: a removed relay is no
// longer in `defaultRelays`, so nothing downstream would ever
// close its socket or drop its queued sends.
let previous = self.defaultRelaySet
self.reloadDefaultRelays()
self.dropRelays(previous.subtracting(self.defaultRelaySet))
self.applyDefaultRelayPolicy(force: true)
}
.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
@@ -285,6 +449,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 +464,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 +483,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 +501,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 +518,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
@@ -381,13 +551,13 @@ final class NostrRelayManager: ObservableObject {
// event locally so it survives a slow bootstrap (queued sends flush // event locally so it survives a slow bootstrap (queued sends flush
// when relays connect), then kick off connection setup, which itself // when relays connect), then kick off connection setup, which itself
// waits for Tor readiness. // waits for Tor readiness.
let targetRelays = allowedRelayList(from: relayUrls ?? Self.defaultRelays) let targetRelays = allowedRelayList(from: relayUrls ?? defaultRelays)
guard !targetRelays.isEmpty else { return } guard !targetRelays.isEmpty else { return }
enqueuePendingSend(event, pendingRelays: Set(targetRelays)) enqueuePendingSend(event, pendingRelays: Set(targetRelays))
ensureConnections(to: targetRelays) ensureConnections(to: targetRelays)
return return
} }
let requestedRelays = relayUrls ?? Self.defaultRelays let requestedRelays = relayUrls ?? defaultRelays
let targetRelays = allowedRelayList(from: requestedRelays) let targetRelays = allowedRelayList(from: requestedRelays)
guard !targetRelays.isEmpty else { return } guard !targetRelays.isEmpty else { return }
ensureConnections(to: targetRelays) ensureConnections(to: targetRelays)
@@ -406,6 +576,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 ?? 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))
@@ -504,7 +765,7 @@ final class NostrRelayManager: ObservableObject {
// SecureLogger.debug("📋 Subscription filter JSON: \(messageString.prefix(200))...", category: .session) // SecureLogger.debug("📋 Subscription filter JSON: \(messageString.prefix(200))...", category: .session)
// Target specific relays if provided; else default. Filter permanently failed relays. // Target specific relays if provided; else default. Filter permanently failed relays.
let baseUrls = relayUrls ?? Self.defaultRelays let baseUrls = relayUrls ?? defaultRelays
let urls = allowedRelayList(from: baseUrls).filter { !isPermanentlyFailed($0) } let urls = allowedRelayList(from: baseUrls).filter { !isPermanentlyFailed($0) }
let requestState = SubscriptionRequestState(messageString: messageString, relayURLs: Set(urls)) let requestState = SubscriptionRequestState(messageString: messageString, relayURLs: Set(urls))
if subscriptionRequestState[id] == requestState, subscriptionStateExists(id: id, requestState: requestState) { if subscriptionRequestState[id] == requestState, subscriptionStateExists(id: id, requestState: requestState) {
@@ -546,49 +807,60 @@ final class NostrRelayManager: ObservableObject {
} }
private func applyDefaultRelayPolicy(force: Bool = false) { private func applyDefaultRelayPolicy(force: Bool = false) {
let shouldAllow = hasMutualFavorites || hasLocationPermission let shouldAllow = hasMutualFavorites || hasLocationPermission || dependencies.isInLocationChannel()
if !force && shouldAllow == allowDefaultRelays { return } if !force && shouldAllow == allowDefaultRelays { return }
allowDefaultRelays = shouldAllow allowDefaultRelays = shouldAllow
if shouldAllow { if shouldAllow {
var existing = Set(relays.map { $0.url }) var existing = Set(relays.map { $0.url })
for url in Self.defaultRelays where !existing.contains(url) { for url in defaultRelays where !existing.contains(url) {
relays.append(Relay(url: url)) relays.append(Relay(url: url))
existing.insert(url) existing.insert(url)
} }
if dependencies.activationAllowed() { if dependencies.activationAllowed() {
ensureConnections(to: Self.defaultRelays) ensureConnections(to: defaultRelays)
} }
} else { } else {
for url in Self.defaultRelays { dropRelays(defaultRelaySet)
if let connection = connections[url] {
connection.cancel(with: .goingAway, reason: nil)
}
connections.removeValue(forKey: url)
subscriptions.removeValue(forKey: url)
pendingSubscriptions.removeValue(forKey: url)
}
messageQueueLock.lock()
for index in (0..<messageQueue.count).reversed() {
var item = messageQueue[index]
item.pendingRelays.subtract(Self.defaultRelaySet)
if item.pendingRelays.isEmpty {
messageQueue.remove(at: index)
} else {
messageQueue[index] = item
}
}
messageQueueLock.unlock()
relays.removeAll { Self.defaultRelaySet.contains($0.url) }
updateConnectionStatus()
} }
} }
/// Closes and forgets a set of relays: connection, inbound pipeline,
/// subscriptions, queued sends addressed only to them, and the published row.
private func dropRelays(_ urls: Set<String>) {
guard !urls.isEmpty else { return }
for url in urls {
if let connection = connections[url] {
connection.cancel(with: .goingAway, reason: nil)
}
connections.removeValue(forKey: url)
teardownRelayInboundPipeline(for: url)
subscriptions.removeValue(forKey: url)
pendingSubscriptions.removeValue(forKey: url)
}
messageQueueLock.lock()
for index in (0..<messageQueue.count).reversed() {
var item = messageQueue[index]
item.pendingRelays.subtract(urls)
if item.pendingRelays.isEmpty {
messageQueue.remove(at: index)
} else {
messageQueue[index] = item
}
}
messageQueueLock.unlock()
// A relay queued while Tor bootstraps would otherwise reconnect when
// the queue drains, overriding the explicit removal.
pendingTorConnectionURLs.subtract(urls)
relays.removeAll { urls.contains($0.url) }
updateConnectionStatus()
}
private func allowedRelayList(from urls: [String]) -> [String] { private func allowedRelayList(from urls: [String]) -> [String] {
var seen = Set<String>() var seen = Set<String>()
var result: [String] = [] var result: [String] = []
for rawURL in urls { for rawURL in urls {
guard let url = NostrRelayURL.normalized(rawURL) else { continue } guard let url = NostrRelayURL.normalized(rawURL) else { continue }
if !allowDefaultRelays && Self.defaultRelaySet.contains(url) { continue } if !allowDefaultRelays && defaultRelaySet.contains(url) { continue }
if seen.insert(url).inserted { if seen.insert(url).inserted {
result.append(url) result.append(url)
} }
@@ -795,7 +1067,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 +1176,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 +1196,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 +1216,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 +1254,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,39 +1280,63 @@ 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
/// duplicate LOOKUP (no recording) so duplicate fan-in from multiple
/// relays never pays for Schnorr verification. Recording happens only
/// after the signature verifies (`deliverVerifiedInboundEvent`), so a
/// forged-signature copy can never poison the dedup cache and suppress
/// 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 !eventID.isEmpty else { return true }
let key = InboundEventKey(subscriptionID: subscriptionID, eventID: eventID)
if recentInboundEventKeys.contains(key) {
recordDuplicateInboundEventDrop(subscriptionID: subscriptionID)
return false
}
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 {
return
}
if event.kind != 1059 {
// Per-event logging floods dev builds in busy geohashes; sample it.
inboundEventLogCount += 1
if inboundEventLogCount == 1 || inboundEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
SecureLogger.debug("📥 Event #\(inboundEventLogCount) kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
}
}
if let handler = self.messageHandlers[subId] {
handler(event)
} else {
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) { private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) {
switch parsed { switch parsed {
case .event(let subId, let event): case .event:
if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) { // Events flow through the serial inbound pipeline (precheck
self.relays[index].messagesReceived += 1 // off-main signature verification deliverVerifiedInboundEvent)
} // and never reach this fallback.
guard event.isValidSignature() else { assertionFailure("inbound EVENT bypassed the verified pipeline")
SecureLogger.warning(
"⚠️ Dropped invalid Nostr event id=\(event.id.prefix(16))… sub=\(subId) relay=\(relayUrl)",
category: .session
)
return
}
guard shouldDeliverInboundEvent(subscriptionID: subId, eventID: event.id) else {
return
}
if event.kind != 1059 {
// Per-event logging floods dev builds in busy geohashes; sample it.
inboundEventLogCount += 1
if inboundEventLogCount == 1 || inboundEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
SecureLogger.debug("📥 Event #\(inboundEventLogCount) kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
}
}
if let handler = self.messageHandlers[subId] {
handler(event)
} else {
SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session)
}
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 +1344,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 +1361,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 +1379,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 +1401,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 {
@@ -1093,16 +1418,18 @@ final class NostrRelayManager: ObservableObject {
isConnected = relays.contains { $0.isConnected } isConnected = relays.contains { $0.isConnected }
// Relay URLs are normalized before entries are created, so direct // Relay URLs are normalized before entries are created, so direct
// set membership is sound. // set membership is sound.
isDMRelayConnected = relays.contains { $0.isConnected && Self.defaultRelaySet.contains($0.url) } isDMRelayConnected = relays.contains { $0.isConnected && defaultRelaySet.contains($0.url) }
} }
/// A relay that drops before sending EOSE must not stall initial-load /// A relay that drops before sending EOSE must not stall initial-load
/// 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 +1438,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 +1558,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 +1653,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 +1731,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 +1776,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 +1900,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
+92
View File
@@ -0,0 +1,92 @@
//
// NostrRelaySettings.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import Foundation
/// Relays someone has added by hand, alongside the built-in set.
///
/// The built-in relays are four well-known clearnet hostnames, so a censor
/// blocking four names ends internet-delivered private messages for everyone.
/// Adding relays including `.onion` addresses, or a relay run by whoever
/// needs it is the escape hatch that does not require shipping a new build.
///
/// Stored normalized so comparisons against connection keys and the built-in
/// set are exact, and bounded so a long list cannot turn every send into a
/// fan-out across dozens of sockets.
enum NostrRelaySettings {
/// Enough to add a personal relay, an onion address, and a couple of
/// regional fallbacks without letting the connection fan-out grow unbounded.
static let maxCustomRelays = 8
private static let storageKey = "nostr.customRelays"
static let didChangeNotification = Notification.Name("bitchat.nostrRelaySettingsDidChange")
enum AddFailure: Error, Equatable {
case malformed
case alreadyPresent
case limitReached
}
/// Normalized relay URLs, in the order they were added.
static func customRelays(in defaults: UserDefaults = .standard) -> [String] {
let stored = defaults.stringArray(forKey: storageKey) ?? []
// Re-normalize on read: a value written by an older build, or edited
// outside the app, must not reach the connection layer unchecked.
var seen = Set<String>()
return stored.compactMap { NostrRelayURL.normalized($0) }
.filter { seen.insert($0).inserted }
}
/// Adds a relay, returning the normalized URL or why it was rejected.
@discardableResult
static func add(
_ rawValue: String,
builtIn: Set<String>,
in defaults: UserDefaults = .standard
) -> Result<String, AddFailure> {
// Bare hostnames are the common way people quote a relay, and wss is
// the only sensible assumption for one.
guard let normalized = NostrRelayURL.normalized(rawValue, defaultScheme: "wss") else {
return .failure(.malformed)
}
var current = customRelays(in: defaults)
guard !current.contains(normalized), !builtIn.contains(normalized) else {
return .failure(.alreadyPresent)
}
guard current.count < maxCustomRelays else {
return .failure(.limitReached)
}
current.append(normalized)
write(current, in: defaults)
return .success(normalized)
}
static func remove(_ url: String, in defaults: UserDefaults = .standard) {
// Same default scheme as `add`, so a relay entered as a bare hostname
// can be removed the way it was typed.
guard let normalized = NostrRelayURL.normalized(url, defaultScheme: "wss") else { return }
let remaining = customRelays(in: defaults).filter { $0 != normalized }
write(remaining, in: defaults)
}
/// Panic-wipe hook: an added relay names somewhere someone chose to route
/// through, which is exactly the kind of trace a wipe should not leave.
static func reset(in defaults: UserDefaults = .standard) {
defaults.removeObject(forKey: storageKey)
NotificationCenter.default.post(name: didChangeNotification, object: nil)
}
private static func write(_ relays: [String], in defaults: UserDefaults) {
defaults.set(relays, forKey: storageKey)
NotificationCenter.default.post(name: didChangeNotification, object: nil)
}
}
-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>
+94 -11
View File
@@ -28,14 +28,12 @@ struct BitchatFilePacket {
/// Encodes the packet using v2 canonical TLVs (4-byte FILE_SIZE, 4-byte CONTENT length). /// Encodes the packet using v2 canonical TLVs (4-byte FILE_SIZE, 4-byte CONTENT length).
/// Returns `nil` when fields exceed protocol limits (e.g., content > UInt32.max). /// Returns `nil` when fields exceed protocol limits (e.g., content > UInt32.max).
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk transfers pass func encode() -> Data? {
/// `FileTransferLimits.maxWifiBulkPayloadBytes`.
func encode(limit: Int = FileTransferLimits.maxPayloadBytes) -> Data? {
let resolvedSize = fileSize ?? UInt64(content.count) let resolvedSize = fileSize ?? UInt64(content.count)
guard resolvedSize <= UInt64(UInt32.max) else { return nil } guard resolvedSize <= UInt64(UInt32.max) else { return nil }
guard resolvedSize <= UInt64(limit) else { return nil } guard resolvedSize <= UInt64(FileTransferLimits.maxPayloadBytes) else { return nil }
guard content.count <= Int(UInt32.max) else { return nil } guard content.count <= Int(UInt32.max) else { return nil }
guard FileTransferLimits.isValidPayload(content.count, limit: limit) else { return nil } guard FileTransferLimits.isValidPayload(content.count) else { return nil }
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) { func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
var big = value.bigEndian var big = value.bigEndian
@@ -68,9 +66,7 @@ struct BitchatFilePacket {
} }
/// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible. /// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible.
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk transfers pass static func decode(_ data: Data) -> BitchatFilePacket? {
/// the (smaller of the) accepted-offer size and the Wi-Fi bulk ceiling.
static func decode(_ data: Data, limit: Int = FileTransferLimits.maxPayloadBytes) -> BitchatFilePacket? {
var cursor = data.startIndex var cursor = data.startIndex
let end = data.endIndex let end = data.endIndex
@@ -130,7 +126,7 @@ struct BitchatFilePacket {
for byte in value { for byte in value {
size = (size << 8) | UInt64(byte) size = (size << 8) | UInt64(byte)
} }
if size > UInt64(limit) { if size > UInt64(FileTransferLimits.maxPayloadBytes) {
return nil return nil
} }
fileSize = size fileSize = size
@@ -139,7 +135,7 @@ struct BitchatFilePacket {
mimeType = String(data: Data(value), encoding: .utf8) mimeType = String(data: Data(value), encoding: .utf8)
case .content: case .content:
let proposedSize = content.count + value.count let proposedSize = content.count + value.count
if proposedSize > limit { if proposedSize > FileTransferLimits.maxPayloadBytes {
return nil return nil
} }
content.append(contentsOf: value) content.append(contentsOf: value)
@@ -149,7 +145,7 @@ struct BitchatFilePacket {
} }
guard !content.isEmpty else { return nil } guard !content.isEmpty else { return nil }
guard FileTransferLimits.isValidPayload(content.count, limit: limit) else { return nil } guard FileTransferLimits.isValidPayload(content.count) else { return nil }
return BitchatFilePacket( return BitchatFilePacket(
fileName: fileName, fileName: fileName,
fileSize: fileSize ?? UInt64(content.count), fileSize: fileSize ?? UInt64(content.count),
@@ -158,3 +154,90 @@ struct BitchatFilePacket {
) )
} }
} }
/// Wire-compatible identity for private media exchanged by clients using the
/// current iOS entropy-bearing filenames, without extending the deployed file
/// TLV. Android clients reject unknown file tags, so eligible senders and
/// receivers derive the receipt key from fields already on the wire.
///
/// Locally-created image and voice-note filenames contain a UUID or live-voice
/// burst ID. Including the normalized direction keeps a reused filename
/// distinct across chats while allowing short and full Noise-key peer IDs to
/// converge. Android and older-iOS timestamp-only names remain ineligible and
/// retain their legacy random local IDs (transfer-compatible, no receipts).
enum PrivateMediaMessageIdentity {
private static let domain = Data("bitchat-private-media-message-v1".utf8)
private static let idPrefix = "media-"
private static let digestHexLength = 32
static func isStableID(_ candidate: String) -> Bool {
guard candidate.hasPrefix(idPrefix) else { return false }
let digest = candidate.dropFirst(idPrefix.count)
guard digest.utf8.count == digestHexLength else { return false }
return digest.utf8.allSatisfy { byte in
(UInt8(ascii: "0")...UInt8(ascii: "9")).contains(byte)
|| (UInt8(ascii: "a")...UInt8(ascii: "f")).contains(byte)
}
}
static func stableID(
senderPeerID: PeerID,
recipientPeerID: PeerID,
fileName: String?
) -> String? {
guard let fileName, !fileName.isEmpty else { return nil }
let leafName = (fileName as NSString).lastPathComponent
guard leafName == fileName else { return nil }
let path = leafName as NSString
let stem = path.deletingPathExtension
let fileExtension = path.pathExtension.lowercased()
switch true {
case stem.hasPrefix("img_"):
guard fileExtension == "jpg" || fileExtension == "jpeg" else { return nil }
case stem.hasPrefix("voice_"):
guard fileExtension == "m4a" else { return nil }
default:
return nil
}
let entropyToken = stem.split(separator: "_").last.map(String.init)
let hasUUIDEntropy = entropyToken.flatMap(UUID.init(uuidString:)) != nil
let voiceBurstID = stem.hasPrefix("voice_")
? String(stem.dropFirst("voice_".count))
: ""
let hasBurstEntropy = voiceBurstID.count == 16
&& voiceBurstID.allSatisfy(\.isHexDigit)
guard hasUUIDEntropy || hasBurstEntropy else {
return nil
}
let fields = [
Data(senderPeerID.toShort().bare.utf8),
Data(recipientPeerID.toShort().bare.utf8),
Data(leafName.utf8)
]
var input = domain
for field in fields {
guard let length = UInt32(exactly: field.count) else { return nil }
var bigEndianLength = length.bigEndian
withUnsafeBytes(of: &bigEndianLength) {
input.append(contentsOf: $0)
}
input.append(field)
}
return "\(idPrefix)\(input.sha256Hex().prefix(digestHexLength))"
}
static func stableID(
for packet: BitchatFilePacket,
senderPeerID: PeerID,
recipientPeerID: PeerID
) -> String? {
stableID(
senderPeerID: senderPeerID,
recipientPeerID: recipientPeerID,
fileName: packet.fileName
)
}
}
+42 -8
View File
@@ -38,11 +38,15 @@
/// 7. **Decoding**: Binary data parsed back to message objects /// 7. **Decoding**: Binary data parsed back to message objects
/// ///
/// ## Security Considerations /// ## Security Considerations
/// - Message padding (to 256/512/1024/2048-byte blocks) obscures actual content length /// - Noise frames are padded (to 256/512/1024/2048-byte blocks) to obscure
/// content length; other packet types are not padded, so their payload
/// length is observable
/// - Randomized relay jitter reduces the traffic-analysis signal; there is no /// - Randomized relay jitter reduces the traffic-analysis signal; there is no
/// cover traffic or per-message timing obfuscation /// cover traffic or per-message timing obfuscation
/// - Integration with Noise Protocol for E2E encryption /// - Integration with Noise Protocol for E2E encryption
/// - No persistent identifiers in protocol headers /// - The 8-byte sender ID in every header IS a persistent identifier: it is
/// derived from the long-lived Noise static key and rotates only on a panic
/// wipe. Treat headers as linkable across sessions.
/// ///
/// ## Message Types /// ## Message Types
/// - **Announce/Leave**: Peer presence notifications /// - **Announce/Leave**: Peer presence notifications
@@ -74,27 +78,50 @@ enum NoisePayloadType: UInt8 {
case privateMessage = 0x01 // Private chat message case privateMessage = 0x01 // Private chat message
case readReceipt = 0x02 // Message was read case readReceipt = 0x02 // Message was read
case delivered = 0x03 // Message was delivered case delivered = 0x03 // Message was delivered
// Wi-Fi bulk transport negotiation (AWDL data plane for large media) // Private groups (0x04/0x05 reserved by other features)
case bulkTransferOffer = 0x04 // Offer to move a large file over peer-to-peer Wi-Fi
case bulkTransferResponse = 0x05 // Accept/decline reply to a bulk transfer offer
// Private groups
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"
case .readReceipt: return "readReceipt" case .readReceipt: return "readReceipt"
case .delivered: return "delivered" case .delivered: return "delivered"
case .bulkTransferOffer: return "bulkTransferOffer"
case .bulkTransferResponse: return "bulkTransferResponse"
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"
@@ -132,6 +159,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?)
@@ -155,6 +185,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
} }
+4 -4
View File
@@ -10,11 +10,11 @@ enum Geohash {
return map return map
}() }()
/// Validates a geohash string for building-level precision (8 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 valid 8-character base32 geohash, false otherwise /// - Returns: true if a non-empty base32 geohash of at most 12 characters
static func isValidBuildingGeohash(_ geohash: String) -> Bool { static func isValidGeohash(_ geohash: String) -> Bool {
guard geohash.count == 8 else { return false } guard (1...12).contains(geohash.count) else { return false }
return geohash.lowercased().allSatisfy { base32Map[$0] != nil } return geohash.lowercased().allSatisfy { base32Map[$0] != nil }
} }
+6 -6
View File
@@ -24,17 +24,17 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
var displayName: String { var displayName: String {
switch self { switch self {
case .building: case .building:
return String(localized: "location_levels.building", defaultValue: "building", comment: "Name for building-level location channel") return String(localized: "location_levels.building", comment: "Name for building-level location channel")
case .block: case .block:
return String(localized: "location_levels.block", defaultValue: "block", comment: "Name for block-level location channel") return String(localized: "location_levels.block", comment: "Name for block-level location channel")
case .neighborhood: case .neighborhood:
return String(localized: "location_levels.neighborhood", defaultValue: "neighborhood", comment: "Name for neighborhood-level location channel") return String(localized: "location_levels.neighborhood", comment: "Name for neighborhood-level location channel")
case .city: case .city:
return String(localized: "location_levels.city", defaultValue: "city", comment: "Name for city-level location channel") return String(localized: "location_levels.city", comment: "Name for city-level location channel")
case .province: case .province:
return String(localized: "location_levels.province", defaultValue: "province", comment: "Name for province-level location channel") return String(localized: "location_levels.province", comment: "Name for province-level location channel")
case .region: case .region:
return String(localized: "location_levels.region", defaultValue: "region", comment: "Name for region-level location channel") return String(localized: "location_levels.region", comment: "Name for region-level location channel")
} }
} }
} }
@@ -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,9 +3,11 @@ 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 = { static let localSupported: PeerCapabilities = [
var caps: PeerCapabilities = [.prekeys, .vouch, .groups] .vouch,
if TransportConfig.wifiBulkEnabled { caps.insert(.wifiBulk) } .prekeys,
return caps .groups,
}() .privateMedia,
.privateMediaReceipts
]
} }
+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)
} }
+18 -9
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,16 +23,18 @@ 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 {
let minimumInterval = force ? forcedMinimumInterval : normalMinimumInterval lock.withLock {
guard elapsed(since: now) >= minimumInterval else { let minimumInterval = force ? forcedMinimumInterval : normalMinimumInterval
return false guard now.timeIntervalSince(lastSent) >= minimumInterval else {
} return false
}
lastSent = now lastSent = now
return true return true
}
} }
} }
+47 -11
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
rawAllowed, // (BLEService's raw bind and verified rebind). Collapsing them per
peripheralPeerBindings: peripheralPeerBindings, // peer starves duplicate same-peer links of the announce they need to
centralPeerBindings: centralPeerBindings // become bound the duplicates then look "pre-announce" forever and
) // every broadcast sprays down all of them. Announces are small and
// throttled, so they go on every live link.
let allowed = collapseDuplicatePeerLinks
? collapseDuplicateLinksPerPeer(
rawAllowed,
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings,
preferredPeripheralPerPeer: preferredPeripheralPerPeer
)
: rawAllowed
guard shouldSubset(packetType: packetType, directedPeerHint: directedPeerHint) else { 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 {

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