Compare commits

...
Author SHA1 Message Date
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
c2a5668569 Sync cleanups: normalize SyncTypeFlags, single announce-ID path, TODO (#1373)
Follow-ups deferred from the REQUEST_SYNC review (#1371):

- SyncTypeFlags.init(rawValue:) now masks to the union of bits that map to a
  known message type (derived from the bit↔type table, so it tracks new
  types automatically). Phantom bits from a truncated/garbled flags field —
  or a type a newer peer added — no longer live in the set as membership no
  contains() matches yet toData() re-serializes.

- GossipSyncManager stored each latest announce as (hex-id string, packet)
  and diffed announces against the stored string while every other type
  recomputed the ID via PacketIdUtil. Collapsed the store to just the packet
  and recompute the ID everywhere, removing the latent dual-path divergence.

- Documented the REQUEST_SYNC TLV table and marked fragmentIdFilter (0x06)
  with a TODO(v2): it's parsed/re-serialized but never populated or honored
  (reserved for incremental fragment sync) — finish or drop, not silent dead
  surface.

Adds SyncTypeFlags phantom-bit/round-trip tests and a GossipSyncManager test
that an announce already in the requester's filter is suppressed (guards the
recompute path). Full suite: 1034 tests pass.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:04:14 +02:00
7341696280 Expand store-and-forward: open couriers, spray-and-wait, persistent outbox, 6h public history (#1372)
Store-and-forward previously delivered to an out-of-range peer only if a
mutual favorite happened to be connected at send time and later met the
recipient directly, and everything except courier envelopes died with the
app process. This closes those gaps end to end:

- Persist the MessageRouter outbox to disk, sealed with a ChaChaPoly key
  held only in the Keychain (no plaintext at rest); queued private
  messages now survive an app kill and flush on next launch.
- Deposit retry: queued messages are re-deposited whenever a new eligible
  courier connects, tracked per message so the same courier is never
  double-burned, until 3 distinct couriers carry it or it expires.
- Tiered open couriering: signature-verified strangers can now carry mail
  (2 envelopes/depositor into a 20-slot pool) alongside mutual favorites
  (5 each); overflow evicts verified-tier mail before favorites'.
- Spray-and-wait: envelopes carry a copy budget (4, capped 8, new TLV,
  wire-compatible with old clients); couriers split half their remaining
  budget with each newly encountered courier so mail diffuses through a
  moving crowd.
- Remote handover: a verified relayed announce now floods a copy toward
  the multi-hop recipient (directed-relay treatment, 10-min per-envelope
  cooldown) while the carried original stays put for a direct encounter.
- Public history: gossip-sync window for whole public messages widened
  from 15 min to 6 h, matched on the receive-acceptance side, and the
  message store persists to disk so devices bridge partitions and
  restarts ("town crier").
- Privacy-safe local delivery counters (bare tallies, log-only) so the
  store-and-forward stack is measurable on-device.
- Panic wipe now also clears the sealed outbox, gossip archive, and
  counters.
- Rewrite WHITEPAPER.md to describe the app as implemented (Noise XX/X,
  actual flood control, courier system, gossip sync, Nostr path); the old
  document described a bloom filter, three fragment types, and a
  MessageRetryService that don't exist.

1037 macOS tests pass (17 new); iOS builds.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 19:33:16 +02:00
295f855b6f Harden REQUEST_SYNC and stop gossip-sync re-send loops (#1371)
* Harden REQUEST_SYNC and stop gossip-sync re-send loops

Two fixes from an end-to-end review of the sync path:

Efficiency: the GCS filter (400B, p=7) covers ~355 packet IDs, but stores
hold up to 1000 messages + 600 fragments + 200 files. Once a mesh
accumulates more than the filter can cover, responders re-sent the entire
older tail to every requester every round — ~120KB per pair per 30s during
file transfers, dropped by dedup after the airtime was already burned.
Requesters now stamp the dormant sinceTimestamp TLV with the oldest
timestamp their filter covers, and responders skip older packets (announces
exempt: they carry the signing keys needed to verify everything else).
Periodic sync also sends one request per type schedule instead of a union
filter, so fragment floods can't crowd messages out of the filter budget.

Security: a ~40-byte unsigned REQUEST_SYNC with an empty filter could elicit
a full store replay (~900KB) — an unauthenticated >10,000x amplification
vector, repeatable in a tight loop and relayable with crafted TTL to fan the
drain out of every reachable node. Requests now require ttl == 0, a valid
signature from the claimed sender's announced signing key, and a matching
link binding; REQUEST_SYNC is never relayed regardless of TTL; and responses
are rate-limited per peer (8 per 30s sliding window, ~3x the legitimate
cadence).

Cross-platform: verified against bitchat-android — it signs REQUEST_SYNC and
sends SYNC_TTL_HOPS = 0, so both gates hold; it neither sends nor honors
sinceTimestamp yet, so mixed pairs keep today's behavior with no regression.

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

* Address Codex review: enforce no-relay on route path, exact since-cursor

Two P2 findings from Codex on the REQUEST_SYNC hardening:

- Route-forwarding bypass: handleRequestSync's early return for a rejected
  (nonzero-TTL / unsigned) request still fell through to
  forwardAlongRouteIfNeeded, which relays any routed packet with ttl > 1
  regardless of type. The no-relay invariant was only enforced on the flood
  path. BLERouteForwardingPolicy now suppresses REQUEST_SYNC outright, so a
  crafted request with a route and TTL headroom can't be forwarded to the
  next hop either.

- Inexact since-cursor: GCSFilter.buildFilter trimmed by hash order when the
  encoding overflowed the byte budget, so the cursor (computed from the
  untrimmed prefix) could claim coverage of timestamps whose packets were
  dropped from the filter — re-sending exactly those every round. buildFilter
  now trims from the input tail (oldest, since candidates are newest-first)
  and reports includedCount; the cursor is derived from that, so the covered
  set is always a contiguous newest-prefix and the cursor is exact.

Adds GCSFilter includedCount coverage (full vs trimmed), a route-forwarding
test for REQUEST_SYNC, and makes the truncated-cursor test robust to trim
variance. Full suite: 1029 tests pass.

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

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:17:26 +02:00
3ea4188699 Single-source MARKETING_VERSION in the xcconfig (#1369)
The version lived in five places: Release.xcconfig (which Debug
includes) plus four literal per-target overrides in the pbxproj that
shadow it. A bump that misses any subset splits app and extension
versions, and App Store validation rejects the archive
("CFBundleShortVersionString of an app extension must match its
containing parent app"). Remove the pbxproj entries so every target in
every configuration inherits the one xcconfig value; verified all six
target/config combinations resolve to 1.5.4 via -showBuildSettings.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 18:15:13 +02:00
a66c591f8e Courier: deposit in parallel when the only route is a send queue (#1368)
* Deposit with couriers in parallel when the only route is a send queue

The courier path was nearly unreachable: NostrTransport claims any
favorite with a known npub as "reachable" regardless of connectivity,
and the mesh favorite exchange shares npubs, so for essentially every
courier-eligible recipient the router picked Nostr's reachable branch.
With no internet the message just sat in the relay send queue — in the
flagship scenario (internet shutdown, mutual friend standing right
there) the courier walked away carrying nothing.

Add Transport.canDeliverPromptly(to:), defaulting to reachability for
radio-backed transports; NostrTransport answers honestly by mirroring
the relay manager's connection state (fail-closed behind Tor). When the
chosen transport can't hand the message off promptly, the router now
also deposits a sealed copy with connected couriers. Double delivery is
harmless: receivers dedup by message ID, and delivered/read acks never
downgrade the carried status. When relays are up, sends are trusted and
no courier quota is spent.

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

* Track DM-relay connectivity, not any-relay, for prompt delivery

Codex review: NostrRelayManager.isConnected is true when any relay is
up, including geohash/custom relays — but private messages target the
default (gift-wrap-capable) relay set and queue when none of those are
connected. A lone geohash relay would have suppressed the parallel
courier deposit while the DM sat in the queue. Publish a DM-scoped
connectivity flag and drive canDeliverPromptly from 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-06 17:45:57 +02:00
75da63c9d7 Fix favorites end-to-end: peer-list duplicates, Nostr sync, /fav key corruption (v1.5.4) (#1367)
* Friend-courier store-and-forward: mutual favorites carry sealed messages to offline peers

When a private message has no reachable transport, the router now seals it
to the recipient's Noise static key (new one-way Noise X pattern) and hands
the envelope to up to three connected mutual favorites. Couriers store the
opaque ciphertext under strict quotas (20 total, 5 per depositor, 16 KiB,
24 h) and hand it over when the recipient's announce matches a rotating
HMAC recipient tag; the recipient opens it and the message flows through
the normal private-message pipeline, so dedup and delivery acks just work.

- CourierEnvelope TLV + courierEnvelope (0x04) message type in BitFoundation
- Noise X one-way pattern reusing the existing handshake machinery,
  domain-separated by a courier prologue; sender identity authenticated
  via the ss DH (no forward secrecy - documented tradeoff)
- CourierStore with eviction, file persistence, and panic-wipe integration
- Rotating recipient tags (HMAC over epoch day) so carried envelopes don't
  correlate for observers who don't already know the recipient's key
- New "carried" delivery status with figure.walk glyph; header indicator
  while carrying mail for others
- Three-node end-to-end test ferrying packets through real BLEService
  instances, plus codec/crypto/store/router suites (986 tests green)

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

* Fix courier handoff verification and directed sends

* Authenticate courier deposits by ingress peer

* Gate courier handover on direct announces and isolate store test

Envelopes are removed from the courier store optimistically, so releasing
them on a relayed (multi-hop) announce risks losing carried mail to a
speculative flood that never reaches the recipient. Handover now also
requires the announce to have arrived directly (full TTL), i.e. an actual
encounter with a live link; regression test builds a relayed copy of a
genuinely signed announce (TTL is excluded from announce signatures).

Also make CourierStore's on-disk location injectable so the persistence
test round-trips through a temp directory instead of wiping the real
Application Support store, and reattach BLEAnnounceHandler's doc comment
to the class it describes.

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

* Use Xcode-bundled Swift in CI instead of a standalone toolchain

The unpinned setup-swift action installs Swift 6.1, which refuses the
SDK on runner images that have rolled to Xcode 26.5 ("this SDK is not
supported by the compiler"). Jobs passed or failed depending on which
image they landed on. The Xcode-bundled toolchain always matches the
image's SDK, and matches local development. Cache keys now include the
toolchain version so artifacts from one compiler are never restored
into builds with another.

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

* Drop couriered mail from blocked senders at envelope open

The UI-layer block check (isPeerBlocked in the transport event
coordinator) resolves a fingerprint from the live session or peer list,
but a couriered message arrives precisely when its sender is absent —
no session, no registry entry — so the check failed open and a blocked
identity's mail was delivered anyway. Gate in openCourierEnvelope,
where the sealed sender's full static key is in hand.

End-to-end test ferries a full deposit→carry→handover round and
verifies the envelope from a blocked sender never reaches the delegate
(confirmed failing without the gate).

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

* Fix favorites end-to-end: peer-list dedup, Nostr sync, /fav key corruption

- UnifiedPeerService: dedup offline favorites against mesh peers by noise
  key. Phase 2 compared a 64-hex noise-key PeerID against 16-hex mesh IDs
  (never equal), leaving only a nickname+isConnected heuristic — a mutual
  favorite that was reachable-but-not-connected or renamed rendered twice,
  and a same-nick stranger could suppress a favorite entirely.
- Nostr inbound: intercept [FAVORITED]/[UNFAVORITED] markers in the live
  PM handler so they update theyFavoritedUs instead of rendering as chat
  text; mutual favorites can now form over Nostr. Delete the dead
  favorite-aware PM variant and ChatNostrCoordinator.handleFavoriteNotification
  (unwired, parsed a stale FAVORITE:TRUE|… format no sender emits).
- NostrTransport.isPeerReachable: match short form regardless of incoming
  ID width — toggling an offline favorite (addressed by 64-hex noise key)
  was silently dropped with no reachable transport.
- BLEService.sendPrivateMessage: normalize recipient to the short ID like
  sendFilePrivate, so a 64-hex target hits the existing Noise session
  instead of initiating a handshake with a 32-byte wire recipient ID.
- /fav, /unfav: stop writing Data(hexString: peerID.id) — the 8-byte
  routing ID for mesh peers — into the favorites store as a "noise key",
  and stop double-sending the favorite notification; delegate to
  toggleFavorite with a proper state check.
- FavoritesPersistenceService.updatePeerFavoritedUs: keep the stored
  nickname when the caller passes the "Unknown" placeholder.
- Bump marketing version to 1.5.4.

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

* Route DMs to mutual favorites via Nostr when a mesh-keyed peer goes offline

Field-tested on device: with a DM window opened while the peer was on
mesh (conversation keyed by the short 16-hex ID), walking out of range
and sending failed instantly with "peer not reachable" even though the
header showed the peer as Nostr-reachable (mutual favorite, npub known).

sendPrivateMessage derived the favorites key as Data(hexString:
peerID.id) — for a short mesh ID that is the 8-byte routing ID, never
the noise key — so the mutual-favorite/Nostr-key checks always came up
empty and the send failed before reaching MessageRouter. Conversations
keyed by the full 64-hex noise-key ID (opened from the offline favorite
row) were unaffected, which is why later tests appeared to work.

Resolve the noise key properly (peerID.noiseKey, then the unified peer
row, then the favorites store by derived short ID) and add a regression
test for the mesh-keyed-peer-goes-offline case.

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

* Label Nostr DMs from favorites with their stored nickname

Field-tested: a DM delivered over the Nostr fallback rendered as
"anon#678e" instead of the sender's name. The inbound handler named the
sender via displayNameForNostrPubkey, which only knows geohash-scoped
names — even though the pipeline had already resolved the sender's
noise key (the conversation is keyed by it).

When the conversation key carries a noise key, prefer the favorite's
stored nickname; geohash DMs (nostr_ keys) keep the anon geo name. This
also stops an inbound Nostr [FAVORITED] from overwriting the stored
nickname with the anon fallback, since the same name feeds
updatePeerFavoritedUs.

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

* Fix courier path for offline favorites addressed by noise-key IDs

Two Codex review findings, both the same ID-width confusion this PR
targets, in the courier flow:

- CourierDirectory.favoritesBacked resolved recipients only via
  getFavoriteStatus(forPeerID:), which requires a short 16-hex ID —
  offline favorites are addressed by the full 64-hex noise-key ID, so
  attemptCourierDeposit silently bailed for exactly the peers couriers
  exist to serve. The 64-hex ID now yields its own key directly.
- openCourierEnvelope emitted the derived short mesh ID even when the
  sender has no live mesh identity, landing couriered mail in an
  unresolvable short-ID thread labeled "Unknown". Absent senders now
  emit the full noise-key ID so the message joins the stable favorite
  conversation; present senders keep the live short-ID thread.

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-06 17:17:21 +02:00
d285c6ad53 ux-fixes: lock alignment, caption band, wrapping, tap targets, VoiceOver, theme sweep (#1366)
* Fix lock glyph alignment, privacy-caption band, and empty-state wrapping

- Message-row locks: align to first text baseline instead of a hardcoded
  top padding that left the lock ~4pt below the line's visual center
- Header/caption locks: 1pt optical lift (lock.fill ink is bottom-heavy;
  geometric centering reads low); seal badge stays untouched
- DM privacy caption: sit on the themed surface like the rest of the
  bottom chrome instead of painting its own orange band
- Empty-state lines: non-breaking spaces so the closing * can't orphan
  and 'bitchat/ for help' can't break right after the slash

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

* Unify sheet close buttons, widen tiny tap targets, handle long nicknames

- New SheetCloseButton component: one glyph size/weight (13 semibold),
  32pt visual box, 44pt hit target; adopted by all 7 sheets (sizes had
  drifted across 12/13/14pt, two had no frame at all)
- Favorite star buttons get real tap targets (peer list + DM header)
- DM header nickname: single line with middle truncation instead of
  wrapping into the fixed-height header; peer-list names truncate tail
- Geohash people rows: leading glyph 12 -> 10 to match mesh rows
- Sidebar lock glyphs get the same optical lift as the DM header

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

* Make channel switching, voice notes, and header actions work under VoiceOver

- Channel rows in the location sheet are now single activatable buttons
  (label + selected trait + switch hint) with the bookmark toggle
  mirrored as a named accessibility action; bookmark buttons labeled
- Voice-note mic: press-and-hold drag gestures can't be activated by
  VoiceOver, so the default accessibility action now toggles
  start/stop-and-send; announces 'recording' state; localized labels
- Attachment button: camera (long-press) path exposed as a named
  action; labels localized instead of hardcoded English
- People-count button announces connected vs no-one-reachable (was
  color-only); verification QR button gains a spoken name (.help is
  only a hint on iOS); bitchat/ logo exposes its tap-for-app-info as a
  button (panic triple-tap stays undiscoverable on purpose)

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

* Theme-correctness sweep: palette colors everywhere, AX-size header growth

- Fingerprint/verification sheet cards: palette-tinted boxes instead of
  fixed gray bands that ignored matrix green and occluded glass
- Voice-note card: palette background (translucent) instead of opaque
  white/black; waveform + payment chips + image placeholder follow suit
- .secondary/.primary/Color.blue swapped for palette.secondary/primary/
  accentBlue across location sheets, people sheets, message captions,
  and the header count (system gray read wrong under matrix green)
- Autocomplete/command rows: dropped the uniform gray wash that dulled
  the themed overlay panel
- 'tap to reveal' caption follows the theme font instead of hardcoding
  monospaced
- Headers use minHeight so two-line accessibility text sizes grow the
  bar instead of clipping inside it

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

* Fix main header expanding to fill the screen

The header bar's fixed height was load-bearing: its children fill the
bar with .frame(maxHeight: .infinity) tap targets, so switching to an
open-ended minHeight let the header expand to swallow all available
vertical space, centering the title mid-screen and crushing the
timeline into the composer. Restore the fixed height — headerHeight is
a @ScaledMetric, so it already grows with Dynamic Type. Reproduced and
verified both layouts with an offscreen render harness.

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

* DM header: floating glass panel instead of muddy orange wash under glass

Orange at 14% over the backdrop gradient reads as a gray-beige band,
not a privacy signature. Under liquid glass the DM header now uses the
same floating chrome panel as the main header; the private signature is
already carried by the orange lock, caption, and composer accents.
Matrix keeps its orange wash over the opaque themed surface, unchanged.

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-06 14:22:38 +02:00
8296630cf3 Deflake app test suite: hermetic caches, robust async waits, perf-gate retry (#1365)
Four spurious CI failures on July 5, all loaded-runner flakiness:

- ViewSmokeTests.voiceAndMediaViews_renderAndWarmCaches asserted an exact
  bin count on WaveformCache.shared for the same URL the mounted
  VoiceNoteView was concurrently warming at its default 120-bin width;
  whichever barrier write landed last owned the entry. Probe the cache with
  a dedicated audio file no view touches, and purge both URLs. Also replace
  the fixed 250ms sleep for loadDuration's background hop with a waitUntil
  poll.

- sendImage_privateChatProcessesAndTransfersImage (and its sendVoiceNote /
  sendImage siblings) wait on work that hops through Task.detached; the
  global executor is shared with every parallel test worker, so a loaded
  runner can exceed the 5s wait. Raise those positive waits to
  TestConstants.longTimeout (10s) — waitUntil returns as soon as the
  condition holds, so passing runs are unaffected.

- subscribeNostrEvent_addsToTimeline_ifMatchesGeohash raced concurrently
  running suites (e.g. CommandProcessorTests) on the process-wide
  LocationChannelManager singleton: a mid-test channel flip reroutes or
  drops the event permanently, so no fixed wait recovers. The wait loop now
  re-asserts the channel and redelivers the event on each poll — idempotent
  because channel switches clear the processed-event set and the store
  dedups by message ID — so interference heals while genuine failures still
  time out.

- The performance floor gate failed on a saturated runner
  (gcs.buildAndDecode at 85% of floor). check-perf-floors.sh now re-runs
  the benchmark suite up to twice when a metric lands below floor,
  appending to the same PERF log and keeping each benchmark's best value
  across attempts: noise clears on a retry, a real algorithmic regression
  fails every attempt. Floors are unchanged and never lowered by the
  mechanism; missing-benchmark failures exit immediately without retrying.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 10:25:17 +02:00
c74e212ea3 Make peer lists accessible and actionable; block mesh peers by stable identity (#1360)
* Make peer lists accessible and actionable; block by stable identity

Who you can reach — the app's most important fact — was encoded in
unlabeled 10pt icons with macOS-only tooltips, and the mesh list had no
actions (block/favorite/verify were slash-command-only).

- Both peer lists become real accessibility citizens: each row is one
  element announcing name, reachability, and favorite/unread/blocked
  state, with a button trait and custom actions for the gesture-only
  interactions. Neither file previously had a single accessibility
  modifier. Reachability icons gain tooltips reusing existing strings;
  teleported vs in-area pins are explained.
- Mesh rows gain the context menu the geohash list already had: direct
  message, favorite, show fingerprint, block/unblock. The fingerprint
  double-tap, previously shadowed by the single tap, is reordered so it
  fires.
- The DM header's offline state (previously EmptyView — absence of a
  glyph as the only signal) becomes a dimmed "offline" tag, and a
  geohash DM — always Nostr-routed — no longer mislabels itself
  "offline".
- App Info gains a SYMBOLS legend defining every glyph the lists and
  headers use; nothing defined them before.
- Mesh block/unblock now resolve by the peer's stable Noise identity
  instead of a `/block <displayName>` string, so the exact tapped row is
  affected and offline peers can be unblocked (with covering tests).

New strings are added source-language (en) only.

* Surface block/unblock feedback in the conversation where it was triggered

setMeshPeerBlocked silently returned when the peer's identity could not
be resolved (e.g. long-press-blocking an old public message from a
sender who left and was never a favorite), where the /block command
printed "cannot block X: not found or unable to verify identity" — post
that same message from the guard branch.

Both the failure and confirmation messages now route through
addCommandOutput instead of addSystemMessage, so blocking from inside a
private chat prints into that chat rather than invisibly into the
public timeline (same routing #1363 applied to command output).

The confirmation also reuses the /block wording ("blocked X. you will
no longer receive messages from them") for parity with the command.

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

* Remove dead accessibility label and unreachable /unblock fallback

The favorite button's .accessibilityLabel in MeshPeerList is
unreachable: the row-level .accessibilityElement(children: .ignore)
swallows child elements, and the row's custom accessibility action
already covers favoriting.

ConversationUIModel.unblock is only called from the mesh peer list with
a non-optional mesh peerID, so the "/unblock <name>" fallback branch
could never run — take PeerID directly and drop the branch.

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

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 10:12:53 +02:00
7a0c821807 Improve message-list interactions: empty-state guidance, jump-to-latest, per-message actions (#1359)
* Improve message-list interactions: empty-state guidance, jump-to-latest, per-message actions

Three usability gaps in the message list, all presentation-layer:

- Empty timeline was a blank screen. It now narrates itself in dim,
  terminal-styled lines: what the channel is, that it's waiting for
  peers, and where the channel switcher and help live. Disappears with
  the first message.

- Scrolled up in a busy channel, nothing signalled that new messages
  arrived and there was no way back. A small "jump to latest" pill now
  appears while scrolled up, counting messages that arrived below, and
  taps back to the newest via the existing scroll helper. The unseen
  count re-baselines on channel switch so a cross-channel count delta is
  never shown as "new".

- A single tap anywhere on a message overwrote the composer draft with
  "@sender " and force-focused the field — casual taps while reading
  destroyed drafts. That whole-row tap is removed; mention/DM/hug/slap/
  block now live in the per-message context menu (reusing the handlers
  the existing action sheet already calls), and mention appends to the
  draft rather than replacing it. A failed own private message gets a
  resend item. The triple-tap-to-clear gesture gains a confirmation.

New strings are added source-language (en) only.

* Remove the failed original when resending a private message

Resend re-submitted the content but left the red failed bubble in
place, so every tap stacked another copy under it. Route resend
through ConversationUIModel, which drops the failed original from the
conversation store (removePrivateMessage) before sending the new copy.

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

* Count only rendered human messages in the jump-to-latest pill

The unseen count was a raw delta of the messages array, so system
lines (join/leave narration) and whitespace-only messages that never
render as rows inflated the "N new" pill. Baseline the counters
against the number of messages that render as human message rows,
using the same predicates the row builder applies.

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

* Hide mention/DM context-menu actions inside 1:1 conversations

In a private conversation, mentioning the only other participant is
noise and the DM action just reopens the already-open conversation
(toggling the sidebar). Gate both behind privatePeer == nil so the
public-timeline context menu is unchanged; hug/slap/block/copy/resend
remain in DMs.

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

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 10:09:04 +02:00
0d251ad20c Require confirmation before deleting a received image; label media controls (#1358)
* Require confirmation before deleting a received image; label media controls

Double-tapping a received image permanently deleted the message and its
file — no confirmation, no undo — while double-tap is the most ingrained
photo gesture on mobile, and it raced the reveal tap via
`.exclusively(before:)`. A mesh may never re-deliver that image, so an
accidental double-tap can destroy the only copy.

- Remove the double-tap-to-delete gesture. Delete moves into a
  long-press context menu behind a confirmation dialog ("this cannot be
  undone — the sender may not be in range to send it again"), alongside
  explicit open and hide-image actions (the swipe-to-re-blur was
  undiscoverable). Taps now only reveal and open.
- The blur overlay says "tap to reveal" instead of a bare eye-slash.
- Add the first accessibility support to these media views: labeled
  image states (hidden/revealed/sending) with custom actions, labeled
  voice play/pause with the duration as the value, and labeled cancel
  buttons.

Delete remains available and its underlying behavior is unchanged — it's
just gated. New strings are added source-language (en) only.

* Expose the in-flight cancel button to VoiceOver

The image tile uses accessibilityElement(children: .ignore), which
collapses the whole subtree — including the visible cancel button shown
while a send is in flight — into one element. VoiceOver users could not
cancel an in-progress image send. Add a cancel accessibility action for
the sending state.

* Mark the accessibility delete action destructive too

The context-menu delete already uses role: .destructive; the matching
accessibility action did not. Make them consistent.

* Deduplicate image actions and align accessibility labels with convention

Extract the open/hide/delete button set shared by the context menu and
accessibilityActions into a single @ViewBuilder so the two can't drift.
Move the interaction hints out of the accessibility labels into
accessibilityHint (labels stay nouns; "tap to reveal" was wrong for
VoiceOver activation anyway), and rename the blurred-state action to
"reveal image" since it reveals rather than opens.

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

* Offer cancel-send in the context menu while an image is sending

The context menu body was empty during sends, which some OS versions
still present as an empty preview. The accessibility path already
exposed a cancel-send action in that state; share the same button with
the context menu so pointer/touch users get a cancel path too.

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

* Label broken images honestly and drop actions that need the file

When the image file fails to load, the placeholder kept the "hidden
image"/"image" accessibility label with a reveal/open hint, and the
context menu still offered open/reveal on a URL that will not load.
Track the failed load, announce "image unavailable" with no interaction
hint, show a broken-photo glyph instead of an endless spinner, disable
the reveal/open gestures, and drop open/hide/reveal from the context
menu and accessibility actions -- keeping delete so received broken
attachments can still be cleaned up.

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

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 10:07:18 +02:00
c8ceac1968 Give private DMs an unmistakable visual signature (#1357)
* Give private DMs an unmistakable visual signature

An open DM renders identically to the public room — same view, same
green-on-black surface, with a small header name and two orange icons
as the only cues. For this audience the cost of misreading "am I in the
encrypted DM or the public channel?" is severe: sensitive text typed
into the wrong composer.

Four presentation-layer cues; no formatter or cache changes:

- The composer placeholder states the destination instead of a generic
  prompt: "message @jack — private" in a DM, "message #mesh — public,
  nearby" on mesh, "message #9q8yy — public" in a geohash channel.
- A persistent lock caption sits above the DM composer. It reads
  "private · end-to-end encrypted" only once the Noise session is
  actually secured or verified, and "private conversation" before that
  — the caption must not overstate encryption mid-handshake.
- The DM sheet header carries a faint orange wash (6%), extending the
  existing orange self-accent to the chrome.
- Each private message row is prefixed with a small orange lock glyph
  (view-layer, hidden from VoiceOver — the caption carries the
  semantic; the cached AttributedString formatter is untouched).

New strings are added source-language (en) only.

* Fix geohash-DM caption and placeholder

Two carve/review follow-ups:

- The privacy caption showed "private conversation" for geohash DMs,
  implying they are not encrypted — but geohash DMs are NIP-17
  gift-wrapped (always end-to-end encrypted), they just carry no Noise
  session status. Show the encrypted caption for geohash DMs and for
  secured Noise sessions; the pre-secured wording now applies only while
  a mesh handshake is still in progress.
- The private-chat placeholder prepended "@" to the partner name, which
  for a geohash DM (whose display name is already "#geohash/@name")
  produced a doubled "@". The "@" is now added only for mesh nicknames.

* Make the DM header orange wash visible in the matrix theme

The 6% orange background was chained after .themedSurface(), so in the
default matrix theme (whose themedSurface paints an opaque background)
the wash sat behind the surface and never rendered — it was only
visible in liquid glass. Apply the orange tint before .themedSurface()
so it layers in front of the themed background.

* Align DM lock glyph across text and media rows; keep header wash visible under glass

Media rows in a private conversation now get the same leading lock
glyph as text rows, so left edges line up instead of misaligning by
the glyph's width. The DM header's orange wash gets a higher opacity
under the liquid-glass theme, where themedSurface() adds no opaque
backing and 6% orange disappears into the backdrop gradient. Also
drops the dead sender != "system" guard in TextMessageView — system
messages are routed to systemMessageRow before this view is built.

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

* Remove orphaned content.input.message_placeholder from the string catalog

The destination-stating placeholders replaced its last code reference;
nothing on the branch resolves this key anymore.

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

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 10:06:18 +02:00
9ccff9cce4 Make private-message delivery status legible and accessible (#1356)
* Make private-message delivery status legible and accessible

The delivery indicator is the most stress-relevant signal in an
off-grid messenger, but it is hard to read:

- The status glyphs are 10pt icons whose only explanation is a
  `.help()` tooltip, which does not exist on iOS.
- Delivered vs read is the same double-checkmark distinguished only by
  colour.
- No case carries an accessibility label, so VoiceOver announces
  nothing.
- Two failure reasons ("Not delivered", "Encryption failed") bypass the
  localized reason catalog and are hardcoded English.

Changes (presentation only; the DeliveryStatus enum and the
contract-tested `displayText` are untouched):

- Add `DeliveryStatus.bitchatDescription`, a localized app-layer
  description, used as the macOS tooltip, a VoiceOver label on every
  status glyph, and — on iOS, where tooltips don't exist — a
  tap-to-reveal caption under the message.
- Failure reasons stay visible as a red caption without a tap.
- Read vs delivered is now legible without colour: read uses
  filled-circle checkmarks.
- Route the two hardcoded failure reasons through the localized catalog.

New strings are added source-language (en) only.

* Show the failure reason on failed media messages too

TextMessageView gained a visible red failure caption (the status
glyph's .help() tooltip does not exist on iOS), but MediaMessageView
still rendered the bare glyph — so a failed voice-note or image send
showed only a 10pt red triangle with no reason on iOS. Add the same
failure caption to media messages.

* Collapse revealed delivery detail when the status changes

A caption revealed while a message was "sending" stayed open and
silently morphed through later statuses (sent, delivered, read).
Reset showDeliveryDetail when the snapshotted DeliveryStatus changes.

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

* Add tap-to-reveal delivery detail to media rows

Media rows showed the same delivery glyphs as text rows but offered no
way to explain them on iOS, where .help() tooltips don't exist. Mirror
the text-row pattern: the glyph is now a button that reveals the
localized status caption below the header, failure reasons stay
visible without a tap, and the revealed caption collapses when the
status advances.

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

* Localize the remaining voice-note failure reasons

ChatMediaTransferCoordinator still passed hardcoded English reasons
into .failed(reason:), which now surface verbatim in the always-visible
failure caption. Route them through String(localized:) under the
existing content.delivery.reason.* convention.

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-06 09:58:25 +02:00
66536063ca Route command output to the conversation where the command was typed (#1363)
CommandProcessor results (/help text, errors like "unknown command",
/msg confirmations) were always appended to the public timeline via
addSystemMessage, so a command typed inside a DM appeared to do
nothing until the user switched back to the public channel.

handleCommand now routes .success/.error output to the open private
chat when one is selected, falling back to the public timeline
otherwise. The DM selection is read after processing so commands that
switch chats (/msg) print into the conversation they just opened.

Follow-up to #1354, which added /help and surfaced this routing gap.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:26:31 +02:00
Hot Pixel GroupandGitHub e191e9c6f2 Fix slash-command suggestions that insert commands the processor rejects (#1354)
The autocomplete panel is the only in-app surface for discovering slash
commands, but several suggestions do not match what CommandProcessor
accepts, so tapping them inserts a command that returns "unknown
command":

- CommandInfo suggests /dm, /favorite, /unfavorite, but the processor
  only handles /m, /msg, /fav, /unfav. Aliases are aligned to the
  accepted spellings (msg, fav, unfav).
- Favorites are suggested only in geohash contexts (isGeoPublic ||
  isGeoDM) — exactly where the processor rejects them ("favorites are
  only for mesh peers"). The gating is inverted so they appear in mesh,
  where they work.

Also, small related fixes to the discovery surface:
- /help is now handled (the ChatViewModel command docstring already
  claimed it existed); it prints a local system line listing the valid
  commands, and the unknown-command error points at it.
- The suggestion panel keeps the matched command's usage row (e.g.
  "/msg <nickname>") visible while arguments are typed, instead of
  vanishing at the first space; in that mode the row is informational
  and no longer overwrites the draft on tap.

New string is added source-language (en) only. The CommandInfo contract
test is updated to the corrected metadata.
2026-07-05 14:15:57 +02:00
b31a63ce37 Burn down SwiftLint advisory violations from 109 to 4 (#1362)
Mechanical style fixes across the enabled rule set, mostly via
swiftlint --fix (trailing_comma, comma, colon, trailing_newline,
comment_spacing, unused_closure_parameter, unneeded_break_in_switch,
opening_brace) plus hand fixes:

- non_optional_string_data_conversion (45): .data(using: .utf8)! and
  ?? Data() fallbacks replaced with the non-optional Data(_.utf8),
  including two production sites (NIP-44 HKDF info constant and the
  announce canonicalization context/nickname bytes — byte-identical
  output, only the impossible-nil handling is gone).
- switch_case_alignment: LocationChannel had a misindented closing
  brace; also repaired an --fix artifact in BLEService's .none case.
- redundant_string_enum_value: TrustLevel raw values equal to the case
  names (encoded form unchanged).
- unused_optional_binding: let _ = binds replaced with != nil / is Bool.
- static_over_final_class: PreviewView.layerClass.
- Resolved the BinaryProtocolTests TODO by documenting that 8-byte
  recipient ID truncation is the fixed wire-field size, not a bug.

The 4 remaining violations are all todo markers for a shared
test-helpers module (tracked in #1088) and one Reuse note.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:09:13 +02:00
0f26a27980 Add SwiftLint as an advisory CI-only lint job (no Xcode plugin dependency) (#1361)
* Add SwiftLint as an advisory CI-only lint job (no Xcode plugin dependency)

* Harden the advisory lint job and exclude build dirs from local runs

The lint job runs a third-party container image, so drop its token to
read-only, stop actions/checkout from persisting credentials into the
workspace the container can read, and pin the image by digest as well
as tag (tags are mutable). Also add an excluded: list to .swiftlint.yml
so local swiftlint runs don't drown in .build/DerivedData artifacts —
CI checkouts are fresh, so this only affects working trees.

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-05 13:36:59 +02:00
227 changed files with 52419 additions and 2714 deletions
+34 -2
View File
@@ -12,7 +12,10 @@ jobs:
runs-on: macos-latest runs-on: macos-latest
# A hung test must fail fast, not hold a runner for GitHub's 360-minute # A hung test must fail fast, not hold a runner for GitHub's 360-minute
# default (observed: intermittent app-suite hangs starving the queue). # default (observed: intermittent app-suite hangs starving the queue).
timeout-minutes: 15 # The long steps carry tighter individual bounds (5-minute test watchdog,
# 6-minute benchmark step, 10-minute floor gate that may re-run the
# benchmarks up to twice on a noisy runner); this is the backstop.
timeout-minutes: 25
strategy: strategy:
fail-fast: false # Don't cancel other matrix jobs when one fails fail-fast: false # Don't cancel other matrix jobs when one fails
@@ -102,9 +105,14 @@ jobs:
# Order-of-magnitude performance regression gate. Floors are deliberately # Order-of-magnitude performance regression gate. Floors are deliberately
# generous (see bitchatTests/Performance/perf-floors.json) so this # generous (see bitchatTests/Performance/perf-floors.json) so this
# catches algorithmic regressions, never runner variance. # catches algorithmic regressions, never runner variance. If a metric
# still lands below floor (a saturated runner can dip one), the script
# re-runs the benchmarks — appending to the same log and keeping each
# benchmark's best value per metric — so noise clears on retry while a
# real regression fails every attempt. Floors are never lowered by this.
- name: Performance floor gate - name: Performance floor gate
if: matrix.name == 'app' if: matrix.name == 'app'
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 # Informational only: surfaces per-file and total line coverage in the
@@ -145,3 +153,27 @@ jobs:
ARCHS=arm64 \ ARCHS=arm64 \
CODE_SIGNING_ALLOWED=NO \ CODE_SIGNING_ALLOWED=NO \
build build
# Advisory only: SwiftLint reports style violations without ever failing the
# build. Runs in a pinned container (no Xcode plugin, no pbxproj changes) so
# it can never break the documented xcodebuild path or block a merge.
lint:
name: SwiftLint (advisory)
runs-on: ubuntu-latest
timeout-minutes: 15
# This job runs a third-party container image, so give it the least
# privilege we can: a read-only token, and no credentials left in the
# checkout for the container to find.
permissions:
contents: read
container:
# Tag for readability, digest for immutability (tags can be repointed).
# Bump both together, deliberately — never a floating tag.
image: ghcr.io/realm/swiftlint:0.65.0@sha256:a482729f4b58741875af1566f23397f3f6db300372756fc31606d0a4527fab9e
continue-on-error: true
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Run SwiftLint
run: swiftlint lint --reporter github-actions-logging
+33
View File
@@ -0,0 +1,33 @@
# Build artifacts and generated sources; keeps local `swiftlint` runs clean
# (CI checkouts are fresh, so this only matters in a working tree).
excluded:
- .build
- .swiftpm
- .DerivedData
- DerivedData
- build
- localPackages/*/.build
disabled_rules:
- line_length
- type_name
- identifier_name
- statement_position
- implicit_optional_initialization
- force_try
- vertical_whitespace
- for_where
- control_statement
- void_function_in_ternary
- redundant_discardable_let # SwiftUI breaks without it
# To be enabled as we fix the issues
- trailing_whitespace
- cyclomatic_complexity
- function_body_length
- function_parameter_count
- type_body_length
- file_length
- large_tuple
- force_cast
- multiple_closures_with_trailing_closure
- nesting
+1 -1
View File
@@ -1,4 +1,4 @@
MARKETING_VERSION = 1.5.3 MARKETING_VERSION = 1.6.0
CURRENT_PROJECT_VERSION = 1 CURRENT_PROJECT_VERSION = 1
IPHONEOS_DEPLOYMENT_TARGET = 16.0 IPHONEOS_DEPLOYMENT_TARGET = 16.0
+2 -2
View File
@@ -13,9 +13,9 @@ let package = Package(
.executable( .executable(
name: "bitchat", name: "bitchat",
targets: ["bitchat"] targets: ["bitchat"]
), )
], ],
dependencies:[ dependencies: [
.package(path: "localPackages/Arti"), .package(path: "localPackages/Arti"),
.package(path: "localPackages/BitFoundation"), .package(path: "localPackages/BitFoundation"),
.package(path: "localPackages/BitLogger"), .package(path: "localPackages/BitLogger"),
+82 -250
View File
@@ -1,309 +1,141 @@
# BitChat Protocol Whitepaper # bitchat Protocol Whitepaper
**Version 1.1** **Version 2.0**
**Date: July 25, 2025** **Date: July 6, 2026**
--- ---
## Abstract ## Abstract
BitChat is a decentralized, peer-to-peer messaging application designed for secure, private, and censorship-resistant communication over ephemeral, ad-hoc networks. This whitepaper details the BitChat Protocol Stack, a layered architecture that combines a modern cryptographic foundation with a flexible application protocol. At its core, BitChat leverages the Noise Protocol Framework (specifically, the `XX` pattern) to establish mutually authenticated, end-to-end encrypted sessions between peers. This document provides a technical specification of the identity management, session lifecycle, message framing, and security considerations that underpin the BitChat network. bitchat is a decentralized, peer-to-peer messaging application for secure, private, censorship-resistant communication that works with or without the internet. Nearby devices form an ad-hoc Bluetooth Low Energy (BLE) mesh; distant peers are reached over the Nostr protocol when a connection exists. A layered store-and-forward stack — a persistent sender outbox, opportunistic couriers with a spray-and-wait copy budget, gossip-synced public history, and Nostr relay mailboxes — delivers messages to peers who are out of range at send time. This document describes the protocol and its delivery guarantees as implemented.
--- ---
## 1. Introduction ## 1. Design Goals
In an era of centralized communication platforms, BitChat offers a resilient alternative by operating without central servers. It is designed for scenarios where internet connectivity is unavailable or untrustworthy, such as protests, natural disasters, or remote areas. Communication occurs directly between devices over transports like Bluetooth Low Energy (BLE). * **Confidentiality:** all private communication is end-to-end encrypted; intermediate nodes and couriers carry only opaque ciphertext.
* **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.
* **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.
The design goals of the BitChat Protocol are: ## 2. Architecture Overview
* **Confidentiality:** All communication must be unreadable to third parties. Two transports implement a common `Transport` interface and are coordinated by a `MessageRouter`:
* **Authentication:** Users must be able to verify the identity of their correspondents.
* **Integrity:** Messages cannot be tampered with in transit.
* **Forward Secrecy:** The compromise of long-term identity keys must not compromise past session keys.
* **Deniability:** It should be difficult to cryptographically prove that a specific user sent a particular message.
* **Resilience:** The protocol must function reliably in lossy, low-bandwidth environments.
This paper specifies the technical details of the protocol designed to meet these goals. * **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.
--- The router prefers a live mesh link, falls back to Nostr, and engages the courier system when neither can deliver promptly.
## 2. Protocol Stack ## 3. Identity
The BitChat Protocol is a four-layer stack. This layered approach separates concerns, allowing for modularity and future extensibility. Each device holds two long-term key pairs in the Keychain:
```mermaid * a **Curve25519 static key** for Noise key agreement — its SHA-256 fingerprint is the peer's stable identity, and
graph TD * an **Ed25519 signing key** for packet signatures.
A[Application Layer] --> B[Session Layer];
B --> C[Encryption Layer];
C --> D[Transport Layer];
subgraph "BitChat Application" 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.
A
end
subgraph "Message Framing & State" ## 4. BLE Mesh Layer
B
end
subgraph "Noise Protocol Framework" ### 4.1 Packet Format
C
end
subgraph "BLE, Wi-Fi Direct, etc." 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.
D
end
style A fill:#cde4ff ### 4.2 Flood Control
style B fill:#b5d8ff
style C fill:#9ac2ff
style D fill:#7eadff
```
* **Application Layer:** Defines the structure of user-facing messages (`BitchatMessage`), acknowledgments (`DeliveryAck`), and other application-level data. Relaying is a deterministic controlled flood tuned by local connection degree:
* **Session Layer:** Manages the overall communication packet (`BitchatPacket`). This includes routing information (TTL), message typing, fragmentation, and serialization into a compact binary format.
* **Encryption Layer:** Establishes and manages secure channels using the Noise Protocol Framework. It is responsible for the cryptographic handshake, session management, and transport message encryption/decryption.
* **Transport Layer:** The underlying physical medium used for data transmission, such as Bluetooth Low Energy (BLE). This layer is abstracted away from the core protocol.
--- * **TTL:** packets originate with TTL 7. Relays clamp: dense graphs (≥ 6 links) cap broadcast TTL at 5; thin chains (≤ 2 links) relay at full incoming depth.
* **Deduplication:** an LRU seen-set (1000 entries, 5-minute expiry) keyed by sender, timestamp, type, and a payload digest drops duplicates. A scheduled relay is cancelled when a duplicate arrives first from another relay.
* **Jitter:** relays wait a random 10220 ms (wider when dense) so duplicate suppression wins often.
* **Fanout subsetting:** broadcast messages are re-sent to a deterministic, message-ID-seeded subset of links (~log₂ of degree) rather than all of them; announces, fragments, and sync packets use full fanout. The ingress link is always excluded (split horizon).
* **Directed traffic** (handshakes, private messages, courier envelopes) relays deterministically with TTL 1 and tight jitter, and is never subset.
## 3. Identity and Key Management ### 4.3 Routing
A peer's identity in BitChat is defined by two persistent cryptographic key pairs, which are generated on first launch and stored securely in the device's Keychain. Announcements carry up to 10 direct-neighbor IDs, giving each node a shallow topology map (60 s freshness). When a bidirectionally-confirmed path exists, packets are source-routed along it; otherwise — and whenever a route fails — delivery falls back to flooding.
1. **Noise Static Key Pair (`Curve25519`):** This is the long-term identity key used for the Noise Protocol handshake. The public part of this key is shared with peers to establish secure sessions. ### 4.4 Fragmentation
2. **Signing Key Pair (`Ed25519`):** This key is used to sign announcements and other protocol messages where non-repudiation is required, such as binding a public key to a nickname.
### 3.1. Fingerprint Packets exceeding the link MTU split into ~469-byte fragments (8-byte fragment ID, index/total header) that relay independently and reassemble at each receiving node (128 concurrent assemblies, 30 s timeout, 1 MiB cap).
A user's unique, verifiable fingerprint is the **SHA-256 hash** of their **Noise static public key**. This provides a user-friendly and secure way to verify an identity out-of-band (e.g., by reading it aloud or scanning a QR code). ### 4.5 Presence
`Fingerprint = SHA256(StaticPublicKey_Curve25519)` Signed announcements propagate multi-hop: every 4 s while isolated, backing off to ~1530 s (jittered) when connected. A verified announce retains a peer as *reachable* for 60 s after last contact. Connection scheduling is RSSI-gated with duty-cycled scanning to bound battery drain.
### 3.2. Identity Management ## 5. Encryption
The `SecureIdentityStateManager` class is responsible for managing all cryptographic identity material and social metadata (petnames, trust levels, etc.). It uses an in-memory cache for performance and persists this cache to the Keychain after encrypting it with a separate AES-GCM key. ### 5.1 Live Sessions: Noise XX
--- Connected peers establish sessions with the Noise `XX` pattern (Curve25519 / ChaCha20-Poly1305 / SHA-256), providing mutual authentication and forward secrecy. All private payloads — messages, delivery acks, read receipts — ride inside the session as typed ciphertext. Intermediate relays see only opaque `noiseEncrypted` packets.
## 4. The Social Trust Layer ### 5.2 Offline Seals: Noise X
Beyond cryptographic identity, BitChat incorporates a social trust layer, allowing users to manage their relationships with peers. This functionality is handled by the `SecureIdentityStateManager`. Courier envelopes are sealed to the recipient's *static* key with the one-way Noise `X` pattern; the sender's identity is authenticated inside the ciphertext. **This path has no forward secrecy** — compromise of the recipient's static key exposes sealed-but-undelivered mail. A prekey scheme is future work.
### 4.1. Peer Verification ### 5.3 Nostr Path
While the Noise handshake cryptographically authenticates a peer's key, it doesn't confirm the real-world identity of the person holding the device. To solve this, users can perform out-of-band (OOB) verification by comparing fingerprints. Once a user confirms that a peer's fingerprint matches the one they expect, they can mark that peer as "verified". This status is stored locally and displayed in the UI, providing a strong assurance of identity for future conversations. 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.
### 4.2. Favorites and Blocking ## 6. Store and Forward
To improve the user experience and provide control over interactions, the protocol supports: Four mechanisms cover the "recipient is not here right now" problem. All persisted state is wiped by panic mode.
* **Favorites:** Users can mark trusted or frequently contacted peers as "favorites". This is a local designation that can be used by the application to prioritize notifications or display peers more prominently.
* **Blocking:** Users can block peers. When a peer is blocked, the application will discard any incoming packets from that peer's fingerprint at the earliest possible stage, effectively silencing them without notifying the blocked peer.
--- ### 6.1 Sender Outbox
## 5. The Noise Protocol Layer Private messages without a prompt route are retained per peer (100 messages/peer, 24 h TTL) and re-sent on reconnect events until a delivery or read ack clears them, or a resend cap (8 attempts) drops them with visible failure. The outbox persists to disk sealed under a ChaChaPoly key held only in the Keychain, so queued mail survives an app kill without ever storing plaintext.
BitChat implements the Noise Protocol Framework to provide strong, authenticated end-to-end encryption. ### 6.2 Couriers
### 5.1. Protocol Name When no transport can deliver promptly, the message is sealed (§5.2) into a **courier envelope** and handed to up to 3 connected peers who may physically encounter the recipient:
The specific Noise protocol implemented is: * **Opaque addressing.** The only routing information is a 16-byte rotating recipient tag — an HMAC of the recipient's static key and the UTC day — computable solely by parties who already know that key. Couriers learn neither sender, recipient, nor content, and tags do not correlate across days.
* **Trust tiers.** Mutual favorites may deposit 5 envelopes each; any peer with a signature-verified announce may deposit 2, into a bounded pool (20 of 40 slots) that can never crowd out favorites' mail. Envelopes are capped at 16 KiB and 24 h; overflow evicts oldest verified-tier mail first.
* **Deposit retry.** Queued messages are re-deposited whenever a new eligible courier connects, until 3 distinct couriers carry the message or it expires.
* **Spray and wait.** Envelopes carry a copy budget (initially 4, capped at 8). A courier meeting another eligible courier hands over half its remaining budget, so mail diffuses through a moving crowd instead of riding one person. Budgets, spray history, and carried mail persist across app restarts (iOS file protection).
* **Handover.** On a verified *direct* announce from the recipient, matching envelopes are delivered over the live link and removed. On a verified *relayed* announce, a copy floods toward the recipient as a directed packet while the carried original stays put, throttled to one attempt per envelope per 10 minutes.
* Receivers dedup by message ID, so redundant copies and the retained outbox original are harmless. Couriered mail from blocked senders is dropped at decryption time.
**`Noise_XX_25519_ChaChaPoly_SHA256`** ### 6.3 Public History (Gossip Sync)
* **`XX` Pattern:** This handshake pattern provides mutual authentication and forward secrecy. It does not require either party to know the other's static public key before the handshake begins. The keys are exchanged and authenticated during the three-part handshake. This is ideal for a decentralized P2P environment. Public broadcast messages are cached (1000 packets) and reconciled between peers every ~15 s using compact GCS filters: each side advertises what it holds, the other returns what is missing. Messages stay sync-able for **6 hours** and the cache persists to disk, so a device that walks between two partitions — or relaunches later — serves the room's recent history to whoever missed it. Fragments and file transfers keep a short 15-minute window.
* **`25519`:** The Diffie-Hellman function used is Curve25519.
* **`ChaChaPoly`:** The AEAD (Authenticated Encryption with Associated Data) cipher is ChaCha20-Poly1305.
* **`SHA256`:** The hash function used for all cryptographic hashing operations is SHA-256.
### 5.2. The `XX` Handshake ### 6.4 Nostr Mailboxes
The `XX` handshake consists of three messages exchanged between an Initiator and a Responder to establish a shared secret and derive transport encryption keys. 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.
```mermaid ### 6.5 Delivery Metrics
sequenceDiagram
participant I as Initiator
participant R as Responder
Note over I, R: Pre-computation: h = SHA256(protocol_name) Bare local counters (deposits, handovers, sprays, opens, outbox flushes and drops — no identities, message IDs, or timestamps) let delivery behavior be measured on-device. They never leave the device and are cleared by the panic wipe.
I->>R: -> e ## 7. Application Layer
Note right of I: I generates ephemeral key `e_i`.<br/>h = SHA256(h + e_i.pub)
R->>I: <- e, ee, s, es * **Public chat** — signed broadcast messages within the mesh, backed by the gossip-synced history above.
Note left of R: R generates ephemeral key `e_r`.<br/>h = SHA256(h + e_r.pub)<br/>MixKey(DH(e_i, e_r))<br/>R sends static key `s_r`, encrypted.<br/>h = SHA256(h + ciphertext)<br/>MixKey(DH(e_i, s_r)) * **Private chat** — end-to-end encrypted messages with delivery and read receipts, over mesh, courier, or Nostr.
* **Location channels** — geohash-scoped public rooms carried over Nostr relays for regional chat beyond radio range.
I->>R: -> s, se * **Favorites** — the mutual-trust relationship that unlocks Nostr delivery and the larger courier quota.
Note right of I: I decrypts and verifies `s_r`.<br/>I sends static key `s_i`, encrypted.<br/>h = SHA256(h + ciphertext)<br/>MixKey(DH(s_i, e_r)) * **Media** — files and images fragment over the mesh (1 MiB cap, explicit accept before anything touches disk); couriers carry text only.
* **Panic wipe** — clears identity keys, favorites, carried courier mail, the sealed outbox, archived public history, and metrics.
Note over I, R: Handshake complete. Transport keys derived.
```
**Handshake Flow:**
1. **Initiator -> Responder:** The initiator generates a new ephemeral key pair (`e_i`) and sends the public part to the responder.
2. **Responder -> Initiator:** The responder receives the initiator's ephemeral public key. It then generates its own ephemeral key pair (`e_r`), performs a DH exchange with the initiator's ephemeral key (`ee`), sends its own static public key (`s_r`) encrypted with the resulting symmetric key, and performs another DH exchange between the initiator's ephemeral key and its own static key (`es`).
3. **Initiator -> Responder:** The initiator receives the responder's message, decrypts the responder's static key, and authenticates it. The initiator then sends its own static key (`s_i`) encrypted and performs a final DH exchange between its static key and the responder's ephemeral key (`se`).
Upon completion, both parties share a set of symmetric keys for bidirectional transport message encryption. The final handshake hash is used for channel binding.
### 5.3. Session Management
The `NoiseSessionManager` class manages all active Noise sessions. It handles:
* Creating sessions for new peers.
* Coordinating the handshake process to prevent race conditions.
* Storing the resulting transport ciphers (`sendCipher`, `receiveCipher`).
* Periodically checking if sessions need to be re-keyed for enhanced security.
---
## 6. The BitChat Session and Application Protocol
Once a Noise session is established, peers exchange `BitchatPacket` structures, which are encrypted as the payload of Noise transport messages.
### 6.1. Binary Packet Format (`BitchatPacket`)
To minimize bandwidth, `BitchatPacket`s are serialized into a compact binary format. The structure is designed to be fixed-size where possible to resist traffic analysis.
| Field | Size (bytes) | Description |
|-----------------|--------------|---------------------------------------------------------------------------------------------------------|
| **Header** | **13** | **Fixed-size header** |
| Version | 1 | Protocol version (currently `1`). |
| Type | 1 | Message type (e.g., `message`, `deliveryAck`, `noiseHandshakeInit`). See `MessageType` enum. |
| TTL | 1 | Time-To-Live for mesh network routing. Decremented at each hop. |
| Timestamp | 8 | `UInt64` millisecond timestamp of packet creation. |
| Flags | 1 | Bitmask for optional fields (`hasRecipient`, `hasSignature`, `isCompressed`). |
| Payload Length | 2 | `UInt16` length of the payload field. |
| **Variable** | **...** | **Variable-size fields** |
| Sender ID | 8 | 8-byte truncated peer ID of the sender. |
| Recipient ID | 8 (optional) | 8-byte truncated peer ID of the recipient. Present if `hasRecipient` flag is set. Broadcast if `0xFF..FF`. |
| Payload | Variable | The actual content of the packet, as defined by the `Type` field. |
| Signature | 64 (optional)| `Ed25519` signature of the packet. Present if `hasSignature` flag is set. |
**Padding:** All packets are padded to the next standard block size (256, 512, 1024, or 2048 bytes) using a PKCS#7-style scheme to obscure the true message length from network observers.
```mermaid
---
config:
theme: dark
---
---
title: "BitchatPacket"
---
packet
+8: "Version"
+8: "Type"
+8: "TTL"
+64: "Timestamp"
+8: "Flags"
+16: "Payload Length"
+64: "Sender ID"
+64: "Recipient ID (optional)"
+48: "Payload (variable)"
+64: "Signature (optional)"
```
_A representation of the sizes of the fields in `BitchatPacket`_
### 6.2. Application Message Format (`BitchatMessage`)
For packets of type `message`, the payload is a binary-serialized `BitchatMessage` containing the chat content.
| Field | Size (bytes) | Description |
|---------------------|--------------|--------------------------------------------------------------------------|
| Flags | 1 | Bitmask for optional fields (`isRelay`, `isPrivate`, `hasOriginalSender`). |
| Timestamp | 8 | `UInt64` millisecond timestamp of message creation. |
| ID | 1 + len | `UUID` string for the message. |
| Sender | 1 + len | Nickname of the sender. |
| Content | 2 + len | The UTF-8 encoded message content. |
| Original Sender | 1 + len (opt)| Nickname of the original sender if the message is a relay. |
| Recipient Nickname | 1 + len (opt)| Nickname of the recipient for private messages. |
```mermaid
---
config:
theme: dark
---
---
title: "BitchatMessage"
---
packet
+8: "Flags"
+64: "Timestamp"
+24: "ID (variable)"
+32: "Sender (variable)"
+32: "Content (variable)"
+32: "Original Sender (variable) (optional)"
+32: "Recipient Nickname (variable) (optional)"
```
_A representation of the sizes of the fields in `BitchatMessage`_
---
## 7. Message Routing and Propagation
BitChat operates as a decentralized mesh network, meaning there are no central servers to route messages. Packets are propagated through the network from peer to peer. The protocol supports several modes of message delivery.
### 7.1. Direct Connection
This is the simplest case. If Peer A and Peer B are directly connected, they can exchange packets after establishing a mutually authenticated Noise session. All packets are encrypted using the transport ciphers derived from the handshake.
### 7.2. Efficient Gossip with Bloom Filters
To send messages to peers that are not directly connected, BitChat employs a "flooding" or "gossip" protocol. When a peer receives a packet that is not destined for it, it acts as a relay. To prevent infinite routing loops and minimize memory usage, the protocol uses an `OptimizedBloomFilter` to track recently seen packet IDs.
The logic is as follows:
1. A peer receives a packet.
2. It checks the Bloom filter to see if the packet's ID has likely been seen before. If so, the packet is discarded. Bloom filters can have false positives (though they are rare), but they guarantee no false negatives. This means that while some packets may be incorrectly discarded due to false positives, the gossip protocol's redundancy ensures these packets will eventually be received through subsequent exchanges with other peers.
3. If the packet is new, its ID is added to the Bloom filter.
4. The peer decrements the packet's Time-To-Live (TTL) field.
5. If the TTL is greater than zero, the peer re-broadcasts the packet to all of its connected peers, *except* for the peer from which it received the packet.
This mechanism allows packets to "flood" through the network efficiently, maximizing the chance of reaching their destination while using minimal resources to prevent loops.
### 7.3. Time-To-Live (TTL)
Every `BitchatPacket` contains an 8-bit TTL field. This value is set by the originating peer and is decremented by one at each relay hop. If a peer receives a packet and decrements its TTL to 0, it will process the packet (if it is the recipient) but will not relay it further. This is a crucial mechanism to prevent packets from circulating endlessly in the mesh.
### 7.4. Private vs. Broadcast Messages
The routing logic respects the confidentiality of private messages:
* **Private Messages:** A packet with a specific `recipientID` is a private message. Relay nodes forward the entire, encrypted Noise message without being able to access the inner `BitchatPacket` or its payload. Only the final recipient, who shares the correct Noise session keys with the sender, can decrypt the packet.
* **Broadcast Messages:** A packet with the special broadcast `recipientID` (`0xFFFFFFFFFFFFFFFF`) is intended for all peers. Any peer that receives and decrypts a broadcast message will process its content. It will still be relayed according to the flooding algorithm to ensure it reaches the entire network.
### 7.5. Message Reliability and Lifecycle
To function in unreliable, lossy networks, the protocol includes features to track the lifecycle of a message and ensure its delivery.
* **Delivery Acknowledgments (`DeliveryAck`):** When a private message reaches its final destination, the recipient's device sends a `DeliveryAck` packet back to the original sender. This acknowledgment contains the ID of the original message.
* **Read Receipts (`ReadReceipt`):** After a message is displayed on the recipient's screen, the application can send a `ReadReceipt`, also containing the original message ID, to inform the sender that the message has been seen.
* **Message Retry Service:** Senders maintain a `MessageRetryService` which tracks outgoing messages. If a `DeliveryAck` is not received for a message within a certain time window, the service will automatically re-send the message, creating a more resilient user experience.
### 7.6. Fragmentation
Transport layers like BLE have a Maximum Transmission Unit (MTU) that limits the size of a single packet. To handle messages larger than this limit, BitChat implements a fragmentation protocol.
* **`fragmentStart`:** A packet with this type marks the beginning of a fragmented message. It contains metadata about the total size and number of fragments.
* **`fragmentContinue`:** These packets carry the intermediate chunks of the message data.
* **`fragmentEnd`:** This packet carries the final chunk of the message and signals the receiver to begin reassembly.
Receiving peers collect all fragments and reassemble them in the correct order before passing the complete message up to the application layer.
---
## 8. Security Considerations ## 8. Security Considerations
* **Replay Attacks:** The Noise transport messages include a nonce that is incremented for each message. The `NoiseCipherState` implements a sliding window replay protection mechanism to detect and discard replayed or out-of-order messages. * **Relay nodes** cannot read private traffic; they forward padded, opaque ciphertext.
* **Denial of Service:** The `NoiseRateLimiter` is implemented to prevent resource exhaustion from rapid, repeated handshake attempts from a single peer. * **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.
* **Key-Compromise Impersonation:** The `XX` pattern authenticates both parties, preventing an attacker from impersonating one party to the other. * **Flooding abuse** is bounded by TTL clamps, deduplication, per-depositor quotas, connect-rate limits, and announce-rate limiting.
* **Identity Binding:** While the Noise handshake authenticates the cryptographic keys, binding those keys to a human-readable nickname is handled at the application layer. Users must verify fingerprints out-of-band to prevent man-in-the-middle attacks. * **Replay** of public broadcasts is bounded by the 6-hour acceptance window plus deduplication; private payloads are protected by Noise nonces.
* **Traffic Analysis:** The use of fixed-size padding for all packets helps to obscure the exact nature and content of the communication, making it harder for a network-level adversary to infer information based on message size. * **Metadata.** BLE proximity is inherently observable; ephemeral IDs and daily-rotating courier tags limit long-term correlation. Nostr traffic can ride Tor.
* **No forward secrecy for sealed mail** (§5.2) is the main cryptographic trade-off of the offline path.
## 9. Future Work
* Prekey-based forward secrecy for courier envelopes.
* Couriered media beyond the 16 KiB text cap.
* Probabilistic relay and edge-of-network TTL boosting for very dense and very sparse graphs.
* Multi-hop courier routing informed by encounter history.
--- ---
## 9. Conclusion *This document describes the protocol as implemented in the current release. The implementation is free and unencumbered software released into the public domain.*
The BitChat Protocol provides a robust and secure foundation for decentralized, peer-to-peer communication. By layering a flexible application protocol on top of the well-regarded Noise Protocol Framework, it achieves strong confidentiality, authentication, and forward secrecy. The use of a compact binary format and thoughtful security considerations like rate limiting and traffic analysis resistance make it suitable for use in challenging network environments.
-8
View File
@@ -528,7 +528,6 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = "$(MARKETING_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension";
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
@@ -561,7 +560,6 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.5.3;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -620,7 +618,6 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.5.3;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -655,7 +652,6 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = 1.5.3;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -716,7 +712,6 @@
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)"; IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = "$(MARKETING_VERSION)";
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -749,7 +744,6 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = 1.5.3;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -816,7 +810,6 @@
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)"; IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = "$(MARKETING_VERSION)";
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
@@ -846,7 +839,6 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = "$(MARKETING_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension";
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+25
View File
@@ -18,6 +18,9 @@ final class AppChromeModel: ObservableObject {
private let chatViewModel: ChatViewModel private let chatViewModel: ChatViewModel
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
/// Bulletin-board coordinator, created on first use of the board sheet.
private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService)
init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) { init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) {
self.chatViewModel = chatViewModel self.chatViewModel = chatViewModel
self.nickname = chatViewModel.nickname self.nickname = chatViewModel.nickname
@@ -59,6 +62,28 @@ final class AppChromeModel: ObservableObject {
isAppInfoPresented = true isAppInfoPresented = true
} }
/// Builds the mesh topology map model from the transport's gossiped
/// 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.
func meshTopologyDisplayModel() -> MeshTopologyDisplayModel {
let mesh = chatViewModel.meshService
guard let snapshot = mesh.currentMeshTopology() else { return .empty }
let nicknames = mesh.getPeerNicknames()
let nodes = snapshot.nodes.map { peerID -> MeshTopologyDisplayModel.Node in
let isSelf = peerID == snapshot.localPeerID
let label: String
if isSelf {
label = chatViewModel.nickname
} else {
label = nicknames[peerID] ?? "\(peerID.id.prefix(8))"
}
return MeshTopologyDisplayModel.Node(id: peerID.id, label: label, isSelf: isSelf)
}
let edges = snapshot.edges.map { ($0.a.id, $0.b.id) }
return MeshTopologyDisplayModel(nodes: nodes, edges: edges)
}
func triggerScreenshotPrivacyWarning() { func triggerScreenshotPrivacyWarning() {
showScreenshotPrivacyWarning = true showScreenshotPrivacyWarning = true
} }
+19
View File
@@ -28,6 +28,7 @@ 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
private let idBridge: NostrIdentityBridge private let idBridge: NostrIdentityBridge
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
@@ -91,6 +92,24 @@ final class AppRuntime: ObservableObject {
chatViewModel: self.chatViewModel, chatViewModel: self.chatViewModel,
privateInboxModel: self.privateInboxModel privateInboxModel: self.privateInboxModel
) )
let chatViewModel = self.chatViewModel
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)
}
}
)
)
GeoRelayDirectory.shared.prefetchIfNeeded() GeoRelayDirectory.shared.prefetchIfNeeded()
bindRuntimeObservers() bindRuntimeObservers()
+23 -1
View File
@@ -49,6 +49,14 @@ final class ConversationUIModel: ObservableObject {
chatViewModel.sendMessage(message) chatViewModel.sendMessage(message)
} }
/// Resends a failed private message through the normal send path,
/// removing the failed original so the re-submission replaces it
/// instead of stacking a duplicate under the red bubble.
func resendFailedPrivateMessage(_ message: BitchatMessage) {
chatViewModel.removePrivateMessage(withID: message.id)
chatViewModel.sendMessage(message.content)
}
func clearCurrentConversation() { func clearCurrentConversation() {
chatViewModel.sendMessage("/clear") chatViewModel.sendMessage("/clear")
} }
@@ -67,11 +75,23 @@ final class ConversationUIModel: ObservableObject {
if let peerID, peerID.isGeoChat, if let peerID, peerID.isGeoChat,
let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) { let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) {
chatViewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: displayName) chatViewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: displayName)
} else if let peerID, !peerID.isGeoDM, !peerID.isGeoChat {
// Mesh: block the peer's stable Noise identity resolved from the
// tapped peerID rather than re-resolving a display-name string.
chatViewModel.blockMeshPeer(peerID: peerID, displayName: displayName)
} else { } else {
chatViewModel.sendMessage("/block \(displayName)") chatViewModel.sendMessage("/block \(displayName)")
} }
} }
/// Mesh counterpart of `block(peerID:displayName:)`. Resolves the unblock by
/// the tapped peer's stable identity so the exact row is unblocked this
/// also works for offline peers, which the `/unblock <displayName>` command
/// cannot resolve.
func unblock(peerID: PeerID, displayName: String) {
chatViewModel.unblockMeshPeer(peerID: peerID, displayName: displayName)
}
func updateAutocomplete(for text: String, cursorPosition: Int) { func updateAutocomplete(for text: String, cursorPosition: Int) {
chatViewModel.updateAutocomplete(for: text, cursorPosition: cursorPosition) chatViewModel.updateAutocomplete(for: text, cursorPosition: cursorPosition)
} }
@@ -173,7 +193,9 @@ final class ConversationUIModel: ObservableObject {
private func refreshComputedState() { private func refreshComputedState() {
if let selectedPeerID = privateConversationModel.selectedPeerID { if let selectedPeerID = privateConversationModel.selectedPeerID {
canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat) // Media transfer is not wired for groups in v1; keep it off so the
// composer can't strand a media placeholder that never sends.
canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat || selectedPeerID.isGroup)
return return
} }
+15 -1
View File
@@ -12,20 +12,26 @@ final class LocationChannelsModel: ObservableObject {
@Published private(set) var bookmarkNames: [String: String] @Published private(set) var bookmarkNames: [String: String]
@Published private(set) var locationNames: [GeohashChannelLevel: String] @Published private(set) var locationNames: [GeohashChannelLevel: String]
@Published private(set) var userTorEnabled: Bool @Published private(set) var userTorEnabled: Bool
@Published private(set) var gatewayEnabled: Bool
private let manager: LocationChannelManager private let manager: LocationChannelManager
private let network: NetworkActivationService private let network: NetworkActivationService
private let gateway: GatewayService
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
init( init(
manager: LocationChannelManager? = nil, manager: LocationChannelManager? = nil,
network: NetworkActivationService? = nil network: NetworkActivationService? = nil,
gateway: GatewayService? = nil
) { ) {
let manager = manager ?? .shared let manager = manager ?? .shared
let network = network ?? .shared let network = network ?? .shared
let gateway = gateway ?? .shared
self.manager = manager self.manager = manager
self.network = network self.network = network
self.gateway = gateway
self.gatewayEnabled = gateway.isEnabled
self.permissionState = manager.permissionState self.permissionState = manager.permissionState
self.availableChannels = manager.availableChannels self.availableChannels = manager.availableChannels
self.selectedChannel = manager.selectedChannel self.selectedChannel = manager.selectedChannel
@@ -96,6 +102,10 @@ 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,
@@ -160,6 +170,10 @@ final class LocationChannelsModel: ObservableObject {
network.$userTorEnabled network.$userTorEnabled
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.assign(to: &$userTorEnabled) .assign(to: &$userTorEnabled)
gateway.$isEnabled
.receive(on: DispatchQueue.main)
.assign(to: &$gatewayEnabled)
} }
private func level(forLength length: Int) -> GeohashChannelLevel { private func level(forLength length: Int) -> GeohashChannelLevel {
+47 -8
View File
@@ -14,6 +14,9 @@ struct MeshPeerRow: Identifiable, Equatable {
let isMutualFavorite: Bool let isMutualFavorite: Bool
let encryptionStatus: EncryptionStatus let encryptionStatus: EncryptionStatus
let showsVerifiedBadgeWhenOffline: Bool let showsVerifiedBadgeWhenOffline: Bool
/// Vouched-for by someone I verified, without an explicit verification of
/// mine rendered as the unfilled seal (verified gets the filled one).
let showsVouchedBadge: Bool
var id: String { peerID.id } var id: String { peerID.id }
} }
@@ -26,11 +29,22 @@ struct GeohashPersonRow: Identifiable, Equatable {
let isBlocked: Bool let isBlocked: Bool
} }
struct GroupChatRow: Identifiable, Equatable {
let peerID: PeerID
let name: String
let memberCount: Int
let isCreator: Bool
let hasUnread: Bool
var id: String { peerID.id }
}
@MainActor @MainActor
final class PeerListModel: ObservableObject { final class PeerListModel: ObservableObject {
@Published private(set) var allPeers: [BitchatPeer] = [] @Published private(set) var allPeers: [BitchatPeer] = []
@Published private(set) var meshRows: [MeshPeerRow] = [] @Published private(set) var meshRows: [MeshPeerRow] = []
@Published private(set) var geohashPeople: [GeohashPersonRow] = [] @Published private(set) var geohashPeople: [GeohashPersonRow] = []
@Published private(set) var groupRows: [GroupChatRow] = []
@Published private(set) var reachableMeshPeerCount = 0 @Published private(set) var reachableMeshPeerCount = 0
@Published private(set) var connectedMeshPeerCount = 0 @Published private(set) var connectedMeshPeerCount = 0
@Published private(set) var visibleGeohashPeerCount = 0 @Published private(set) var visibleGeohashPeerCount = 0
@@ -129,6 +143,13 @@ final class PeerListModel: ObservableObject {
} }
.store(in: &cancellables) .store(in: &cancellables)
chatViewModel.groupStore.$groups
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
peerIdentityStore.$encryptionStatuses peerIdentityStore.$encryptionStatuses
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] _ in .sink { [weak self] _ in
@@ -183,13 +204,12 @@ final class PeerListModel: ObservableObject {
let myPeerID = chatViewModel.meshService.myPeerID let myPeerID = chatViewModel.meshService.myPeerID
let meshRows = allPeers.map { peer in let meshRows = allPeers.map { peer in
let isMe = peer.peerID == myPeerID let isMe = peer.peerID == myPeerID
let verifiedBadge: Bool let fingerprint = isMe ? nil : chatViewModel.getFingerprint(for: peer.peerID)
if !isMe && !peer.isConnected, let isVerifiedFingerprint = fingerprint.map { peerIdentityStore.isVerified($0) } ?? false
let fingerprint = chatViewModel.getFingerprint(for: peer.peerID) { let verifiedBadge = !peer.isConnected && isVerifiedFingerprint
verifiedBadge = peerIdentityStore.isVerified(fingerprint) // Vouched is subordinate to verified: never show both seals.
} else { let vouchedBadge = !isVerifiedFingerprint
verifiedBadge = false && (fingerprint.map { chatViewModel.isVouchedFingerprint($0) } ?? false)
}
return MeshPeerRow( return MeshPeerRow(
peerID: peer.peerID, peerID: peer.peerID,
@@ -202,7 +222,8 @@ final class PeerListModel: ObservableObject {
isReachable: peer.isReachable, isReachable: peer.isReachable,
isMutualFavorite: peer.isMutualFavorite, isMutualFavorite: peer.isMutualFavorite,
encryptionStatus: chatViewModel.getEncryptionStatus(for: peer.peerID), encryptionStatus: chatViewModel.getEncryptionStatus(for: peer.peerID),
showsVerifiedBadgeWhenOffline: verifiedBadge showsVerifiedBadgeWhenOffline: verifiedBadge,
showsVouchedBadge: vouchedBadge
) )
} }
@@ -217,22 +238,40 @@ final class PeerListModel: ObservableObject {
} }
let geohashPeople = buildGeohashPeople() let geohashPeople = buildGeohashPeople()
let groupRows = buildGroupRows()
self.meshRows = meshRows self.meshRows = meshRows
reachableMeshPeerCount = meshCounts.reachable reachableMeshPeerCount = meshCounts.reachable
connectedMeshPeerCount = meshCounts.connected connectedMeshPeerCount = meshCounts.connected
self.geohashPeople = geohashPeople self.geohashPeople = geohashPeople
visibleGeohashPeerCount = geohashPeople.count visibleGeohashPeerCount = geohashPeople.count
self.groupRows = groupRows
renderID = ( renderID = (
meshRows.map { meshRows.map {
"\($0.id)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)" "\($0.id)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
} + } +
geohashPeople.map { geohashPeople.map {
"geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)" "geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)"
} +
groupRows.map {
"group:\($0.id)-\($0.name)-\($0.memberCount)-\($0.hasUnread)"
} }
).joined(separator: "|") ).joined(separator: "|")
} }
private func buildGroupRows() -> [GroupChatRow] {
let myFingerprint = chatViewModel.meshService.noiseIdentityFingerprint()
return chatViewModel.groupStore.groups.map { group in
GroupChatRow(
peerID: group.peerID,
name: group.name,
memberCount: group.members.count,
isCreator: group.creatorFingerprint == myFingerprint,
hasUnread: chatViewModel.hasUnreadMessages(for: group.peerID)
)
}
}
private func buildGeohashPeople() -> [GeohashPersonRow] { private func buildGeohashPeople() -> [GeohashPersonRow] {
let myHex = currentGeohashIdentityHex() let myHex = currentGeohashIdentityHex()
let teleportedSet = Set(locationPresenceStore.teleportedGeo.map { $0.lowercased() }) let teleportedSet = Set(locationPresenceStore.teleportedGeo.map { $0.lowercased() })
+41 -2
View File
@@ -108,7 +108,13 @@ struct PrivateConversationHeaderState: Equatable {
let encryptionStatus: EncryptionStatus? let encryptionStatus: EncryptionStatus?
var supportsFavoriteToggle: Bool { var supportsFavoriteToggle: Bool {
!conversationPeerID.isGeoDM !conversationPeerID.isGeoDM && !conversationPeerID.isGroup
}
/// Group chats have no single peer identity behind the header: no
/// fingerprint screen, no per-peer encryption badge.
var isGroupConversation: Bool {
conversationPeerID.isGroup
} }
} }
@@ -206,6 +212,13 @@ final class PrivateConversationModel: ObservableObject {
} }
.store(in: &cancellables) .store(in: &cancellables)
chatViewModel.groupStore.$groups
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshSelectedConversation()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated")) NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] _ in .sink { [weak self] _ in
@@ -229,10 +242,36 @@ final class PrivateConversationModel: ObservableObject {
} }
private func makeHeaderState(for conversationPeerID: PeerID) -> PrivateConversationHeaderState { private func makeHeaderState(for conversationPeerID: PeerID) -> PrivateConversationHeaderState {
// Group chats: the "peer" is the whole crew. Name + member count in
// the header; availability reads as mesh since group traffic floods
// the local mesh, and the per-peer encryption badge does not apply.
if conversationPeerID.isGroup {
let displayName: String
if let group = chatViewModel.groupStore.group(for: conversationPeerID) {
displayName = "#\(group.name) (\(group.members.count))"
} else {
displayName = String(localized: "common.unknown", comment: "Fallback label for unknown peer")
}
return PrivateConversationHeaderState(
conversationPeerID: conversationPeerID,
headerPeerID: conversationPeerID,
displayName: displayName,
availability: .meshReachable,
isFavorite: false,
encryptionStatus: nil
)
}
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)
let availability = resolveAvailability(for: headerPeerID, peer: peer) // Geo DMs are always routed over Nostr (NIP-17); their nostr_ keys
// never resolve to a reachable mesh peer, so resolveAvailability would
// report .offline. Report .nostrAvailable so the header shows the
// globe instead of a misleading "offline" tag.
let availability = conversationPeerID.isGeoDM
? .nostrAvailable
: resolveAvailability(for: headerPeerID, peer: peer)
let encryptionStatus: EncryptionStatus? = conversationPeerID.isGeoDM let encryptionStatus: EncryptionStatus? = conversationPeerID.isGeoDM
? nil ? nil
: chatViewModel.getEncryptionStatus(for: headerPeerID) : chatViewModel.getEncryptionStatus(for: headerPeerID)
+40 -1
View File
@@ -9,6 +9,14 @@ struct FingerprintPresentationState: Equatable {
let theirFingerprint: String? let theirFingerprint: String?
let myFingerprint: String let myFingerprint: String
let isVerified: Bool let isVerified: Bool
/// Number of currently-valid vouches from peers the user verified
/// (0 when the peer is explicitly verified the stronger badge wins).
let voucherCount: Int
/// Display names of the (verified) vouchers, where known.
let voucherNames: [String]
/// Vouched for by 1 peer the user verified (and not explicitly verified).
var isVouched: Bool { voucherCount > 0 }
var canToggleVerification: Bool { var canToggleVerification: Bool {
encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified
@@ -82,6 +90,24 @@ final class VerificationModel: ObservableObject {
let encryptionStatus = chatViewModel.getEncryptionStatus(for: statusPeerID) let encryptionStatus = chatViewModel.getEncryptionStatus(for: statusPeerID)
let theirFingerprint = chatViewModel.getFingerprint(for: statusPeerID) let theirFingerprint = chatViewModel.getFingerprint(for: statusPeerID)
let peerNickname = resolveDisplayName(for: peerID, statusPeerID: statusPeerID) let peerNickname = resolveDisplayName(for: peerID, statusPeerID: statusPeerID)
let isVerified = theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false
// Vouch state is recomputed on read: only vouchers still in the
// verified set count, so removing a verification silently retires the
// vouches that peer gave.
let vouchers: [VouchRecord]
if !isVerified, let theirFingerprint {
vouchers = chatViewModel.identityManager.validVouchers(for: theirFingerprint)
} else {
vouchers = []
}
let voucherNames = vouchers.compactMap { record -> String? in
guard let social = chatViewModel.identityManager.getSocialIdentity(for: record.voucherFingerprint) else {
return nil
}
if let petname = social.localPetname, !petname.isEmpty { return petname }
return social.claimedNickname.isEmpty ? nil : social.claimedNickname
}
return FingerprintPresentationState( return FingerprintPresentationState(
statusPeerID: statusPeerID, statusPeerID: statusPeerID,
@@ -89,7 +115,9 @@ final class VerificationModel: ObservableObject {
encryptionStatus: encryptionStatus, encryptionStatus: encryptionStatus,
theirFingerprint: theirFingerprint, theirFingerprint: theirFingerprint,
myFingerprint: chatViewModel.getMyFingerprint(), myFingerprint: chatViewModel.getMyFingerprint(),
isVerified: theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false isVerified: isVerified,
voucherCount: vouchers.count,
voucherNames: voucherNames
) )
} }
@@ -122,6 +150,17 @@ final class VerificationModel: ObservableObject {
self?.objectWillChange.send() self?.objectWillChange.send()
} }
.store(in: &cancellables) .store(in: &cancellables)
// Vouch state changes (ChatVouchCoordinator.notifyPeerTrustChanged)
// are signalled via this notification rather than a published
// property, so an open fingerprint sheet refreshes its vouched badge
// live when a vouch batch is accepted.
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
} }
private func resolveDisplayName(for peerID: PeerID, statusPeerID: PeerID) -> String { private func resolveDisplayName(for peerID: PeerID, statusPeerID: PeerID) -> String {
+2 -1
View File
@@ -40,6 +40,7 @@ struct BitchatApp: App {
.environmentObject(runtime.locationChannelsModel) .environmentObject(runtime.locationChannelsModel)
.environmentObject(runtime.peerListModel) .environmentObject(runtime.peerListModel)
.environmentObject(runtime.appChromeModel) .environmentObject(runtime.appChromeModel)
.environmentObject(runtime.boardAlertsModel)
.onAppear { .onAppear {
appDelegate.runtime = runtime appDelegate.runtime = runtime
runtime.start() runtime.start()
@@ -71,7 +72,7 @@ struct BitchatApp: App {
final class AppDelegate: NSObject, UIApplicationDelegate { 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 true
} }
+44 -5
View File
@@ -126,11 +126,35 @@ struct SocialIdentity: Codable {
var notes: String? var notes: String?
} }
/// Trust ladder: unknown casual vouched trusted verified.
///
/// Persistence compatibility: `TrustLevel` is stored by its *String* raw
/// value ("unknown", "casual", ), not by ordinal position, so inserting
/// `vouched` mid-ladder cannot corrupt previously persisted values every
/// pre-existing case keeps the exact raw value it was written with. The
/// `vouched` tier is additionally never persisted into `SocialIdentity`
/// (it's recomputed on read from stored vouches), so downgraded builds never
/// encounter the unfamiliar raw value.
enum TrustLevel: String, Codable { enum TrustLevel: String, Codable {
case unknown = "unknown" case unknown
case casual = "casual" case casual
case trusted = "trusted" /// Transitively trusted: vouched for by at least one peer *I* verified.
case verified = "verified" /// Derived at read time never written to persistent storage.
case vouched
case trusted
case verified
}
// MARK: - Vouching (transitive verification)
/// One accepted vouch: a peer I verified (the voucher) attested that they
/// verified the vouchee. Validity is recomputed on read a record only
/// counts while its voucher remains in `verifiedFingerprints` and its
/// timestamp is within `VouchAttestation.maxAge` so unverifying a voucher
/// silently invalidates the vouches they gave without a cascade delete.
struct VouchRecord: Codable, Equatable {
let voucherFingerprint: String
let timestamp: Date
} }
// MARK: - Identity Cache // MARK: - Identity Cache
@@ -154,7 +178,22 @@ struct IdentityCache: Codable {
// Blocked Nostr pubkeys (lowercased hex) for geohash chats // Blocked Nostr pubkeys (lowercased hex) for geohash chats
var blockedNostrPubkeys: Set<String> = [] var blockedNostrPubkeys: Set<String> = []
// Vouching (transitive verification). All three fields are Optional so
// caches persisted before this feature decode cleanly the synthesized
// decoder uses decodeIfPresent for optionals, and a missing key must not
// trip the "unreadable cache" recovery path that discards everything.
// Vouchee fingerprint -> accepted vouches (capped per vouchee)
var vouchesByVouchee: [String: [VouchRecord]]? = nil
// Peer fingerprint -> when we last sent them a vouch batch (rate limit)
var vouchBatchSentAt: [String: Date]? = nil
// Fingerprint -> when we verified it (orders outgoing vouch batches;
// entries verified before this field exists sort as oldest)
var verifiedAt: [String: Date]? = nil
// Schema version for future migrations // Schema version for future migrations
var version: Int = 1 var version: Int = 1
} }
@@ -133,6 +133,17 @@ protocol SecureIdentityStateManagerProtocol {
func setVerified(fingerprint: String, verified: Bool) func setVerified(fingerprint: String, verified: Bool)
func isVerified(fingerprint: String) -> Bool func isVerified(fingerprint: String) -> Bool
func getVerifiedFingerprints() -> Set<String> func getVerifiedFingerprints() -> Set<String>
// MARK: Vouching (transitive verification)
@discardableResult
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool
func validVouchers(for fingerprint: String) -> [VouchRecord]
func isVouched(fingerprint: String) -> Bool
func effectiveTrustLevel(for fingerprint: String) -> TrustLevel
func lastVouchBatchSent(to fingerprint: String) -> Date?
func markVouchBatchSent(to fingerprint: String, at date: Date)
func signingPublicKey(forFingerprint fingerprint: String) -> Data?
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String]
} }
/// Singleton manager for secure identity state persistence and retrieval. /// Singleton manager for secure identity state persistence and retrieval.
@@ -550,16 +561,20 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
if verified { if verified {
self.cache.verifiedFingerprints.insert(fingerprint) self.cache.verifiedFingerprints.insert(fingerprint)
var verifiedAt = self.cache.verifiedAt ?? [:]
verifiedAt[fingerprint] = Date()
self.cache.verifiedAt = verifiedAt
} else { } else {
self.cache.verifiedFingerprints.remove(fingerprint) self.cache.verifiedFingerprints.remove(fingerprint)
self.cache.verifiedAt?.removeValue(forKey: fingerprint)
} }
// Update trust level if social identity exists // Update trust level if social identity exists
if var identity = self.cache.socialIdentities[fingerprint] { if var identity = self.cache.socialIdentities[fingerprint] {
identity.trustLevel = verified ? .verified : .casual identity.trustLevel = verified ? .verified : .casual
self.cache.socialIdentities[fingerprint] = identity self.cache.socialIdentities[fingerprint] = identity
} }
self.saveIdentityCache() self.saveIdentityCache()
} }
} }
@@ -576,6 +591,159 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
} }
} }
// MARK: - Vouching (transitive verification)
/// Maximum vouchers retained per vouchee (most recent kept).
static let maxVouchersPerVouchee = 8
/// Records an accepted vouch, enforcing every accept-policy gate that can
/// be evaluated against stored state (signature verification is the
/// caller's job it needs the sender's announce-bound signing key):
/// - the voucher must be a fingerprint *I* verified
/// - self-vouches are ignored
/// - vouches for peers I already verified are ignored (nothing to add)
/// - attestations outside the validity window are ignored
/// - at most `maxVouchersPerVouchee` vouchers are kept per vouchee
///
/// Returns true when the vouch was stored (or refreshed).
@discardableResult
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool {
recordVouch(
voucheeFingerprint: voucheeFingerprint,
voucherFingerprint: voucherFingerprint,
timestamp: timestamp,
now: Date()
)
}
@discardableResult
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date, now: Date) -> Bool {
queue.sync(flags: .barrier) {
guard voucheeFingerprint != voucherFingerprint,
self.cache.verifiedFingerprints.contains(voucherFingerprint),
!self.cache.verifiedFingerprints.contains(voucheeFingerprint) else {
return false
}
let age = now.timeIntervalSince(timestamp)
guard age <= VouchAttestation.maxAge, age >= -VouchAttestation.maxClockSkew else {
return false
}
var records = self.cache.vouchesByVouchee?[voucheeFingerprint] ?? []
if let index = records.firstIndex(where: { $0.voucherFingerprint == voucherFingerprint }) {
let newest = max(records[index].timestamp, timestamp)
records[index] = VouchRecord(voucherFingerprint: voucherFingerprint, timestamp: newest)
} else {
records.append(VouchRecord(voucherFingerprint: voucherFingerprint, timestamp: timestamp))
}
// Keep the most recent vouchers up to the cap.
records.sort { $0.timestamp > $1.timestamp }
let capped = Array(records.prefix(Self.maxVouchersPerVouchee))
guard capped.contains(where: { $0.voucherFingerprint == voucherFingerprint }) else {
return false // Full of fresher vouches; nothing changed.
}
var vouches = self.cache.vouchesByVouchee ?? [:]
vouches[voucheeFingerprint] = capped
self.cache.vouchesByVouchee = vouches
self.saveIdentityCache()
return true
}
}
/// The vouches that currently count for `fingerprint`. Validity is
/// recomputed here rather than maintained by cascade deletes: a record
/// only counts while its voucher is still verified-by-me and its
/// timestamp is within the expiry window.
func validVouchers(for fingerprint: String) -> [VouchRecord] {
validVouchers(for: fingerprint, now: Date())
}
func validVouchers(for fingerprint: String, now: Date) -> [VouchRecord] {
queue.sync {
self.validVouchersLocked(for: fingerprint, now: now)
}
}
/// Requires `queue`.
private func validVouchersLocked(for fingerprint: String, now: Date) -> [VouchRecord] {
guard let records = cache.vouchesByVouchee?[fingerprint] else { return [] }
return records.filter { record in
record.voucherFingerprint != fingerprint
&& cache.verifiedFingerprints.contains(record.voucherFingerprint)
&& now.timeIntervalSince(record.timestamp) <= VouchAttestation.maxAge
}
}
/// True when the peer has at least one valid vouch and no explicit
/// verification of ours.
func isVouched(fingerprint: String) -> Bool {
isVouched(fingerprint: fingerprint, now: Date())
}
func isVouched(fingerprint: String, now: Date) -> Bool {
queue.sync {
guard !self.cache.verifiedFingerprints.contains(fingerprint) else { return false }
return !self.validVouchersLocked(for: fingerprint, now: now).isEmpty
}
}
/// The trust level to display: explicit verification wins, then the
/// persisted level, with `vouched` layered in (derived, never persisted)
/// between `casual` and `trusted`.
func effectiveTrustLevel(for fingerprint: String) -> TrustLevel {
effectiveTrustLevel(for: fingerprint, now: Date())
}
func effectiveTrustLevel(for fingerprint: String, now: Date) -> TrustLevel {
queue.sync {
if self.cache.verifiedFingerprints.contains(fingerprint) { return .verified }
let stored = self.cache.socialIdentities[fingerprint]?.trustLevel ?? .unknown
let vouched = !self.validVouchersLocked(for: fingerprint, now: now).isEmpty
switch stored {
case .verified, .trusted:
return stored
case .vouched, .casual, .unknown:
if vouched { return .vouched }
// `.vouched` should never be persisted; degrade defensively.
return stored == .vouched ? .casual : stored
}
}
}
func lastVouchBatchSent(to fingerprint: String) -> Date? {
queue.sync { cache.vouchBatchSentAt?[fingerprint] }
}
func markVouchBatchSent(to fingerprint: String, at date: Date) {
queue.async(flags: .barrier) {
var sentAt = self.cache.vouchBatchSentAt ?? [:]
sentAt[fingerprint] = date
self.cache.vouchBatchSentAt = sentAt
self.saveIdentityCache()
}
}
/// The peer's announce-bound Ed25519 signing key, if seen this session.
func signingPublicKey(forFingerprint fingerprint: String) -> Data? {
queue.sync { cryptographicIdentities[fingerprint]?.signingPublicKey }
}
/// Verified fingerprints ordered most recently verified first (entries
/// without a recorded verification time sort last), excluding the given
/// fingerprint. Feeds the outgoing vouch batch.
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] {
queue.sync {
let verifiedAt = cache.verifiedAt ?? [:]
let ordered = cache.verifiedFingerprints
.filter { $0 != fingerprint }
.sorted {
(verifiedAt[$0] ?? .distantPast, $0) > (verifiedAt[$1] ?? .distantPast, $1)
}
return Array(ordered.prefix(limit))
}
}
var debugNicknameIndex: [String: Set<String>] { var debugNicknameIndex: [String: Set<String>] {
queue.sync { cache.nicknameIndex } queue.sync { cache.nicknameIndex }
} }
+28021 -416
View File
File diff suppressed because it is too large Load Diff
+40 -14
View File
@@ -11,48 +11,74 @@ import Foundation
// MARK: - CommandInfo Enum // MARK: - CommandInfo Enum
enum CommandInfo: String, Identifiable { enum CommandInfo: String, Identifiable {
// Raw values must match the aliases CommandProcessor actually accepts
// the suggestion panel is the app's only command-discovery surface, and
// suggesting a spelling the processor rejects teaches users dead ends.
case block case block
case clear case clear
case group
case help
case hug case hug
case message = "dm" case message = "msg"
case slap case slap
case pay
case unblock case unblock
case who case who
case favorite case favorite = "fav"
case unfavorite case unfavorite = "unfav"
case ping
case trace
var id: String { rawValue } var id: String { rawValue }
var alias: String { "/" + rawValue } var alias: String { "/" + rawValue }
var placeholder: String? { var placeholder: String? {
switch self { switch self {
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite: case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite, .ping, .trace:
return "<" + String(localized: "content.input.nickname_placeholder") + ">" return "<" + String(localized: "content.input.nickname_placeholder") + ">"
case .clear, .who: case .group:
return "<" + String(localized: "content.input.group_placeholder") + ">"
case .pay:
return "<" + String(localized: "content.input.token_placeholder") + ">"
case .clear, .help, .who:
return nil return nil
} }
} }
var description: String { var description: String {
switch self { switch self {
case .block: String(localized: "content.commands.block") case .block: String(localized: "content.commands.block")
case .clear: String(localized: "content.commands.clear") case .clear: String(localized: "content.commands.clear")
case .group: String(localized: "content.commands.group")
case .help: String(localized: "content.commands.help")
case .hug: String(localized: "content.commands.hug") case .hug: String(localized: "content.commands.hug")
case .message: String(localized: "content.commands.message") case .message: String(localized: "content.commands.message")
case .pay: String(localized: "content.commands.pay")
case .slap: String(localized: "content.commands.slap") case .slap: String(localized: "content.commands.slap")
case .unblock: String(localized: "content.commands.unblock") case .unblock: String(localized: "content.commands.unblock")
case .who: String(localized: "content.commands.who") case .who: String(localized: "content.commands.who")
case .favorite: String(localized: "content.commands.favorite") case .favorite: String(localized: "content.commands.favorite")
case .unfavorite: String(localized: "content.commands.unfavorite") case .unfavorite: String(localized: "content.commands.unfavorite")
case .ping: String(localized: "content.commands.ping")
case .trace: String(localized: "content.commands.trace")
} }
} }
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] { static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .hug, .message, .slap, .who] var commands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who]
if isGeoPublic || isGeoDM { // Cashu tokens are bearer instruments: in a public geohash any nearby
return baseCommands + [.favorite, .unfavorite] // stranger can redeem one, so don't *suggest* /pay there (the
// processor still allows it behind an explicit "public" confirm).
// Payments make sense in every DM and in mesh public.
if !isGeoPublic {
commands.append(.pay)
} }
return baseCommands // The processor rejects favorites, groups, and mesh diagnostics in
// geohash contexts, so only suggest them where they work: mesh.
if isGeoPublic || isGeoDM {
return commands
}
return commands + [.favorite, .unfavorite, .ping, .trace, .group]
} }
} }
+37 -1
View File
@@ -1,10 +1,21 @@
import BitFoundation
import Foundation import Foundation
// REQUEST_SYNC payload TLV (type, length16, value) // REQUEST_SYNC payload TLV (type, length16, value)
// - 0x01: P (uint8) Golomb-Rice parameter // - 0x01: P (uint8) Golomb-Rice parameter
// - 0x02: M (uint32, big-endian) hash range (N * 2^P) // - 0x02: M (uint32, big-endian) hash range (N * 2^P)
// - 0x03: data (opaque) GR bitstream bytes (MSB-first) // - 0x03: data (opaque) GR bitstream bytes (MSB-first)
// - 0x04: types (SyncTypeFlags) packet types the filter covers
// - 0x05: sinceTimestamp (uint64, big-endian) filter coverage cursor
// - 0x06: fragmentIdFilter (UTF-8) comma-separated 16-hex-char (8-byte)
// fragment stream IDs; restricts the fragment diff to exactly those
// streams (targeted resync for stalled reassemblies)
struct RequestSyncPacket { struct RequestSyncPacket {
/// Maximum fragment IDs one 0x06 filter may carry. Each ID encodes as
/// 16 hex chars plus a comma separator, so the largest encoded value is
/// 60 * 17 - 1 = 1019 bytes, which fits the 1024-byte decoder cap.
static let maxFragmentIdFilterCount = 60
let p: Int let p: Int
let m: UInt32 let m: UInt32
let data: Data let data: Data
@@ -12,6 +23,29 @@ struct RequestSyncPacket {
let sinceTimestamp: UInt64? let sinceTimestamp: UInt64?
let fragmentIdFilter: String? let fragmentIdFilter: String?
/// Encodes 8-byte fragment stream IDs as the 0x06 filter string,
/// dropping malformed IDs and capping at `maxFragmentIdFilterCount`.
static func encodeFragmentIdFilter(_ fragmentIDs: [Data]) -> String? {
let tokens = fragmentIDs
.filter { $0.count == 8 }
.prefix(maxFragmentIdFilterCount)
.map { $0.hexEncodedString() }
guard !tokens.isEmpty else { return nil }
return tokens.joined(separator: ",")
}
/// Decodes a 0x06 filter string back into 8-byte fragment stream IDs,
/// ignoring malformed tokens and capping at `maxFragmentIdFilterCount`.
static func decodeFragmentIdFilter(_ filter: String?) -> Set<Data>? {
guard let filter else { return nil }
var ids: Set<Data> = []
for token in filter.split(separator: ",").prefix(maxFragmentIdFilterCount) {
guard token.count == 16, let id = Data(hexString: String(token)) else { continue }
ids.insert(id)
}
return ids.isEmpty ? nil : ids
}
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil, sinceTimestamp: UInt64? = nil, fragmentIdFilter: String? = nil) { init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil, sinceTimestamp: UInt64? = nil, fragmentIdFilter: String? = nil) {
self.p = p self.p = p
self.m = m self.m = m
@@ -88,7 +122,9 @@ struct RequestSyncPacket {
sinceTimestamp = ts sinceTimestamp = ts
} }
case 0x06: case 0x06:
if let fid = String(data: v, encoding: .utf8) { // Same acceptance cap as the GCS payload; an oversized filter
// is ignored rather than failing the whole request.
if v.count <= maxAcceptBytes, let fid = String(data: v, encoding: .utf8) {
fragmentIdFilter = fid fragmentIdFilter = fid
} }
default: default:
+7 -1
View File
@@ -93,6 +93,7 @@ enum NoisePattern {
case XX // Most versatile, mutual authentication case XX // Most versatile, mutual authentication
case IK // Initiator knows responder's static key case IK // Initiator knows responder's static key
case NK // Anonymous initiator case NK // Anonymous initiator
case X // One-way: single message to a known static key (no response)
} }
enum NoiseRole { enum NoiseRole {
@@ -601,7 +602,7 @@ final class NoiseHandshakeState {
switch pattern { switch pattern {
case .XX: case .XX:
break // No pre-message keys break // No pre-message keys
case .IK, .NK: case .IK, .NK, .X:
if role == .initiator, let remoteStatic = remoteStaticPublic { if role == .initiator, let remoteStatic = remoteStaticPublic {
symmetricState.mixHash(remoteStatic.rawRepresentation) symmetricState.mixHash(remoteStatic.rawRepresentation)
} else if role == .responder, let localStatic = localStaticPublic { } else if role == .responder, let localStatic = localStaticPublic {
@@ -904,6 +905,7 @@ extension NoisePattern {
case .XX: return "XX" case .XX: return "XX"
case .IK: return "IK" case .IK: return "IK"
case .NK: return "NK" case .NK: return "NK"
case .X: return "X"
} }
} }
@@ -925,6 +927,10 @@ extension NoisePattern {
[.e, .es], // -> e, es [.e, .es], // -> e, es
[.e, .ee] // <- e, ee [.e, .ee] // <- e, ee
] ]
case .X:
return [
[.e, .es, .s, .ss] // -> e, es, s, ss (single one-way message)
]
} }
} }
} }
+215
View File
@@ -0,0 +1,215 @@
import BitFoundation
import CryptoKit
import Foundation
/// NIP-13 proof-of-work for Nostr events.
///
/// Outgoing kind-20000 geohash messages mine a `["nonce", "<value>", "<target>"]`
/// tag so the event ID carries at least `target` leading zero bits. Inbound
/// events are scored (never hard-rejected the network has clients that do
/// not mine): validated PoW at or above `rateLimitBypassBits` relaxes the
/// per-sender public rate limit, everything else keeps the strict limits.
enum NostrPoW {
// MARK: - Tuning
/// Difficulty (leading zero bits of the event ID) mined onto outgoing
/// geohash messages. 8 bits is ~256 hash attempts typically well under
/// 100 ms on any supported device.
static let targetBits = 8
/// Inbound events whose validated NIP-13 difficulty is at least this many
/// bits skip the per-sender rate-limit bucket (the content-flood bucket
/// still applies). See `MessageRateLimiter.allow`.
static let rateLimitBypassBits = 8
/// Hard cap on mining wall-clock time. When it hits, the committed target
/// steps down until a difficulty reachable in a small extra budget is
/// found and the message is sent anyway mining never blocks sending.
static let miningTimeCap: TimeInterval = 2.0
/// Budget for each stepped-down attempt after the main cap (or a task
/// cancellation) hits.
private static let fallbackTimeCap: TimeInterval = 0.15
/// The hot loop checks the deadline and task cancellation every this many
/// hash attempts.
private static let checkInterval: UInt64 = 1024
/// The nonce value is a fixed-width hex counter so the serialized event
/// template can be mutated in place without reallocation.
private static let nonceLength = 16
// MARK: - Scoring
/// Number of leading zero bits in a byte sequence (NIP-13 difficulty of
/// an event-ID hash).
static func leadingZeroBits<Bytes: Sequence<UInt8>>(_ bytes: Bytes) -> Int {
var total = 0
for byte in bytes {
if byte == 0 {
total += 8
} else {
total += byte.leadingZeroBitCount
break
}
}
return total
}
/// Validated NIP-13 difficulty of an inbound event.
///
/// The committed target in the nonce tag is what counts: the actual
/// leading zero bits of the ID must meet it (otherwise the claim is void
/// and the event scores 0), and work beyond the commitment earns no extra
/// credit this stops spammers who mine a low target from getting lucky
/// high scores. Events without a well-formed commitment score 0.
static func validatedDifficulty(idHex: String, tags: [[String]]) -> Int {
guard let nonceTag = tags.last(where: { $0.first == "nonce" }),
nonceTag.count >= 3,
let committed = Int(nonceTag[2]),
committed > 0, committed <= 256,
let idData = Data(hexString: idHex)
else {
return 0
}
return leadingZeroBits(idData) >= committed ? committed : 0
}
// MARK: - Mining
/// Mine a `["nonce", value, target]` tag for the given unsigned-event
/// fields. Nonisolated async: runs off the calling actor.
///
/// Bounded by `miningTimeCap`: when the cap hits or the surrounding
/// task is cancelled the committed target steps down (halving to 0,
/// which any hash satisfies) so the event still ships promptly with an
/// honest commitment at the difficulty actually reached. Returns nil only
/// if canonical serialization fails; the caller then sends unmined.
static func mineNonceTag(
pubkey: String,
createdAt: Int,
kind: Int,
tags: [[String]],
content: String,
targetBits: Int = NostrPoW.targetBits
) async -> [String]? {
var target = min(max(targetBits, 0), 256)
var budget = miningTimeCap
while true {
if let tag = mineAttempt(
pubkey: pubkey,
createdAt: createdAt,
kind: kind,
baseTags: tags,
content: content,
targetBits: target,
budget: budget
) {
return tag
}
// Target 0 succeeds on the first hash, so reaching it with nil
// means serialization itself failed give up on mining.
if target == 0 { return nil }
target /= 2
budget = fallbackTimeCap
}
}
/// One bounded mining pass at a fixed committed target. Allocation-light:
/// the canonical serialization is built once and only the fixed-width
/// nonce bytes are rewritten per attempt (the event ID is recomputed for
/// every attempt, per NIP-13). Returns nil on timeout/cancellation or if
/// the template could not be built.
private static func mineAttempt(
pubkey: String,
createdAt: Int,
kind: Int,
baseTags: [[String]],
content: String,
targetBits: Int,
budget: TimeInterval
) -> [String]? {
let targetString = String(targetBits)
guard let template = serializedTemplate(
pubkey: pubkey,
createdAt: createdAt,
kind: kind,
baseTags: baseTags,
content: content,
targetString: targetString
) else {
return nil
}
var buffer = template.buffer
let nonceRange = template.nonceRange
let deadline = DispatchTime.now().uptimeNanoseconds &+ UInt64(budget * 1_000_000_000)
let hexDigits = [UInt8]("0123456789abcdef".utf8)
var nonce = UInt64.random(in: .min ... .max)
var attempts: UInt64 = 0
while true {
// Write the nonce as 16 lowercase hex chars, in place.
var value = nonce
var index = nonceRange.upperBound
while index > nonceRange.lowerBound {
index -= 1
buffer[index] = hexDigits[Int(value & 0xF)]
value >>= 4
}
if leadingZeroBits(SHA256.hash(data: buffer)) >= targetBits {
// Identical to the bytes just written into the buffer.
return ["nonce", String(format: "%016llx", nonce), targetString]
}
nonce &+= 1
attempts &+= 1
if attempts % checkInterval == 0,
Task.isCancelled || DispatchTime.now().uptimeNanoseconds >= deadline {
return nil
}
}
}
/// Canonical NIP-01 serialization of the event with a placeholder nonce,
/// plus the byte range of the nonce value inside it.
///
/// The range is located by serializing twice with two same-length
/// placeholders and diffing the buffers the only differing bytes are
/// the nonce value, so this stays correct however `JSONSerialization`
/// escapes the surrounding fields (and even if the content contains the
/// placeholder text itself).
private static func serializedTemplate(
pubkey: String,
createdAt: Int,
kind: Int,
baseTags: [[String]],
content: String,
targetString: String
) -> (buffer: Data, nonceRange: Range<Int>)? {
func serialize(noncePlaceholder: String) -> Data? {
var tags = baseTags
tags.append(["nonce", noncePlaceholder, targetString])
let serialized: [Any] = [0, pubkey, createdAt, kind, tags, content]
return try? JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
}
guard let zeros = serialize(noncePlaceholder: String(repeating: "0", count: nonceLength)),
let effs = serialize(noncePlaceholder: String(repeating: "f", count: nonceLength)),
zeros.count == effs.count
else {
return nil
}
var firstDiff = -1
var lastDiff = -1
for index in 0..<zeros.count where zeros[index] != effs[index] {
if firstDiff < 0 { firstDiff = index }
lastDiff = index
}
guard firstDiff >= 0, lastDiff - firstDiff + 1 == nonceLength else { return nil }
return (zeros, firstDiff..<(firstDiff + nonceLength))
}
}
+86 -12
View File
@@ -19,6 +19,7 @@ struct NostrProtocol {
case giftWrap = 1059 // NIP-59 gift wrap case giftWrap = 1059 // NIP-59 gift wrap
case ephemeralEvent = 20000 case ephemeralEvent = 20000
case geohashPresence = 20001 case geohashPresence = 20001
case deletion = 5 // NIP-09 event deletion request
} }
/// Create a NIP-17 private message /// Create a NIP-17 private message
@@ -170,6 +171,63 @@ struct NostrProtocol {
nickname: String? = nil, nickname: String? = nil,
teleported: Bool = false teleported: Bool = false
) throws -> NostrEvent { ) throws -> NostrEvent {
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .ephemeralEvent,
tags: ephemeralGeohashTags(geohash: geohash, nickname: nickname, teleported: teleported),
content: content
)
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
}
/// Create a kind-20000 geohash message carrying a NIP-13 proof-of-work
/// nonce tag (see `NostrPoW`). Mining runs off the calling actor and is
/// bounded by `NostrPoW.miningTimeCap`; when the cap hits (or the
/// surrounding task is cancelled) the event ships at the highest
/// committed difficulty still met, and if mining is impossible it ships
/// unmined sending is never blocked.
static func createMinedEphemeralGeohashEvent(
content: String,
geohash: String,
senderIdentity: NostrIdentity,
nickname: String? = nil,
teleported: Bool = false,
powTargetBits: Int = NostrPoW.targetBits
) async throws -> NostrEvent {
var tags = ephemeralGeohashTags(geohash: geohash, nickname: nickname, teleported: teleported)
// Fix created_at up front: the mined nonce commits to the full
// serialized event, so the signed event must reuse the exact value.
let createdAt = Int(Date().timeIntervalSince1970)
if let nonceTag = await NostrPoW.mineNonceTag(
pubkey: senderIdentity.publicKeyHex,
createdAt: createdAt,
kind: EventKind.ephemeralEvent.rawValue,
tags: tags,
content: content,
targetBits: powTargetBits
) {
tags.append(nonceTag)
}
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(timeIntervalSince1970: TimeInterval(createdAt)),
kind: .ephemeralEvent,
tags: tags,
content: content
)
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
}
/// Tags for a kind-20000 geohash message (shared by the plain and mined
/// variants).
private static func ephemeralGeohashTags(
geohash: String,
nickname: String?,
teleported: Bool
) -> [[String]] {
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])
@@ -177,15 +235,7 @@ struct NostrProtocol {
if teleported { if teleported {
tags.append(["t", "teleport"]) tags.append(["t", "teleport"])
} }
let event = NostrEvent( return tags
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .ephemeralEvent,
tags: tags,
content: content
)
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
} }
/// Create a geohash presence heartbeat (kind 20001) /// Create a geohash presence heartbeat (kind 20001)
@@ -207,16 +257,22 @@ struct NostrProtocol {
} }
/// 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
) 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))])
}
let event = NostrEvent( let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex, pubkey: senderIdentity.publicKeyHex,
createdAt: Date(), createdAt: Date(),
@@ -227,7 +283,25 @@ struct NostrProtocol {
let schnorrKey = try senderIdentity.schnorrSigningKey() let schnorrKey = try senderIdentity.schnorrSigningKey()
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(
@@ -648,7 +722,7 @@ private extension NostrProtocol {
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey( let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: sharedSecretData), inputKeyMaterial: SymmetricKey(data: sharedSecretData),
salt: Data(), salt: Data(),
info: "nip44-v2".data(using: .utf8)!, info: Data("nip44-v2".utf8),
outputByteCount: 32 outputByteCount: 32
) )
return derivedKey.withUnsafeBytes { Data($0) } return derivedKey.withUnsafeBytes { Data($0) }
+7
View File
@@ -137,6 +137,10 @@ final class NostrRelayManager: ObservableObject {
@Published private(set) var relays: [Relay] = [] @Published private(set) var relays: [Relay] = []
@Published private(set) var isConnected = false @Published private(set) var isConnected = false
/// Whether a relay that carries private messages is connected. DMs
/// target the default (gift-wrap-capable) relay set, so a connected
/// geohash/custom relay alone must not count sends would still queue.
@Published private(set) var isDMRelayConnected = false
private let dependencies: NostrRelayManagerDependencies private let dependencies: NostrRelayManagerDependencies
private var allowDefaultRelays: Bool = false private var allowDefaultRelays: Bool = false
@@ -1087,6 +1091,9 @@ final class NostrRelayManager: ObservableObject {
private func updateConnectionStatus() { private func updateConnectionStatus() {
isConnected = relays.contains { $0.isConnected } isConnected = relays.contains { $0.isConnected }
// Relay URLs are normalized before entries are created, so direct
// set membership is sound.
isDMRelayConnected = relays.contains { $0.isConnected && Self.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
@@ -132,4 +132,3 @@ private extension Data {
replaceSubrange(offset..<(offset+4), with: bytes) replaceSubrange(offset..<(offset+4), with: bytes)
} }
} }
+25 -8
View File
@@ -18,7 +18,7 @@
/// - Efficient binary message encoding /// - Efficient binary message encoding
/// - Message fragmentation for large payloads /// - Message fragmentation for large payloads
/// - TTL-based routing for mesh networks /// - TTL-based routing for mesh networks
/// - Privacy features like padding and timing obfuscation /// - Privacy features: message padding and randomized relay jitter
/// - Integration points for end-to-end encryption /// - Integration points for end-to-end encryption
/// ///
/// ## Protocol Design /// ## Protocol Design
@@ -38,18 +38,20 @@
/// 7. **Decoding**: Binary data parsed back to message objects /// 7. **Decoding**: Binary data parsed back to message objects
/// ///
/// ## Security Considerations /// ## Security Considerations
/// - Message padding obscures actual content length /// - Message padding (to 256/512/1024/2048-byte blocks) obscures actual content length
/// - Timing obfuscation prevents traffic analysis /// - Randomized relay jitter reduces the traffic-analysis signal; there is no
/// 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 /// - No persistent identifiers in protocol headers
/// ///
/// ## Message Types /// ## Message Types
/// - **Announce/Leave**: Peer presence notifications /// - **Announce/Leave**: Peer presence notifications
/// - **Message**: User chat messages (broadcast or directed) /// - **Message**: Public chat messages
/// - **Fragment**: Multi-part message handling /// - **Fragment**: Multi-part message handling
/// - **Delivery/Read**: Message acknowledgments /// - **NoiseHandshake/NoiseEncrypted**: Encrypted channel establishment and
/// - **Noise**: Encrypted channel establishment /// all private payloads (messages, delivery acks, read receipts)
/// - **Version**: Protocol version negotiation /// - **CourierEnvelope**: Sealed store-and-forward mail
/// - **RequestSync/FileTransfer**: Gossip history sync and media transfer
/// ///
/// ## Future Extensions /// ## Future Extensions
/// The protocol is designed to be extensible: /// The protocol is designed to be extensible:
@@ -72,17 +74,25 @@ 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
// Private groups (0x04/0x05 reserved by other features)
case groupInvite = 0x06 // Creator-signed group state (invite)
case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update)
// 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)
case vouch = 0x12 // Batch of vouch attestations
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 .groupInvite: return "groupInvite"
case .groupKeyUpdate: return "groupKeyUpdate"
case .verifyChallenge: return "verifyChallenge" case .verifyChallenge: return "verifyChallenge"
case .verifyResponse: return "verifyResponse" case .verifyResponse: return "verifyResponse"
case .vouch: return "vouch"
} }
} }
} }
@@ -114,6 +124,9 @@ protocol BitchatDelegate: AnyObject {
// Low-level events for better separation of concerns // Low-level events for better separation of concerns
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
// Encrypted group broadcast (opaque envelope; decrypted by the group coordinator)
func didReceiveGroupMessage(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?)
@@ -133,6 +146,10 @@ extension BitchatDelegate {
// Default empty implementation // Default empty implementation
} }
func didReceiveGroupMessage(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
} }
+348
View File
@@ -0,0 +1,348 @@
//
// BoardPackets.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import CryptoKit
import Foundation
// MARK: - Board wire format (MessageType.boardPost payloads)
//
// TLV layout (type u8, length u16 big-endian, value), matching REQUEST_SYNC:
// - 0x01: kind (u8) 0x01 post, 0x02 tombstone
// - 0x02: postID (16B random)
// - 0x03: geohash (UTF-8, empty = mesh-local board, max 12 chars)
// - 0x04: content (UTF-8, 1...512 bytes) [post]
// - 0x05: authorSigningKey (32B Ed25519 public key)
// - 0x06: authorNickname (UTF-8, max 64 bytes)
// - 0x07: createdAt (u64 big-endian, ms) [post]
// - 0x08: expiresAt (u64 big-endian, ms, max 7 days after createdAt) [post]
// - 0x09: flags (u8, bit0 = urgent) [post]
// - 0x0A: signature (64B Ed25519)
// - 0x0B: deletedAt (u64 big-endian, ms) [tombstone]
// Unknown TLVs are skipped for forward compatibility.
enum BoardWireConstants {
static let postIDLength = 16
static let signingKeyLength = 32
static let signatureLength = 64
static let contentMaxBytes = 512
static let nicknameMaxBytes = 64
static let geohashMaxLength = 12
/// Posts may live at most 7 days past their creation timestamp.
static let maxLifetimeMs: UInt64 = 7 * 24 * 60 * 60 * 1000
static let postSigningContext = "bitchat-board-v1"
static let tombstoneSigningContext = "bitchat-board-del-v1"
static let geohashAlphabet = Set("0123456789bcdefghjkmnpqrstuvwxyz")
}
private enum BoardTLVType: UInt8 {
case kind = 0x01
case postID = 0x02
case geohash = 0x03
case content = 0x04
case authorSigningKey = 0x05
case authorNickname = 0x06
case createdAt = 0x07
case expiresAt = 0x08
case flags = 0x09
case signature = 0x0A
case deletedAt = 0x0B
}
private enum BoardWireKind: UInt8 {
case post = 0x01
case tombstone = 0x02
}
/// A signed, persistent bulletin-board notice.
struct BoardPostPacket: Equatable {
let postID: Data
/// Empty string scopes the post to the mesh-local board.
let geohash: String
let content: String
let authorSigningKey: Data
let authorNickname: String
let createdAt: UInt64
let expiresAt: UInt64
let flags: UInt8
let signature: Data
static let urgentFlag: UInt8 = 0x01
var isUrgent: Bool { flags & Self.urgentFlag != 0 }
/// Canonical bytes covered by the Ed25519 signature. Variable-length
/// fields are length-prefixed so no two field combinations can collide.
static func signingBytes(
postID: Data,
geohash: String,
content: String,
authorSigningKey: Data,
authorNickname: String,
createdAt: UInt64,
expiresAt: UInt64,
flags: UInt8
) -> Data {
var out = Data()
BoardWireEncoding.appendContext(BoardWireConstants.postSigningContext, to: &out)
out.append(postID)
BoardWireEncoding.appendLengthPrefixed(Data(geohash.utf8), to: &out)
BoardWireEncoding.appendLengthPrefixed(Data(content.utf8), to: &out)
out.append(authorSigningKey)
BoardWireEncoding.appendLengthPrefixed(Data(authorNickname.utf8), to: &out)
BoardWireEncoding.appendUInt64(createdAt, to: &out)
BoardWireEncoding.appendUInt64(expiresAt, to: &out)
out.append(flags)
return out
}
var signingBytes: Data {
Self.signingBytes(
postID: postID,
geohash: geohash,
content: content,
authorSigningKey: authorSigningKey,
authorNickname: authorNickname,
createdAt: createdAt,
expiresAt: expiresAt,
flags: flags
)
}
func verifySignature() -> Bool {
BoardWireEncoding.verify(signature: signature, over: signingBytes, publicKey: authorSigningKey)
}
}
/// A signed deletion marker. Only the author's key can produce one; receivers
/// keep it until the post's original expiry so the delete outruns the post.
struct BoardTombstonePacket: Equatable {
let postID: Data
let authorSigningKey: Data
let deletedAt: UInt64
let signature: Data
static func signingBytes(postID: Data, deletedAt: UInt64) -> Data {
var out = Data()
BoardWireEncoding.appendContext(BoardWireConstants.tombstoneSigningContext, to: &out)
out.append(postID)
BoardWireEncoding.appendUInt64(deletedAt, to: &out)
return out
}
var signingBytes: Data {
Self.signingBytes(postID: postID, deletedAt: deletedAt)
}
func verifySignature() -> Bool {
BoardWireEncoding.verify(signature: signature, over: signingBytes, publicKey: authorSigningKey)
}
}
/// Decoded board payload: either a live post or a tombstone.
enum BoardWire: Equatable {
case post(BoardPostPacket)
case tombstone(BoardTombstonePacket)
func encode() -> Data {
var out = Data()
func putTLV(_ t: BoardTLVType, _ v: Data) {
out.append(t.rawValue)
let len = UInt16(v.count)
out.append(UInt8((len >> 8) & 0xFF))
out.append(UInt8(len & 0xFF))
out.append(v)
}
switch self {
case .post(let post):
putTLV(.kind, Data([BoardWireKind.post.rawValue]))
putTLV(.postID, post.postID)
putTLV(.geohash, Data(post.geohash.utf8))
putTLV(.content, Data(post.content.utf8))
putTLV(.authorSigningKey, post.authorSigningKey)
putTLV(.authorNickname, Data(post.authorNickname.utf8))
putTLV(.createdAt, BoardWireEncoding.uint64Data(post.createdAt))
putTLV(.expiresAt, BoardWireEncoding.uint64Data(post.expiresAt))
putTLV(.flags, Data([post.flags]))
putTLV(.signature, post.signature)
case .tombstone(let tombstone):
putTLV(.kind, Data([BoardWireKind.tombstone.rawValue]))
putTLV(.postID, tombstone.postID)
putTLV(.authorSigningKey, tombstone.authorSigningKey)
putTLV(.deletedAt, BoardWireEncoding.uint64Data(tombstone.deletedAt))
putTLV(.signature, tombstone.signature)
}
return out
}
/// Structural decode; the caller must still verify the signature before
/// ingesting (`verifySignature()`).
static func decode(from data: Data) -> BoardWire? {
var off = data.startIndex
var kind: BoardWireKind?
var postID: Data?
var geohash: String?
var content: String?
var contentBytes = 0
var authorSigningKey: Data?
var authorNickname: String?
var nicknameBytes = 0
var createdAt: UInt64?
var expiresAt: UInt64?
var flags: UInt8?
var signature: Data?
var deletedAt: UInt64?
while off + 3 <= data.endIndex {
let t = data[off]; off += 1
let len = (Int(data[off]) << 8) | Int(data[off + 1]); off += 2
guard off + len <= data.endIndex else { return nil }
let v = data.subdata(in: off..<(off + len)); off += len
switch BoardTLVType(rawValue: t) {
case .kind:
guard v.count == 1 else { return nil }
kind = BoardWireKind(rawValue: v[v.startIndex])
case .postID:
guard v.count == BoardWireConstants.postIDLength else { return nil }
postID = v
case .geohash:
guard v.count <= BoardWireConstants.geohashMaxLength else { return nil }
geohash = String(data: v, encoding: .utf8)
case .content:
guard v.count <= BoardWireConstants.contentMaxBytes else { return nil }
contentBytes = v.count
content = String(data: v, encoding: .utf8)
case .authorSigningKey:
guard v.count == BoardWireConstants.signingKeyLength else { return nil }
authorSigningKey = v
case .authorNickname:
guard v.count <= BoardWireConstants.nicknameMaxBytes else { return nil }
nicknameBytes = v.count
authorNickname = String(data: v, encoding: .utf8)
case .createdAt:
createdAt = BoardWireEncoding.uint64(from: v)
case .expiresAt:
expiresAt = BoardWireEncoding.uint64(from: v)
case .flags:
guard v.count == 1 else { return nil }
flags = v[v.startIndex]
case .signature:
guard v.count == BoardWireConstants.signatureLength else { return nil }
signature = v
case .deletedAt:
deletedAt = BoardWireEncoding.uint64(from: v)
case nil:
continue // forward compatible; ignore unknown TLVs
}
}
guard let postID, let authorSigningKey, let signature else { return nil }
switch kind {
case .post:
guard let geohash, let content, let authorNickname,
let createdAt, let expiresAt, let flags,
contentBytes >= 1,
nicknameBytes <= BoardWireConstants.nicknameMaxBytes,
isValidGeohashField(geohash),
expiresAt > createdAt,
expiresAt - createdAt <= BoardWireConstants.maxLifetimeMs else {
return nil
}
return .post(BoardPostPacket(
postID: postID,
geohash: geohash,
content: content,
authorSigningKey: authorSigningKey,
authorNickname: authorNickname,
createdAt: createdAt,
expiresAt: expiresAt,
flags: flags,
signature: signature
))
case .tombstone:
guard let deletedAt else { return nil }
return .tombstone(BoardTombstonePacket(
postID: postID,
authorSigningKey: authorSigningKey,
deletedAt: deletedAt,
signature: signature
))
case nil:
return nil
}
}
func verifySignature() -> Bool {
switch self {
case .post(let post): return post.verifySignature()
case .tombstone(let tombstone): return tombstone.verifySignature()
}
}
/// Cheap TLV peek for relay policy: is this payload an urgent post?
/// Avoids a full decode on the hot relay path.
static func urgentFlag(in data: Data) -> Bool {
var off = data.startIndex
while off + 3 <= data.endIndex {
let t = data[off]; off += 1
let len = (Int(data[off]) << 8) | Int(data[off + 1]); off += 2
guard off + len <= data.endIndex else { return false }
if t == BoardTLVType.flags.rawValue, len == 1 {
return data[off] & BoardPostPacket.urgentFlag != 0
}
off += len
}
return false
}
/// Empty geohash = mesh-local board; otherwise 1-12 chars of the geohash
/// base32 alphabet.
private static func isValidGeohashField(_ geohash: String) -> Bool {
geohash.isEmpty || geohash.allSatisfy { BoardWireConstants.geohashAlphabet.contains($0) }
}
}
enum BoardWireEncoding {
static func appendContext(_ context: String, to out: inout Data) {
let bytes = Data(context.utf8)
out.append(UInt8(min(bytes.count, 255)))
out.append(bytes.prefix(255))
}
static func appendLengthPrefixed(_ value: Data, to out: inout Data) {
let len = UInt16(min(value.count, Int(UInt16.max)))
out.append(UInt8((len >> 8) & 0xFF))
out.append(UInt8(len & 0xFF))
out.append(value.prefix(Int(UInt16.max)))
}
static func appendUInt64(_ value: UInt64, to out: inout Data) {
var be = value.bigEndian
withUnsafeBytes(of: &be) { out.append(contentsOf: $0) }
}
static func uint64Data(_ value: UInt64) -> Data {
var out = Data()
appendUInt64(value, to: &out)
return out
}
static func uint64(from data: Data) -> UInt64? {
guard data.count == 8 else { return nil }
var value: UInt64 = 0
for byte in data { value = (value << 8) | UInt64(byte) }
return value
}
static func verify(signature: Data, over message: Data, publicKey: Data) -> Bool {
guard let key = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKey) else {
return false
}
return key.isValidSignature(signature, for: message)
}
}
+8
View File
@@ -18,6 +18,14 @@ enum Geohash {
return geohash.lowercased().allSatisfy { base32Map[$0] != nil } return geohash.lowercased().allSatisfy { base32Map[$0] != nil }
} }
/// Validates a geohash string at any channel precision (1-12 characters).
/// - Parameter geohash: The geohash string to validate
/// - Returns: true if a non-empty base32 geohash of at most 12 characters
static func isValidGeohash(_ geohash: String) -> Bool {
guard (1...12).contains(geohash.count) else { return false }
return geohash.lowercased().allSatisfy { base32Map[$0] != nil }
}
/// Encodes the provided coordinates into a geohash string. /// Encodes the provided coordinates into a geohash string.
/// - Parameters: /// - Parameters:
/// - latitude: Latitude in degrees (-90...90) /// - latitude: Latitude in degrees (-90...90)
+1 -1
View File
@@ -18,7 +18,7 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
case .city: return 5 case .city: return 5
case .province: return 4 case .province: return 4
case .region: return 2 case .region: return 2
} }
} }
var displayName: String { var displayName: String {
+136
View File
@@ -0,0 +1,136 @@
//
// NostrCarrierPacket.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Foundation
/// Wire payload for `MessageType.nostrCarrier` (0x28): a complete, signed
/// Nostr event ferried over the mesh between a mesh-only peer and an
/// internet gateway peer.
///
/// - `toGateway` rides a DIRECTED packet (recipientID = the gateway peer):
/// a mesh-only sender asks the gateway to publish its locally signed
/// geohash event to Nostr relays.
/// - `fromGateway` rides a BROADCAST packet (default TTL): the gateway
/// rebroadcasts inbound relay events so mesh-only peers see the channel.
///
/// The carried event is public geohash chat already plaintext on Nostr
/// so the carrier adds no encryption. It IS signed by the originator's
/// per-geohash identity, so neither the gateway nor any mesh relay can forge
/// or alter it undetected: gateways and receivers verify the Schnorr
/// signature before acting on it.
///
/// TLV encoding with 2-byte big-endian lengths (the event JSON exceeds the
/// 1-byte TLV range used by smaller packets). Unknown TLV types are skipped
/// for forward compatibility.
struct NostrCarrierPacket: Equatable {
enum Direction: UInt8 {
case toGateway = 0x01
case fromGateway = 0x02
}
let direction: Direction
let geohash: String
/// Complete signed Nostr event JSON (id, pubkey, created_at, kind, tags,
/// content, sig).
let eventJSON: Data
/// BLE airtime cap for a carried event.
static let maxEventJSONBytes = 16 * 1024
static let maxGeohashLength = 12
private enum TLVType: UInt8 {
case direction = 0x01
case geohash = 0x02
case eventJSON = 0x03
}
init?(direction: Direction, geohash: String, eventJSON: Data) {
let geohashBytes = Data(geohash.utf8)
guard !geohashBytes.isEmpty,
geohashBytes.count <= Self.maxGeohashLength,
!eventJSON.isEmpty,
eventJSON.count <= Self.maxEventJSONBytes else {
return nil
}
self.direction = direction
self.geohash = geohash
self.eventJSON = eventJSON
}
init?(direction: Direction, geohash: String, event: NostrEvent) {
guard let json = try? event.jsonString(), !json.isEmpty else { return nil }
self.init(direction: direction, geohash: geohash, eventJSON: Data(json.utf8))
}
/// Decodes the carried event. Callers MUST still verify
/// `event.isValidSignature()` before publishing or displaying it.
func event() -> NostrEvent? {
guard let dict = try? JSONSerialization.jsonObject(with: eventJSON) as? [String: Any] else {
return nil
}
return try? NostrEvent(from: dict)
}
func encode() -> Data? {
var data = Data()
data.reserveCapacity(eventJSON.count + geohash.utf8.count + 12)
func appendTLV(_ type: TLVType, _ value: Data) {
data.append(type.rawValue)
data.append(UInt8((value.count >> 8) & 0xFF))
data.append(UInt8(value.count & 0xFF))
data.append(value)
}
appendTLV(.direction, Data([direction.rawValue]))
appendTLV(.geohash, Data(geohash.utf8))
appendTLV(.eventJSON, eventJSON)
return data
}
static func decode(_ data: Data) -> NostrCarrierPacket? {
// Defensive slice re-base (Data slices keep parent indices).
let data = Data(data)
var offset = 0
var direction: Direction?
var geohash: String?
var eventJSON: Data?
while offset + 3 <= data.count {
let typeRaw = data[offset]
let length = (Int(data[offset + 1]) << 8) | Int(data[offset + 2])
offset += 3
guard offset + length <= data.count else { return nil }
let value = data.subdata(in: offset..<offset + length)
offset += length
switch TLVType(rawValue: typeRaw) {
case .direction:
guard value.count == 1, let parsed = Direction(rawValue: value[0]) else { return nil }
direction = parsed
case .geohash:
guard let parsed = String(data: value, encoding: .utf8) else { return nil }
geohash = parsed
case .eventJSON:
eventJSON = value
case nil:
// Unknown TLV; skip (tolerant decoder for forward compatibility).
continue
}
}
guard offset == data.count,
let direction,
let geohash,
let eventJSON else {
return nil
}
return NostrCarrierPacket(direction: direction, geohash: geohash, eventJSON: eventJSON)
}
}
+31 -1
View File
@@ -1,3 +1,4 @@
import BitFoundation
import Foundation import Foundation
// MARK: - Protocol TLV Packets // MARK: - Protocol TLV Packets
@@ -7,12 +8,28 @@ struct AnnouncementPacket {
let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement) let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement)
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)
init(
nickname: String,
noisePublicKey: Data,
signingPublicKey: Data,
directNeighbors: [Data]?,
capabilities: PeerCapabilities? = nil
) {
self.nickname = nickname
self.noisePublicKey = noisePublicKey
self.signingPublicKey = signingPublicKey
self.directNeighbors = directNeighbors
self.capabilities = capabilities
}
private enum TLVType: UInt8 { private enum TLVType: UInt8 {
case nickname = 0x01 case nickname = 0x01
case noisePublicKey = 0x02 case noisePublicKey = 0x02
case signingPublicKey = 0x03 case signingPublicKey = 0x03
case directNeighbors = 0x04 case directNeighbors = 0x04
case capabilities = 0x05
} }
func encode() -> Data? { func encode() -> Data? {
@@ -48,6 +65,15 @@ struct AnnouncementPacket {
} }
} }
// TLV for capabilities (optional)
if let capabilities = capabilities {
let capabilityBytes = capabilities.encoded()
guard capabilityBytes.count <= 255 else { return nil }
data.append(TLVType.capabilities.rawValue)
data.append(UInt8(capabilityBytes.count))
data.append(capabilityBytes)
}
return data return data
} }
@@ -57,6 +83,7 @@ struct AnnouncementPacket {
var noisePublicKey: Data? var noisePublicKey: Data?
var signingPublicKey: Data? var signingPublicKey: Data?
var directNeighbors: [Data]? var directNeighbors: [Data]?
var capabilities: PeerCapabilities?
while offset + 2 <= data.count { while offset + 2 <= data.count {
let typeRaw = data[offset] let typeRaw = data[offset]
@@ -87,6 +114,8 @@ struct AnnouncementPacket {
} }
directNeighbors = neighbors directNeighbors = neighbors
} }
case .capabilities:
capabilities = PeerCapabilities(encoded: Data(value))
} }
} else { } else {
// Unknown TLV; skip (tolerant decoder for forward compatibility) // Unknown TLV; skip (tolerant decoder for forward compatibility)
@@ -99,7 +128,8 @@ struct AnnouncementPacket {
nickname: nickname, nickname: nickname,
noisePublicKey: noisePublicKey, noisePublicKey: noisePublicKey,
signingPublicKey: signingPublicKey, signingPublicKey: signingPublicKey,
directNeighbors: directNeighbors directNeighbors: directNeighbors,
capabilities: capabilities
) )
} }
} }
@@ -0,0 +1,7 @@
import BitFoundation
extension PeerCapabilities {
/// Capabilities this build advertises in its announce packets.
/// Each feature adds its bit here when it ships.
static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups]
}
+225
View File
@@ -0,0 +1,225 @@
//
// VouchAttestation.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import CryptoKit
import Foundation
/// A signed statement that the *sender of the enclosing Noise payload* has
/// verified the identity described here ("transitive verification").
///
/// The voucher's identity is deliberately implicit: attestations only travel
/// inside an authenticated Noise session (`NoisePayloadType.vouch`), so the
/// receiver verifies the Ed25519 signature against the session peer's
/// announce-bound signing key and stores the vouch keyed by that peer's
/// fingerprint. Nothing in the attestation names the voucher, so a captured
/// attestation cannot be replayed by a third party whose signing key doesn't
/// match.
///
/// Wire format single attestation (TLV, 1-byte type + 1-byte length):
/// - `0x01` voucheeFingerprint: 32 bytes, SHA-256 of the vouchee's Noise static key
/// - `0x02` voucheeSigningKey: 32 bytes, Ed25519; anchors the vouch to a concrete identity
/// - `0x03` timestamp: 8 bytes big-endian, milliseconds since 1970
/// - `0x04` signature: 64 bytes, Ed25519 by the VOUCHER's signing key over
/// `"bitchat-vouch-v1" | voucheeFingerprint | voucheeSigningKey | timestamp`
///
/// Unknown TLV types are skipped for forward compatibility.
///
/// Batch format (the `vouch` Noise payload body):
/// `[count: UInt8]` then per attestation `[length: UInt16 BE][attestation TLV]`.
struct VouchAttestation: Equatable {
static let signingContext = "bitchat-vouch-v1"
/// Receiver-side expiry for attestations.
static let maxAge: TimeInterval = 30 * 24 * 60 * 60
/// Tolerated clock skew for attestations timestamped in the future.
static let maxClockSkew: TimeInterval = 60 * 60
/// Upper bound of attestations carried/accepted in one batch payload.
static let maxBatchCount = 16
static let fingerprintSize = 32
static let signingKeySize = 32
static let signatureSize = 64
let voucheeFingerprint: Data // 32 bytes
let voucheeSigningKey: Data // 32 bytes
let timestampMs: UInt64
let signature: Data // 64 bytes
private enum TLVType: UInt8 {
case voucheeFingerprint = 0x01
case voucheeSigningKey = 0x02
case timestamp = 0x03
case signature = 0x04
}
var voucheeFingerprintHex: String { voucheeFingerprint.hexEncodedString() }
var timestamp: Date { Date(timeIntervalSince1970: TimeInterval(timestampMs) / 1000) }
/// The exact bytes the voucher signs.
static func signableBytes(
voucheeFingerprint: Data,
voucheeSigningKey: Data,
timestampMs: UInt64
) -> Data {
var message = Data(signingContext.utf8)
message.append(voucheeFingerprint)
message.append(voucheeSigningKey)
var timestampBE = timestampMs.bigEndian
withUnsafeBytes(of: &timestampBE) { message.append(contentsOf: $0) }
return message
}
var signableBytes: Data {
Self.signableBytes(
voucheeFingerprint: voucheeFingerprint,
voucheeSigningKey: voucheeSigningKey,
timestampMs: timestampMs
)
}
/// Builds and signs an attestation. `sign` is the voucher's Ed25519
/// signing primitive (e.g. `Transport.noiseSignData`).
static func build(
voucheeFingerprint: Data,
voucheeSigningKey: Data,
timestampMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000),
sign: (Data) -> Data?
) -> VouchAttestation? {
guard voucheeFingerprint.count == fingerprintSize,
voucheeSigningKey.count == signingKeySize else { return nil }
let message = signableBytes(
voucheeFingerprint: voucheeFingerprint,
voucheeSigningKey: voucheeSigningKey,
timestampMs: timestampMs
)
guard let signature = sign(message), signature.count == signatureSize else { return nil }
return VouchAttestation(
voucheeFingerprint: voucheeFingerprint,
voucheeSigningKey: voucheeSigningKey,
timestampMs: timestampMs,
signature: signature
)
}
/// Verifies the Ed25519 signature against the voucher's announce-bound
/// signing key.
func verifySignature(voucherSigningKey: Data) -> Bool {
guard let publicKey = try? Curve25519.Signing.PublicKey(rawRepresentation: voucherSigningKey) else {
return false
}
return publicKey.isValidSignature(signature, for: signableBytes)
}
/// Whether the attestation is outside its validity window (older than
/// `maxAge`, or timestamped implausibly far in the future).
func isExpired(now: Date = Date()) -> Bool {
let age = now.timeIntervalSince(timestamp)
return age > Self.maxAge || age < -Self.maxClockSkew
}
// MARK: - Encoding
func encode() -> Data? {
guard voucheeFingerprint.count == Self.fingerprintSize,
voucheeSigningKey.count == Self.signingKeySize,
signature.count == Self.signatureSize else { return nil }
var data = Data()
func appendTLV(_ type: TLVType, _ value: Data) {
data.append(type.rawValue)
data.append(UInt8(value.count))
data.append(value)
}
appendTLV(.voucheeFingerprint, voucheeFingerprint)
appendTLV(.voucheeSigningKey, voucheeSigningKey)
var timestampBE = timestampMs.bigEndian
appendTLV(.timestamp, withUnsafeBytes(of: &timestampBE) { Data($0) })
appendTLV(.signature, signature)
return data
}
static func decode(from data: Data) -> VouchAttestation? {
var fingerprint: Data?
var signingKey: Data?
var timestampMs: UInt64?
var signature: Data?
var offset = data.startIndex
while offset < data.endIndex {
guard data.index(offset, offsetBy: 2, limitedBy: data.endIndex) != nil,
offset + 1 < data.endIndex else { return nil }
let type = data[offset]
let length = Int(data[offset + 1])
let valueStart = offset + 2
guard let valueEnd = data.index(valueStart, offsetBy: length, limitedBy: data.endIndex) else {
return nil
}
let value = Data(data[valueStart..<valueEnd])
switch TLVType(rawValue: type) {
case .voucheeFingerprint:
guard value.count == fingerprintSize else { return nil }
fingerprint = value
case .voucheeSigningKey:
guard value.count == signingKeySize else { return nil }
signingKey = value
case .timestamp:
guard value.count == 8 else { return nil }
timestampMs = value.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) }
case .signature:
guard value.count == signatureSize else { return nil }
signature = value
case nil:
break // Unknown TLV: skip for forward compatibility.
}
offset = valueEnd
}
guard let fingerprint, let signingKey, let timestampMs, let signature else { return nil }
return VouchAttestation(
voucheeFingerprint: fingerprint,
voucheeSigningKey: signingKey,
timestampMs: timestampMs,
signature: signature
)
}
// MARK: - Batch encoding
/// Encodes up to `maxBatchCount` attestations into one payload body.
static func encodeList(_ attestations: [VouchAttestation]) -> Data? {
guard !attestations.isEmpty, attestations.count <= maxBatchCount else { return nil }
var data = Data()
data.append(UInt8(attestations.count))
for attestation in attestations {
guard let encoded = attestation.encode(), encoded.count <= Int(UInt16.max) else { return nil }
var lengthBE = UInt16(encoded.count).bigEndian
withUnsafeBytes(of: &lengthBE) { data.append(contentsOf: $0) }
data.append(encoded)
}
return data
}
/// Decodes a batch payload, dropping malformed entries and ignoring
/// anything beyond `maxBatchCount` (sender-declared count is not trusted).
static func decodeList(from data: Data) -> [VouchAttestation] {
guard data.count > 1 else { return [] }
let declaredCount = Int(data[data.startIndex])
let limit = min(declaredCount, maxBatchCount)
var attestations: [VouchAttestation] = []
var offset = data.startIndex + 1
while attestations.count < limit, offset < data.endIndex {
guard let lengthEnd = data.index(offset, offsetBy: 2, limitedBy: data.endIndex) else { break }
let length = Int(data[offset]) << 8 | Int(data[offset + 1])
guard let entryEnd = data.index(lengthEnd, offsetBy: length, limitedBy: data.endIndex) else { break }
if let attestation = decode(from: Data(data[lengthEnd..<entryEnd])) {
attestations.append(attestation)
}
offset = entryEnd
}
return attestations
}
}
+22 -5
View File
@@ -59,6 +59,15 @@ struct BLEAnnounceHandlerEnvironment {
let scheduleAfterglow: (TimeInterval) -> Void let scheduleAfterglow: (TimeInterval) -> Void
} }
/// Outcome of an accepted announce, surfaced so the service can run
/// follow-up work (e.g. courier handover) that keys off the announce.
struct BLEAnnounceHandlingResult {
let peerID: PeerID
let announcement: AnnouncementPacket
let isDirectAnnounce: Bool
let isVerified: Bool
}
/// Orchestrates inbound announce packets: preflight validation, signature /// Orchestrates inbound announce packets: preflight validation, signature
/// trust, registry/topology updates, identity persistence, UI notification, /// trust, registry/topology updates, identity persistence, UI notification,
/// gossip tracking, and the reciprocal announce response. /// gossip tracking, and the reciprocal announce response.
@@ -69,7 +78,8 @@ final class BLEAnnounceHandler {
self.environment = environment self.environment = environment
} }
func handle(_ packet: BitchatPacket, from peerID: PeerID) { @discardableResult
func handle(_ packet: BitchatPacket, from peerID: PeerID) -> BLEAnnounceHandlingResult? {
let env = environment let env = environment
let now = env.now() let now = env.now()
let preflight = BLEAnnouncePreflightPolicy.evaluate( let preflight = BLEAnnouncePreflightPolicy.evaluate(
@@ -85,15 +95,15 @@ final class BLEAnnounceHandler {
announcement = acceptance.announcement announcement = acceptance.announcement
case .reject(.malformed): case .reject(.malformed):
SecureLogger.error("❌ Failed to decode announce packet from \(peerID.id.prefix(8))", category: .session) SecureLogger.error("❌ Failed to decode announce packet from \(peerID.id.prefix(8))", category: .session)
return return nil
case .reject(.senderMismatch(let derivedFromKey)): case .reject(.senderMismatch(let derivedFromKey)):
SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))", category: .security) SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))", category: .security)
return return nil
case .reject(.selfAnnounce): case .reject(.selfAnnounce):
return return nil
case .reject(.stale(let ageSeconds)): case .reject(.stale(let ageSeconds)):
SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session) SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session)
return return nil
} }
// Suppress announce logs to reduce noise // Suppress announce logs to reduce noise
@@ -210,5 +220,12 @@ final class BLEAnnounceHandler {
let delay = Double.random(in: 0.3...0.6) let delay = Double.random(in: 0.3...0.6)
env.scheduleAfterglow(delay) env.scheduleAfterglow(delay)
} }
return BLEAnnounceHandlingResult(
peerID: peerID,
announcement: announcement,
isDirectAnnounce: isDirectAnnounce,
isVerified: verifiedAnnounce
)
} }
} }
+64 -6
View File
@@ -19,13 +19,35 @@ enum BLEFanoutSelector {
packetType: UInt8, packetType: UInt8,
messageID: String messageID: String
) -> BLEFanoutSelection { ) -> BLEFanoutSelection {
let rawAllowed = allowedLinks(
peripheralIDs: peripheralIDs,
centralIDs: centralIDs,
ingressLink: ingressLink,
excludedLinks: excludedLinks
)
if let directedPeerHint,
let directedSelection = directLinks(
to: directedPeerHint,
links: rawAllowed,
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings
) {
return directedSelection
}
if let directedPeerHint,
hasBoundLink(
to: directedPeerHint,
peripheralIDs: peripheralIDs,
centralIDs: centralIDs,
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings
) {
return BLEFanoutSelection(peripheralIDs: [], centralIDs: [])
}
let allowed = collapseDuplicateLinksPerPeer( let allowed = collapseDuplicateLinksPerPeer(
allowedLinks( rawAllowed,
peripheralIDs: peripheralIDs,
centralIDs: centralIDs,
ingressLink: ingressLink,
excludedLinks: excludedLinks
),
peripheralPeerBindings: peripheralPeerBindings, peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings centralPeerBindings: centralPeerBindings
) )
@@ -71,6 +93,42 @@ enum BLEFanoutSelector {
return (allowedPeripheralIDs, allowedCentralIDs) return (allowedPeripheralIDs, allowedCentralIDs)
} }
private static func directLinks(
to peerID: PeerID,
links: (peripheralIDs: [String], centralIDs: [String]),
peripheralPeerBindings: [String: PeerID],
centralPeerBindings: [String: PeerID]
) -> BLEFanoutSelection? {
let directLinks = collapseDuplicateLinksPerPeer(
(
peripheralIDs: links.peripheralIDs.filter { peripheralPeerBindings[$0] == peerID },
centralIDs: links.centralIDs.filter { centralPeerBindings[$0] == peerID }
),
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings
)
guard !directLinks.peripheralIDs.isEmpty || !directLinks.centralIDs.isEmpty else {
return nil
}
return BLEFanoutSelection(
peripheralIDs: Set(directLinks.peripheralIDs),
centralIDs: Set(directLinks.centralIDs)
)
}
private static func hasBoundLink(
to peerID: PeerID,
peripheralIDs: [String],
centralIDs: [String],
peripheralPeerBindings: [String: PeerID],
centralPeerBindings: [String: PeerID]
) -> Bool {
peripheralIDs.contains { peripheralPeerBindings[$0] == peerID }
|| centralIDs.contains { centralPeerBindings[$0] == peerID }
}
// Dual-role pairs hold two live links (we-as-central writing to their // Dual-role pairs hold two live links (we-as-central writing to their
// peripheral, and they-as-central subscribed to ours). Sending the same // peripheral, and they-as-central subscribed to ours). Sending the same
// packet down both doubles airtime for nothing the receiver's assembler // packet down both doubles airtime for nothing the receiver's assembler
@@ -64,6 +64,9 @@ struct BLEFragmentAssemblyBuffer {
let type: UInt8 let type: UInt8
let total: Int let total: Int
let timestamp: Date let timestamp: Date
let isBroadcast: Bool
var lastFragmentAt: Date
var lastResyncRequestAt: Date?
} }
private var fragmentsByKey: [BLEFragmentKey: [Int: Data]] = [:] private var fragmentsByKey: [BLEFragmentKey: [Int: Data]] = [:]
@@ -105,7 +108,15 @@ struct BLEFragmentAssemblyBuffer {
return .oversized(header: header, projectedSize: projectedSize, limit: limit, started: started) return .oversized(header: header, projectedSize: projectedSize, limit: limit, started: started)
} }
// Only actual progress resets the stall clock: fragment packets
// bypass the packet deduplicator, so relayed duplicates of an
// already-held index must not keep suppressing the targeted
// REQUEST_SYNC for a stalled stream.
let isNewIndex = fragmentsByKey[header.key]?[header.index] == nil
fragmentsByKey[header.key]?[header.index] = header.fragmentData fragmentsByKey[header.key]?[header.index] = header.fragmentData
if isNewIndex {
metadataByKey[header.key]?.lastFragmentAt = now
}
guard let fragments = fragmentsByKey[header.key], guard let fragments = fragmentsByKey[header.key],
fragments.count == header.total else { fragments.count == header.total else {
@@ -138,10 +149,59 @@ struct BLEFragmentAssemblyBuffer {
} }
fragmentsByKey[header.key] = [:] fragmentsByKey[header.key] = [:]
metadataByKey[header.key] = Metadata(type: header.originalType, total: header.total, timestamp: now) metadataByKey[header.key] = Metadata(
type: header.originalType,
total: header.total,
timestamp: now,
isBroadcast: header.isBroadcastFragment,
lastFragmentAt: now
)
return true return true
} }
/// Fragment stream IDs (8-byte, big-endian) of incomplete broadcast
/// reassemblies that have not seen a new fragment for `stalledAfter`
/// seconds candidates for a targeted REQUEST_SYNC. Each returned
/// stream is marked so it is not re-requested within `retryAfter`.
/// At most `RequestSyncPacket.maxFragmentIdFilterCount` streams are
/// returned per pass the wire filter cannot carry more selected
/// oldest-stall first; overflow streams stay unmarked and eligible for
/// the next pass. Directed reassemblies are excluded: peers only archive
/// broadcast fragments for gossip sync, so a targeted request cannot
/// recover them.
mutating func stalledBroadcastFragmentIDs(
stalledAfter: TimeInterval,
retryAfter: TimeInterval,
now: Date = Date()
) -> [Data] {
var candidates: [(key: BLEFragmentKey, lastFragmentAt: Date)] = []
for (key, metadata) in metadataByKey {
guard metadata.isBroadcast,
let fragments = fragmentsByKey[key],
fragments.count < metadata.total,
now.timeIntervalSince(metadata.lastFragmentAt) >= stalledAfter else { continue }
if let lastRequest = metadata.lastResyncRequestAt,
now.timeIntervalSince(lastRequest) < retryAfter { continue }
candidates.append((key: key, lastFragmentAt: metadata.lastFragmentAt))
}
// Mark only the streams that will actually go on the wire, so the
// overflow is not silently suppressed for `retryAfter`.
let selected = candidates
.sorted {
if $0.lastFragmentAt != $1.lastFragmentAt {
return $0.lastFragmentAt < $1.lastFragmentAt
}
return ($0.key.sender, $0.key.id) < ($1.key.sender, $1.key.id)
}
.prefix(RequestSyncPacket.maxFragmentIdFilterCount)
return selected.map { candidate in
metadataByKey[candidate.key]?.lastResyncRequestAt = now
return withUnsafeBytes(of: candidate.key.id.bigEndian) { Data($0) }
}
}
private static func assemblyLimit(for originalType: UInt8) -> Int { private static func assemblyLimit(for originalType: UInt8) -> Int {
if originalType == MessageType.fileTransfer.rawValue { if originalType == MessageType.fileTransfer.rawValue {
// Allow headroom for TLV metadata and binary framing overhead. // Allow headroom for TLV metadata and binary framing overhead.
+11 -3
View File
@@ -33,13 +33,21 @@ final class BLEFragmentHandler {
func handle(_ packet: BitchatPacket, from peerID: PeerID) { func handle(_ packet: BitchatPacket, from peerID: PeerID) {
let env = environment let env = environment
// Don't process our own fragments guard let header = BLEFragmentHeader(packet: packet) else { return }
// Sync replay legitimately hands us our own fragments back (the RSR
// ttl=0 restore path): after a relaunch the fragment store starts
// empty, so our sync filter doesn't cover them and peers re-offer
// them. Record them as seen the next round's filter then covers
// them and the redelivery stops but skip assembly: we authored
// the original, there is nothing to reassemble.
if peerID == env.localPeerID() { if peerID == env.localPeerID() {
if header.isBroadcastFragment {
env.trackPacketSeen(packet)
}
return return
} }
guard let header = BLEFragmentHeader(packet: packet) else { return }
if header.isBroadcastFragment { if header.isBroadcastFragment {
env.trackPacketSeen(packet) env.trackPacketSeen(packet)
} }
@@ -99,7 +99,11 @@ struct BLEIngressLinkRegistry {
} }
private static func requiresDirectSenderBinding(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool { private static func requiresDirectSenderBinding(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool {
packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL // REQUEST_SYNC is never relayed, so on a bound link the claimed sender
// must be the link peer it elicits a full store replay, and the
// response is addressed to whoever the sender claims to be.
if packet.type == MessageType.requestSync.rawValue { return true }
return packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL
} }
private static func isSelfAuthoredSyncResponse(_ packet: BitchatPacket) -> Bool { private static func isSelfAuthoredSyncResponse(_ packet: BitchatPacket) -> Bool {
@@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy {
switch MessageType(rawValue: packetType) { switch MessageType(rawValue: packetType) {
case .noiseEncrypted, .noiseHandshake: case .noiseEncrypted, .noiseHandshake:
return true return true
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer: case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage:
return false return false
} }
} }
+16 -3
View File
@@ -9,6 +9,7 @@ struct BLEPeerInfo: Equatable {
var signingPublicKey: Data? var signingPublicKey: Data?
var isVerifiedNickname: Bool var isVerifiedNickname: Bool
var lastSeen: Date var lastSeen: Date
var capabilities: PeerCapabilities = []
} }
struct BLEPeerAnnounceUpdate: Equatable { struct BLEPeerAnnounceUpdate: Equatable {
@@ -107,6 +108,15 @@ struct BLEPeerRegistry {
peers[peerID]?.noisePublicKey?.sha256Fingerprint() peers[peerID]?.noisePublicKey?.sha256Fingerprint()
} }
func capabilities(for peerID: PeerID) -> PeerCapabilities {
peers[peerID.toShort()]?.capabilities ?? []
}
/// Peers whose last verified announce advertised the given capability.
func peers(advertising capability: PeerCapabilities) -> [PeerID] {
peers.values.filter { $0.capabilities.contains(capability) }.map(\.peerID)
}
func displayNicknames(selfNickname: String) -> [PeerID: String] { func displayNicknames(selfNickname: String) -> [PeerID: String] {
let connected = peers.filter { $0.value.isConnected } let connected = peers.filter { $0.value.isConnected }
let tuples = connected.map { ($0.key, $0.value.nickname, true) } let tuples = connected.map { ($0.key, $0.value.nickname, true) }
@@ -125,7 +135,8 @@ struct BLEPeerRegistry {
nickname: resolvedNames[info.peerID] ?? info.nickname, nickname: resolvedNames[info.peerID] ?? info.nickname,
isConnected: info.isConnected, isConnected: info.isConnected,
noisePublicKey: info.noisePublicKey, noisePublicKey: info.noisePublicKey,
lastSeen: info.lastSeen lastSeen: info.lastSeen,
isVerified: info.isVerifiedNickname
) )
} }
} }
@@ -156,7 +167,8 @@ struct BLEPeerRegistry {
noisePublicKey: Data, noisePublicKey: Data,
signingPublicKey: Data?, signingPublicKey: Data?,
isConnected: Bool, isConnected: Bool,
now: Date now: Date,
capabilities: PeerCapabilities = []
) -> BLEPeerAnnounceUpdate { ) -> BLEPeerAnnounceUpdate {
let existing = peers[peerID] let existing = peers[peerID]
let update = BLEPeerAnnounceUpdate( let update = BLEPeerAnnounceUpdate(
@@ -172,7 +184,8 @@ struct BLEPeerRegistry {
noisePublicKey: noisePublicKey, noisePublicKey: noisePublicKey,
signingPublicKey: signingPublicKey, signingPublicKey: signingPublicKey,
isVerifiedNickname: true, isVerifiedNickname: true,
lastSeen: now lastSeen: now,
capabilities: capabilities
) )
return update return update
@@ -27,8 +27,15 @@ enum BLEPublicMessagePolicy {
} }
let isBroadcast = BLEPacketFreshnessPolicy.isBroadcastRecipient(packet.recipientID) let isBroadcast = BLEPacketFreshnessPolicy.isBroadcastRecipient(packet.recipientID)
// Acceptance window matches the gossip-sync serving window: a peer
// walking between partitions carries hours of public history, so the
// receive side must not drop what sync legitimately serves.
if isBroadcast, if isBroadcast,
BLEPacketFreshnessPolicy.isStale(timestampMilliseconds: packet.timestamp, now: now) { BLEPacketFreshnessPolicy.isStale(
timestampMilliseconds: packet.timestamp,
now: now,
maxAgeSeconds: TransportConfig.syncPublicMessageMaxAgeSeconds
) {
return .reject(.staleBroadcast(ageSeconds: BLEPacketFreshnessPolicy.ageSeconds( return .reject(.staleBroadcast(ageSeconds: BLEPacketFreshnessPolicy.ageSeconds(
timestampMilliseconds: packet.timestamp, timestampMilliseconds: packet.timestamp,
now: now now: now
+18 -1
View File
@@ -48,11 +48,28 @@ struct BLEReceivePipeline {
senderIsSelf: senderID == localPeerID, senderIsSelf: senderID == localPeerID,
recipientIsSelf: PeerID(hexData: packet.recipientID) == localPeerID, recipientIsSelf: PeerID(hexData: packet.recipientID) == localPeerID,
isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue, isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue,
isDirectedEncrypted: packet.type == MessageType.noiseEncrypted.rawValue && packet.recipientID != nil, // Courier envelopes are directed opaque ciphertext like DMs; a
// remote handover toward a relayed announce rides this same
// deterministic relay treatment instead of the broadcast clamp.
// Ping/pong diagnostics ride it too: probes need the same
// deterministic multi-hop relay as DMs (always relay, jitter,
// no TTL cap) so RTT and hop counts reflect the real path.
// Directed nostrCarrier uplinks (mesh-only peer -> gateway) need
// the same multi-hop treatment to reach a non-adjacent gateway.
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue
|| packet.type == MessageType.courierEnvelope.rawValue
|| packet.type == MessageType.ping.rawValue
|| packet.type == MessageType.pong.rawValue
|| packet.type == MessageType.nostrCarrier.rawValue) && packet.recipientID != nil,
isFragment: packet.type == MessageType.fragment.rawValue, isFragment: packet.type == MessageType.fragment.rawValue,
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil, isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
isHandshake: packet.type == MessageType.noiseHandshake.rawValue, isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
isAnnounce: packet.type == MessageType.announce.rawValue, isAnnounce: packet.type == MessageType.announce.rawValue,
isRequestSync: packet.type == MessageType.requestSync.rawValue,
// Board posts relay like broadcast messages; urgent ones get the
// announce-class TTL headroom so alerts travel the extra hop.
isUrgentBoardPost: packet.type == MessageType.boardPost.rawValue
&& BoardWire.urgentFlag(in: packet.payload),
degree: degree, degree: degree,
highDegreeThreshold: highDegreeThreshold highDegreeThreshold: highDegreeThreshold
) )
@@ -0,0 +1,53 @@
import Foundation
/// Remembers recently seen bitchat peripherals (fresh discoveries and dropped
/// links) so the service can arm pending background connections against them
/// when the app leaves the foreground. Generic over the peripheral type so
/// the eviction/expiry logic is testable without CoreBluetooth.
final class BLERecentPeripheralCache<Peripheral> {
private struct Entry {
let peripheral: Peripheral
var lastSeen: Date
}
private var entries: [String: Entry] = [:]
private let capacity: Int
private let maxAge: TimeInterval
init(
capacity: Int = TransportConfig.bleRecentPeripheralCacheCap,
maxAge: TimeInterval = TransportConfig.bleRecentPeripheralMaxAgeSeconds
) {
self.capacity = capacity
self.maxAge = maxAge
}
var count: Int { entries.count }
func record(_ peripheral: Peripheral, peripheralID: String, at now: Date) {
entries[peripheralID] = Entry(peripheral: peripheral, lastSeen: now)
guard entries.count > capacity else { return }
// Inserts overshoot capacity by at most one; evict the stalest entry
if let stalest = entries.min(by: { $0.value.lastSeen < $1.value.lastSeen }) {
entries.removeValue(forKey: stalest.key)
}
}
/// Most-recently-seen peripherals eligible for a pending background
/// connect, freshest first, capped at `limit`. Expired entries are
/// pruned as a side effect.
func reconnectTargets(
now: Date,
limit: Int,
excluding: (String) -> Bool
) -> [(peripheralID: String, peripheral: Peripheral)] {
let cutoff = now.addingTimeInterval(-maxAge)
entries = entries.filter { $0.value.lastSeen >= cutoff }
guard limit > 0 else { return [] }
return entries
.filter { !excluding($0.key) }
.sorted { $0.value.lastSeen > $1.value.lastSeen }
.prefix(limit)
.map { (peripheralID: $0.key, peripheral: $0.value.peripheral) }
}
}
@@ -35,6 +35,14 @@ struct BLERouteForwardingPolicy {
routingPeer: (Data) -> PeerID?, routingPeer: (Data) -> PeerID?,
isPeerConnected: (PeerID) -> Bool isPeerConnected: (PeerID) -> Bool
) -> BLERouteForwardingPlan { ) -> BLERouteForwardingPlan {
// REQUEST_SYNC is link-local: never forward it, on the flood path or
// the source-routed path. A crafted request with a route and TTL
// headroom must not be able to fan a full-store replay out to the next
// hop. Suppressing here also short-circuits the flood relay.
if packet.type == MessageType.requestSync.rawValue {
return .suppressFloodRelay
}
if PeerID(hexData: packet.recipientID) == localPeerID { if PeerID(hexData: packet.recipientID) == localPeerID {
return .suppressFloodRelay return .suppressFloodRelay
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,95 @@
import BitFoundation
import Foundation
/// Tracks whether source-routed sends to a recipient appear to be working.
///
/// A routed unicast rides exactly one path, so a broken hop silently loses the
/// packet where a flood would have healed around it. Rather than building a
/// retransmission machine (MessageRouter already retries at a higher layer),
/// this cache degrades: a routed send that sees no inbound traffic from the
/// recipient within the confirmation window marks the route as failed, and
/// subsequent sends fall back to flooding until the suppression TTL lapses.
struct BLESourceRouteFailureCache {
struct Config {
/// How long a routed send may go unconfirmed before it counts as a
/// route failure.
var confirmationWindowSeconds: TimeInterval = TransportConfig.bleSourceRouteConfirmationWindowSeconds
/// How long to flood instead of routing after a failure.
var suppressionSeconds: TimeInterval = TransportConfig.bleSourceRouteSuppressionSeconds
}
private struct State {
var pendingSince: Date?
var suppressedUntil: Date?
}
private let config: Config
private var states: [PeerID: State] = [:]
init(config: Config = Config()) {
self.config = config
}
/// Whether the next directed send to `recipient` may carry a source
/// route. Flips the recipient into suppression when the last routed send
/// went unconfirmed past the confirmation window.
mutating func shouldAttemptRoute(to recipient: PeerID, now: Date = Date()) -> Bool {
guard var state = states[recipient] else { return true }
if let until = state.suppressedUntil {
guard now >= until else { return false }
state.suppressedUntil = nil
}
if let pending = state.pendingSince,
now.timeIntervalSince(pending) > config.confirmationWindowSeconds {
// The routed send was never confirmed: treat the route as broken
// and flood until the suppression window lapses.
state.pendingSince = nil
state.suppressedUntil = now.addingTimeInterval(config.suppressionSeconds)
states[recipient] = state
return false
}
states[recipient] = state
return true
}
/// Records that a source-routed packet was sent to `recipient`. Keeps the
/// earliest unconfirmed send so back-to-back packets share one deadline.
mutating func noteRoutedSend(to recipient: PeerID, now: Date = Date()) {
var state = states[recipient] ?? State()
if state.pendingSince == nil {
state.pendingSince = now
}
states[recipient] = state
}
/// Any inbound packet authored by `peer` confirms the pending routed send
/// (delivery acks and replies arrive this way). Deliberately does not
/// lift an active suppression: that traffic may have arrived via flood.
mutating func noteInboundActivity(from peer: PeerID) {
guard var state = states[peer] else { return }
state.pendingSince = nil
if state.suppressedUntil == nil {
states.removeValue(forKey: peer)
} else {
states[peer] = state
}
}
/// Drops entries that can no longer influence a routing decision. An
/// expired-but-unconverted pending entry is kept for as long as the
/// suppression it would trigger could still be active.
mutating func prune(now: Date = Date()) {
let pendingRetention = config.confirmationWindowSeconds + config.suppressionSeconds
states = states.filter { _, state in
if let until = state.suppressedUntil, now < until { return true }
if let pending = state.pendingSince,
now.timeIntervalSince(pending) <= pendingRetention {
return true
}
return false
}
}
}
@@ -0,0 +1,40 @@
import BitFoundation
import Foundation
/// Decides whether an outbound directed packet should carry a v2 source
/// route. Pure gating logic so BLEService's hot send path stays a thin wire.
enum BLESourceRouteOriginationPolicy {
/// Returns the intermediate-hop route to attach, or nil to keep the
/// current flood/direct-write behavior unchanged.
///
/// Routes are only originated when every gate passes:
/// - we authored the packet (relays must not rewrite and re-sign someone
/// else's packet; route-following for in-flight routed packets lives in
/// `BLERouteForwardingPolicy`),
/// - the packet is directed at a single peer (not broadcast),
/// - the packet has TTL headroom to traverse hops (link-local TTL-0
/// packets like REQUEST_SYNC never route),
/// - the recipient is not directly connected (a direct write already
/// delivers in one hop),
/// - routing to the recipient is not suppressed by a recent unconfirmed
/// routed send, and
/// - the topology yields a complete path.
static func route(
for packet: BitchatPacket,
to recipient: PeerID,
localPeerIDData: Data,
isRecipientConnected: (PeerID) -> Bool,
shouldAttemptRoute: (PeerID) -> Bool,
computeRoute: (PeerID) -> [Data]?
) -> [Data]? {
guard packet.senderID == localPeerIDData else { return nil }
guard let recipientData = packet.recipientID,
recipientData.count == 8,
!recipientData.allSatisfy({ $0 == 0xFF }) else { return nil }
guard packet.ttl > 1 else { return nil }
guard !isRecipientConnected(recipient) else { return nil }
guard shouldAttemptRoute(recipient) else { return nil }
guard let route = computeRoute(recipient), !route.isEmpty else { return nil }
return route
}
}
@@ -0,0 +1,160 @@
//
// BoardAlertsModel.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Combine
import Foundation
/// Turns newly arriving board posts into local, scope-matched chat alerts.
/// Everything here is derived from posts the mesh already synced no extra
/// wire traffic, nothing another peer can't already see.
///
/// - Urgent, recent pins get one system line in the matching chat (geo pin
/// that geohash's timeline, mesh pin mesh chat), collapsed when several
/// arrive together.
/// - Every other new pin just marks the header's pin icon until the notices
/// sheet is opened.
@MainActor
final class BoardAlertsModel: ObservableObject {
struct Dependencies {
/// Own posts never alert; the author already knows.
var isOwnPost: @MainActor (BoardPostPacket) -> Bool
/// Appends a local system line to a scope's chat timeline
/// (geohash, or "" for mesh chat).
var emitSystemLine: @MainActor (_ content: String, _ geohash: String) -> Void
var now: () -> Date = Date.init
/// Schedules the collapsed flush of pending urgent alerts; tests
/// inject a synchronous hook.
var scheduleFlush: (_ flush: @escaping @MainActor () -> Void) -> Void = { flush in
Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(BoardAlertsModel.collapseDelaySeconds * 1_000_000_000))
flush()
}
}
}
/// Posts older than this at arrival are backfilled history carried in by
/// a peer, not something happening now; they badge but never line the chat.
static let inlineRecencyWindow: TimeInterval = 30 * 60
/// Urgent arrivals within this window collapse into one line.
static let collapseDelaySeconds: TimeInterval = 4
private static let alertContentMaxChars = 120
/// Unseen new pins by postID (hex) geohash scope, cleared when the
/// notices sheet opens.
@Published private(set) var unseenPostScopes: [String: String] = [:]
/// PostIDs already handled this session, so store eviction/re-sync churn
/// can't re-alert. Bounded by session wire volume (32-byte strings).
private var handledPostIDs = Set<String>()
private var pendingUrgent: [String: [BoardPostPacket]] = [:]
private var flushScheduled = false
private let dependencies: Dependencies
private var cancellable: AnyCancellable?
private var wipeCancellable: AnyCancellable?
private enum Strings {
static func urgentSingle(author: String, content: String) -> String {
String(
format: String(localized: "notices.alert.urgent_single", defaultValue: "📌 urgent notice from @%@: %@", comment: "Local chat line when one urgent notice is pinned nearby"),
locale: .current,
author, content
)
}
static func urgentCollapsed(_ count: Int) -> String {
String(
format: String(localized: "notices.alert.urgent_collapsed", defaultValue: "📌 %lld new urgent notices — tap the pin to view", comment: "Local chat line when several urgent notices arrive together"),
locale: .current,
count
)
}
}
init(
arrivals: AnyPublisher<BoardPostPacket, Never>,
wipes: AnyPublisher<Void, Never> = Empty(completeImmediately: false).eraseToAnyPublisher(),
dependencies: Dependencies
) {
self.dependencies = dependencies
cancellable = arrivals
.receive(on: DispatchQueue.main)
.sink { [weak self] post in
self?.handleArrival(post)
}
wipeCancellable = wipes
.receive(on: DispatchQueue.main)
.sink { [weak self] in
self?.reset()
}
}
func unseenCount(forGeohash geohash: String) -> Int {
unseenPostScopes.values.reduce(0) { $0 + ($1 == geohash ? 1 : 0) }
}
/// Marks pins in the given scopes as seen only the scopes the notices
/// sheet actually shows, so unseen pins for other geohash channels keep
/// their badge until visited.
func markSeen(forScopes scopes: Set<String>) {
guard unseenPostScopes.contains(where: { scopes.contains($0.value) }) else { return }
unseenPostScopes = unseenPostScopes.filter { !scopes.contains($0.value) }
}
/// Panic wipe: drop everything derived from pre-wipe posts, including
/// urgent lines still waiting on the collapse flush.
func reset() {
pendingUrgent.removeAll()
handledPostIDs.removeAll()
guard !unseenPostScopes.isEmpty else { return }
unseenPostScopes.removeAll()
}
func handleArrival(_ post: BoardPostPacket) {
let postID = post.postID.hexEncodedString()
guard !handledPostIDs.contains(postID) else { return }
handledPostIDs.insert(postID)
guard !dependencies.isOwnPost(post) else { return }
unseenPostScopes[postID] = post.geohash
let createdAt = Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000)
guard post.isUrgent,
dependencies.now().timeIntervalSince(createdAt) <= Self.inlineRecencyWindow else {
return
}
pendingUrgent[post.geohash, default: []].append(post)
if !flushScheduled {
flushScheduled = true
dependencies.scheduleFlush { [weak self] in
self?.flushPendingUrgent()
}
}
}
private func flushPendingUrgent() {
flushScheduled = false
let pending = pendingUrgent
pendingUrgent.removeAll()
for (geohash, posts) in pending {
guard let first = posts.first else { continue }
let line: String
if posts.count == 1 {
let author = first.authorNickname.trimmedOrNilIfEmpty ?? "anon"
line = Strings.urgentSingle(author: author, content: Self.truncated(first.content))
} else {
line = Strings.urgentCollapsed(posts.count)
}
dependencies.emitSystemLine(line, geohash)
}
}
private static func truncated(_ content: String) -> String {
guard content.count > alertContentMaxChars else { return content }
return content.prefix(alertContentMaxChars) + ""
}
}
+197
View File
@@ -0,0 +1,197 @@
//
// BoardManager.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import Combine
import Foundation
/// UI-facing coordinator for the bulletin board: builds and signs posts and
/// tombstones with the device's Noise signing key, hands them to the mesh
/// transport, and mirrors the store's live posts for SwiftUI.
@MainActor
final class BoardManager: ObservableObject {
/// Live posts across all boards, newest state from the store.
@Published private(set) var posts: [BoardPostPacket] = []
private let transport: Transport
private let store: BoardStore
/// Publishes a bridged kind-1 note (expiring with the board post via
/// NIP-40) and returns its Nostr event id, or nil when bridging failed or
/// was skipped.
private let publishToNostr: (_ content: String, _ geohash: String, _ nickname: String, _ expiresAtMs: UInt64) -> String?
/// Requests NIP-09 deletion of a previously bridged note.
private let deleteFromNostr: (_ eventID: String, _ geohash: String) -> Void
/// Bridged Nostr event ids by postID, for merged deletes. In-memory only:
/// after a relaunch a delete still tombstones the board copy, but the
/// Nostr copy is left to expire with relay retention.
private var bridgedEventIDs: [Data: String] = [:]
private var cancellable: AnyCancellable?
init(
transport: Transport,
store: BoardStore = .shared,
publishToNostr: ((String, String, String, UInt64) -> String?)? = nil,
deleteFromNostr: ((String, String) -> Void)? = nil
) {
self.transport = transport
self.store = store
self.publishToNostr = publishToNostr ?? Self.livePublishToNostr
self.deleteFromNostr = deleteFromNostr ?? Self.liveDeleteFromNostr
cancellable = store.$postsSnapshot
.receive(on: DispatchQueue.main)
.sink { [weak self] snapshot in
self?.posts = snapshot
}
}
/// Posts for one board context, urgent first, then newest first.
func posts(forGeohash geohash: String) -> [BoardPostPacket] {
posts
.filter { $0.geohash == geohash }
.sorted {
if $0.isUrgent != $1.isUrgent { return $0.isUrgent }
return $0.createdAt > $1.createdAt
}
}
func isOwnPost(_ post: BoardPostPacket) -> Bool {
let key = transport.noiseSigningPublicKeyData()
return !key.isEmpty && key == post.authorSigningKey
}
/// Creates, signs, and broadcasts a board post. Returns false when the
/// content is empty/oversized or signing fails.
@discardableResult
func createPost(
content: String,
geohash: String,
urgent: Bool,
expiryDays: Int,
nickname: String
) -> Bool {
guard let trimmed = content.trimmedOrNilIfEmpty,
trimmed.utf8.count <= BoardWireConstants.contentMaxBytes else {
return false
}
let signingKey = transport.noiseSigningPublicKeyData()
guard signingKey.count == BoardWireConstants.signingKeyLength else { return false }
var cleanNickname = nickname
while cleanNickname.utf8.count > BoardWireConstants.nicknameMaxBytes {
cleanNickname.removeLast()
}
let createdAt = UInt64(Date().timeIntervalSince1970 * 1000)
let lifetimeMs = min(
UInt64(max(1, expiryDays)) * 24 * 60 * 60 * 1000,
BoardWireConstants.maxLifetimeMs
)
let expiresAt = createdAt + lifetimeMs
let flags: UInt8 = urgent ? BoardPostPacket.urgentFlag : 0
var postID = Data(count: BoardWireConstants.postIDLength)
let status = postID.withUnsafeMutableBytes { buffer -> Int32 in
guard let base = buffer.baseAddress else { return -1 }
return SecRandomCopyBytes(kSecRandomDefault, buffer.count, base)
}
guard status == errSecSuccess else { return false }
let signingBytes = BoardPostPacket.signingBytes(
postID: postID,
geohash: geohash,
content: trimmed,
authorSigningKey: signingKey,
authorNickname: cleanNickname,
createdAt: createdAt,
expiresAt: expiresAt,
flags: flags
)
guard let signature = transport.noiseSignData(signingBytes) else {
SecureLogger.error("Board: failed to sign post", category: .session)
return false
}
let post = BoardPostPacket(
postID: postID,
geohash: geohash,
content: trimmed,
authorSigningKey: signingKey,
authorNickname: cleanNickname,
createdAt: createdAt,
expiresAt: expiresAt,
flags: flags,
signature: signature
)
transport.sendBoardPayload(BoardWire.post(post).encode())
// Nostr bridge: geohash posts also go out as kind-1 location notes so
// online users see them. Remember the event id for merged deletes.
if !geohash.isEmpty, let eventID = publishToNostr(trimmed, geohash, cleanNickname, expiresAt) {
bridgedEventIDs[postID] = eventID
}
return true
}
/// Signs and broadcasts a tombstone for one of our own posts.
@discardableResult
func deletePost(_ post: BoardPostPacket) -> Bool {
guard isOwnPost(post) else { return false }
let deletedAt = UInt64(Date().timeIntervalSince1970 * 1000)
let signingBytes = BoardTombstonePacket.signingBytes(postID: post.postID, deletedAt: deletedAt)
guard let signature = transport.noiseSignData(signingBytes) else {
SecureLogger.error("Board: failed to sign tombstone", category: .session)
return false
}
let tombstone = BoardTombstonePacket(
postID: post.postID,
authorSigningKey: post.authorSigningKey,
deletedAt: deletedAt,
signature: signature
)
transport.sendBoardPayload(BoardWire.tombstone(tombstone).encode())
// Merged delete: also retract the bridged Nostr copy when we still
// know its event id.
if !post.geohash.isEmpty, let eventID = bridgedEventIDs.removeValue(forKey: post.postID) {
deleteFromNostr(eventID, post.geohash)
}
return true
}
private static func livePublishToNostr(content: String, geohash: String, nickname: String, expiresAtMs: UInt64) -> String? {
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else {
SecureLogger.debug("Board: no geo relays for \(geohash); skipping Nostr bridge", category: .session)
return nil
}
do {
let identity = try NostrIdentityBridge().deriveIdentity(forGeohash: geohash)
let event = try NostrProtocol.createGeohashTextNote(
content: content,
geohash: geohash,
senderIdentity: identity,
nickname: nickname,
expiresAt: Date(timeIntervalSince1970: TimeInterval(expiresAtMs) / 1000)
)
NostrRelayManager.shared.sendEvent(event, to: relays)
return event.id
} catch {
SecureLogger.error("Board: failed to bridge post to Nostr: \(error)", category: .session)
return nil
}
}
private static func liveDeleteFromNostr(eventID: String, geohash: String) {
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else { return }
do {
let identity = try NostrIdentityBridge().deriveIdentity(forGeohash: geohash)
let deletion = try NostrProtocol.createDeleteEvent(ofEventID: eventID, senderIdentity: identity)
NostrRelayManager.shared.sendEvent(deletion, to: relays)
} catch {
SecureLogger.error("Board: failed to delete bridged Nostr note: \(error)", category: .session)
}
}
}
+376
View File
@@ -0,0 +1,376 @@
//
// BoardStore.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 Combine
import Foundation
/// Outcome of feeding a board packet into the store, so the transport can
/// decide whether the packet is still worth relaying.
enum BoardIngestResult {
/// New post or tombstone accepted (or a quota rejected it locally while
/// it remains valid for other devices).
case accepted
/// Already known; nothing changed.
case duplicate
/// Invalid, expired, or deleted; do not relay.
case rejected
}
/// Persistent storage for bulletin-board posts and their tombstones.
///
/// Posts are signed public notices designed to outlive chat: they stay on
/// disk until their author-chosen expiry (max 7 days) and re-enter gossip
/// sync after a restart. Tombstones are retained until the deleted post's
/// original expiry so the delete keeps outrunning stale copies of the post.
///
/// The on-disk format is the raw signed packets themselves (like
/// `GossipMessageArchive`); state is rebuilt by re-verifying and re-ingesting
/// them on launch. Wiped on panic.
final class BoardStore {
enum Limits {
static let maxPosts = 200
static let maxPostsPerAuthor = 5
/// Retention for a tombstone whose post we never saw: we cannot know
/// the original expiry, so cap at the max post lifetime.
static let orphanTombstoneLifetimeMs = BoardWireConstants.maxLifetimeMs
/// Orphan tombstones name posts nobody here has seen, so their volume
/// is entirely sender-controlled; cap them like posts.
static let maxOrphanTombstones = 100
static let maxOrphanTombstonesPerAuthor = 5
/// Allowance for clock skew between peers when judging received
/// timestamps against local time.
static let clockSkewMs: UInt64 = 60 * 60 * 1000
}
private struct StoredPost {
let post: BoardPostPacket
let packet: BitchatPacket
let rawPacket: Data
}
private struct StoredTombstone {
let tombstone: BoardTombstonePacket
let packet: BitchatPacket
let rawPacket: Data
let retainUntil: UInt64
/// True when no matching post was known at ingest time; only these
/// count against the orphan caps.
let isOrphan: Bool
}
/// On-disk entry: the raw signed packet, plus the retention deadline for
/// tombstones (derived from the deleted post's original expiry, which is
/// no longer recoverable once the post is gone).
private struct PersistedEntry: Codable {
let packet: Data
let retainUntil: UInt64?
}
static let shared = BoardStore()
/// Live posts, published on the main thread for the board UI.
@Published private(set) var postsSnapshot: [BoardPostPacket] = []
/// Fires on the main thread for each post newly accepted from the wire
/// (radio, sync, or local echo) not for disk restores. Drives the
/// local new-pin chat alerts; duplicates never fire twice because the
/// store rejects them.
let postArrivals = PassthroughSubject<BoardPostPacket, Never>()
/// Fires on the main thread after a panic wipe so derived state (pending
/// alerts, unseen badges) is dropped along with the posts themselves.
let didWipe = PassthroughSubject<Void, Never>()
private var posts: [StoredPost] = []
private var tombstones: [StoredTombstone] = []
private let queue = DispatchQueue(label: "chat.bitchat.board.store")
private let fileURL: URL?
private let now: () -> Date
/// - Parameter fileURL: Overrides the on-disk location (tests). Ignored
/// when `persistsToDisk` is false.
init(persistsToDisk: Bool = true, fileURL: URL? = nil, now: @escaping () -> Date = Date.init) {
self.now = now
self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
loadFromDisk()
}
// MARK: - Ingest
/// Ingest a board packet whose payload decodes to `wire`. The caller must
/// have verified the wire signature already (`BoardWire.verifySignature`).
@discardableResult
func ingest(_ wire: BoardWire, packet: BitchatPacket) -> BoardIngestResult {
guard let rawPacket = packet.toBinaryData(padding: false) else { return .rejected }
let nowMs = currentMs()
return queue.sync {
let result = ingestLocked(wire, packet: packet, rawPacket: rawPacket, nowMs: nowMs)
if result == .accepted {
persistLocked()
if case .post(let post) = wire {
DispatchQueue.main.async { [weak self] in
self?.postArrivals.send(post)
}
}
}
return result
}
}
// MARK: - Reads
/// Live posts scoped to one board (geohash, or "" for the mesh board).
func posts(forGeohash geohash: String) -> [BoardPostPacket] {
let nowMs = currentMs()
return queue.sync {
pruneExpiredLocked(nowMs: nowMs)
return posts.map(\.post).filter { $0.geohash == geohash }
}
}
/// Raw signed packets (posts and live tombstones) for gossip sync rounds.
func syncCandidates() -> [BitchatPacket] {
let nowMs = currentMs()
return queue.sync {
pruneExpiredLocked(nowMs: nowMs)
return posts.map(\.packet) + tombstones.map(\.packet)
}
}
// MARK: - Maintenance
func pruneExpired() {
let nowMs = currentMs()
queue.sync {
pruneExpiredLocked(nowMs: nowMs)
persistLocked()
}
}
/// Panic wipe: drop all board data from memory and disk.
func wipe() {
queue.sync {
posts.removeAll()
tombstones.removeAll()
if let fileURL {
try? FileManager.default.removeItem(at: fileURL)
}
publishSnapshotLocked()
}
DispatchQueue.main.async { [weak self] in
self?.didWipe.send()
}
}
// MARK: - Internals (call only on `queue`)
private func ingestLocked(
_ wire: BoardWire,
packet: BitchatPacket,
rawPacket: Data,
nowMs: UInt64,
retainUntilOverride: UInt64? = nil
) -> BoardIngestResult {
pruneExpiredLocked(nowMs: nowMs)
switch wire {
case .post(let post):
return ingestPostLocked(post, packet: packet, rawPacket: rawPacket, nowMs: nowMs)
case .tombstone(let tombstone):
return ingestTombstoneLocked(tombstone, packet: packet, rawPacket: rawPacket, nowMs: nowMs, retainUntilOverride: retainUntilOverride)
}
}
private func ingestPostLocked(_ post: BoardPostPacket, packet: BitchatPacket, rawPacket: Data, nowMs: UInt64) -> BoardIngestResult {
guard post.expiresAt > nowMs else { return .rejected }
// Receive-time sanity (this is the single chokepoint for radio, sync,
// and disk restores): the decoder only enforces the createdAt to
// expiresAt span, so a forged future createdAt would sort ahead of
// honest posts and hold a store slot without ever pruning.
guard post.createdAt <= nowMs &+ Limits.clockSkewMs,
post.expiresAt <= nowMs &+ BoardWireConstants.maxLifetimeMs &+ Limits.clockSkewMs else {
return .rejected
}
if tombstones.contains(where: { $0.tombstone.postID == post.postID && $0.tombstone.authorSigningKey == post.authorSigningKey }) {
return .rejected
}
guard !posts.contains(where: { $0.post.postID == post.postID }) else { return .duplicate }
posts.append(StoredPost(post: post, packet: packet, rawPacket: rawPacket))
// Per-author cap, then global cap; oldest posts are evicted first.
let authorPosts = posts.filter { $0.post.authorSigningKey == post.authorSigningKey }
if authorPosts.count > Limits.maxPostsPerAuthor {
evictOldestLocked(from: authorPosts, keep: Limits.maxPostsPerAuthor)
}
if posts.count > Limits.maxPosts {
evictOldestLocked(from: posts, keep: Limits.maxPosts)
}
publishSnapshotLocked()
// Even when the new post itself was the eviction victim it stays
// valid mesh-wide; peers with room should still receive it.
return .accepted
}
private func ingestTombstoneLocked(
_ tombstone: BoardTombstonePacket,
packet: BitchatPacket,
rawPacket: Data,
nowMs: UInt64,
retainUntilOverride: UInt64? = nil
) -> BoardIngestResult {
guard !tombstones.contains(where: { $0.tombstone.postID == tombstone.postID }) else { return .duplicate }
// Cap retention by both the claimed deletion time (so a doctored file
// cannot pin a tombstone past any legal expiry) and the receive time:
// deletedAt is sender-chosen, so a far-future value must not retain
// the tombstone longer than any post still able to arrive could live.
let maxRetain = min(
tombstone.deletedAt &+ Limits.orphanTombstoneLifetimeMs,
nowMs &+ Limits.orphanTombstoneLifetimeMs &+ Limits.clockSkewMs
)
let retainUntil: UInt64
let isOrphan: Bool
if let index = posts.firstIndex(where: { $0.post.postID == tombstone.postID }) {
let target = posts[index].post
// Only the author's key can delete: the tombstone signature was
// already verified against its embedded key, so it suffices to
// require that key to be the post's author key.
guard target.authorSigningKey == tombstone.authorSigningKey else { return .rejected }
retainUntil = target.expiresAt
isOrphan = false
posts.remove(at: index)
publishSnapshotLocked()
} else if let retainUntilOverride {
// Restored from disk: the post is long gone, so trust the
// retention deadline recorded when the delete was first applied.
// Orphans were already capped when first ingested off the air.
retainUntil = min(retainUntilOverride, maxRetain)
isOrphan = false
} else {
// Post unknown (tombstone raced ahead); keep it around so the
// post is suppressed if it arrives later.
retainUntil = maxRetain
isOrphan = true
}
guard retainUntil > nowMs else { return .rejected }
tombstones.append(StoredTombstone(tombstone: tombstone, packet: packet, rawPacket: rawPacket, retainUntil: retainUntil, isOrphan: isOrphan))
if isOrphan {
enforceOrphanTombstoneCapsLocked(author: tombstone.authorSigningKey)
}
// Like posts, a locally evicted tombstone stays valid mesh-wide.
return .accepted
}
/// Orphan tombstones reference posts we never saw, so a peer can mint
/// unlimited valid ones for random IDs; bound them per author and
/// globally, evicting the oldest received first (array order).
private func enforceOrphanTombstoneCapsLocked(author: Data) {
let authorOrphans = tombstones.filter { $0.isOrphan && $0.tombstone.authorSigningKey == author }
if authorOrphans.count > Limits.maxOrphanTombstonesPerAuthor {
removeTombstonesLocked(authorOrphans.prefix(authorOrphans.count - Limits.maxOrphanTombstonesPerAuthor))
}
let orphans = tombstones.filter(\.isOrphan)
if orphans.count > Limits.maxOrphanTombstones {
removeTombstonesLocked(orphans.prefix(orphans.count - Limits.maxOrphanTombstones))
}
}
private func removeTombstonesLocked(_ victims: ArraySlice<StoredTombstone>) {
guard !victims.isEmpty else { return }
let victimIDs = Set(victims.map { $0.tombstone.postID })
tombstones.removeAll { victimIDs.contains($0.tombstone.postID) }
}
private func evictOldestLocked(from candidates: [StoredPost], keep: Int) {
let victims = candidates
.sorted { $0.post.createdAt < $1.post.createdAt }
.prefix(max(0, candidates.count - keep))
guard !victims.isEmpty else { return }
let victimIDs = Set(victims.map { $0.post.postID })
posts.removeAll { victimIDs.contains($0.post.postID) }
}
private func pruneExpiredLocked(nowMs: UInt64) {
let postsBefore = posts.count
posts.removeAll { $0.post.expiresAt <= nowMs }
tombstones.removeAll { $0.retainUntil <= nowMs }
if posts.count != postsBefore {
publishSnapshotLocked()
}
}
private func publishSnapshotLocked() {
let snapshot = posts.map(\.post)
DispatchQueue.main.async { [weak self] in
self?.postsSnapshot = snapshot
}
}
private func currentMs() -> UInt64 {
UInt64(max(0, now().timeIntervalSince1970) * 1000)
}
// MARK: - Persistence
private func persistLocked() {
guard let fileURL else { return }
let payloads = posts.map { PersistedEntry(packet: $0.rawPacket, retainUntil: nil) }
+ tombstones.map { PersistedEntry(packet: $0.rawPacket, retainUntil: $0.retainUntil) }
do {
if payloads.isEmpty {
try? FileManager.default.removeItem(at: fileURL)
return
}
try FileManager.default.createDirectory(
at: fileURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
let data = try JSONEncoder().encode(payloads)
var options: Data.WritingOptions = [.atomic]
#if os(iOS)
options.insert(.completeFileProtection)
#endif
try data.write(to: fileURL, options: options)
} catch {
SecureLogger.error("Failed to persist board store: \(error)", category: .session)
}
}
private func loadFromDisk() {
guard let fileURL,
let data = try? Data(contentsOf: fileURL),
let payloads = try? JSONDecoder().decode([PersistedEntry].self, from: data) else {
return
}
let nowMs = currentMs()
queue.sync {
for entry in payloads {
guard let packet = BitchatPacket.from(entry.packet),
packet.type == MessageType.boardPost.rawValue,
let wire = BoardWire.decode(from: packet.payload),
wire.verifySignature() else { continue }
_ = ingestLocked(wire, packet: packet, rawPacket: entry.packet, nowMs: nowMs, retainUntilOverride: entry.retainUntil)
}
publishSnapshotLocked()
}
}
private static func defaultFileURL() -> URL? {
guard let base = try? FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
) else { return nil }
return base
.appendingPathComponent("board", isDirectory: true)
.appendingPathComponent("posts.json")
}
}
@@ -0,0 +1,88 @@
//
// UnifiedNotices.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
/// One row in the unified notices sheet: a mesh board post or a Nostr
/// location note, normalized for display.
struct NoticeItem: Identifiable, Equatable {
enum Source: Equatable {
/// Signed board post carried by the mesh.
case board(BoardPostPacket)
/// Kind-1 location note seen on geo relays.
case nostr(LocationNotesManager.Note)
}
let id: String
let author: String
let content: String
let createdAt: Date
let isUrgent: Bool
let source: Source
var isBoardPost: Bool {
if case .board = source { return true }
return false
}
init(post: BoardPostPacket) {
id = post.postID.hexEncodedString()
author = post.authorNickname.trimmedOrNilIfEmpty ?? "anon"
content = post.content
createdAt = Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000)
isUrgent = post.isUrgent
source = .board(post)
}
init(note: LocationNotesManager.Note) {
id = note.id
let display = note.displayName
author = display.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)
.first.map(String.init) ?? display
content = note.content
createdAt = note.createdAt
isUrgent = false
source = .nostr(note)
}
}
/// Merges mesh board posts and Nostr location notes into one deduplicated
/// list for the notices sheet's geo tab.
enum UnifiedNotices {
/// Board posts on geohash channels are bridged to Nostr as kind-1 notes at
/// post time, so the same notice arrives twice. The copies share content
/// and nickname but are signed by unlinkable keys; match them
/// heuristically by content + author within a time window.
static let bridgeDedupeWindow: TimeInterval = 15 * 60
/// Returns board posts and notes as one list, urgent posts first, then
/// newest first. Notes that look like bridged copies of a board post are
/// dropped the board copy wins because it carries urgency and supports
/// merged deletion. The geohash must match exactly: the notes
/// subscription also surfaces neighboring cells, and a same-text note
/// from a neighbor is not the bridged copy.
static func merge(posts: [BoardPostPacket], notes: [LocationNotesManager.Note]) -> [NoticeItem] {
var items = posts.map(NoticeItem.init(post:))
for note in notes {
let noteNickname = note.nickname?.trimmedOrNilIfEmpty ?? "anon"
let isBridgedCopy = posts.contains { post in
post.geohash == note.geohash
&& post.content == note.content
&& (post.authorNickname.trimmedOrNilIfEmpty ?? "anon") == noteNickname
&& abs(Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000).timeIntervalSince(note.createdAt)) <= bridgeDedupeWindow
}
if !isBridgedCopy {
items.append(NoticeItem(note: note))
}
}
return items.sorted {
if $0.isUrgent != $1.isUrgent { return $0.isUrgent }
return $0.createdAt > $1.createdAt
}
}
}
+339
View File
@@ -0,0 +1,339 @@
//
// CashuTokenDecoder.swift
// bitchat
//
// Decodes Cashu ecash tokens (V3 `cashuA` = base64url JSON, V4 `cashuB` =
// base64url CBOR) just far enough to summarize them for the UI: total
// amount, unit, mint host, and memo. The app never contacts a mint tokens
// are bearer strings and redemption is delegated to an external wallet.
//
// This parses attacker-controlled message content, so every path is
// bounds-checked, size-capped, and returns nil instead of trapping.
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
enum CashuTokenDecoder {
struct TokenInfo: Equatable {
/// Token serialization version: "A" (JSON) or "B" (CBOR).
let version: String
/// Sum of all proof amounts; nil when no valid amounts were found.
let amount: Int?
/// Currency unit as declared by the token (commonly "sat"), if any.
let unit: String?
/// Host of the (first) mint URL, for display.
let mintHost: String?
/// Optional sender memo, sanitized for display.
let memo: String?
/// "500 sat" style summary, defaulting the unit to sats per NUT-00.
var displayAmount: String? {
amount.map { "\($0) \(unit ?? "sat")" }
}
}
/// Upper bound on accepted token length in characters. Real tokens are a
/// few KB; anything much bigger is abuse we shouldn't spend CPU on.
static let maxTokenLength = 60_000
/// Per-proof and total amount sanity caps (order of total sats in existence).
private static let maxAmount: Int64 = 2_100_000_000_000_000
// MARK: - Public API
/// Extracts the bare `cashuA`/`cashuB` token from raw text that may be
/// a `cashu:`/`cashu://` URI and/or percent-encoded. Returns nil when the
/// input doesn't look like a Cashu token at all.
static func bareToken(from raw: String) -> String? {
var token = raw.trimmingCharacters(in: .whitespacesAndNewlines)
let lower = token.lowercased()
if lower.hasPrefix("cashu://") {
token = String(token.dropFirst(8))
} else if lower.hasPrefix("cashu:") {
token = String(token.dropFirst(6))
}
if token.contains("%"), let decoded = token.removingPercentEncoding {
token = decoded
}
guard token.count >= 12, token.count <= maxTokenLength else { return nil }
guard token.hasPrefix("cashuA") || token.hasPrefix("cashuB") else { return nil }
// Base64 / base64url payload charset ('.' appears in some legacy multi-part tokens)
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_+/=."))
guard token.unicodeScalars.allSatisfy({ allowed.contains($0) }) else { return nil }
return token
}
/// Decodes a token (raw or `cashu:` URI form) into a display summary.
///
/// In the default (permissive) mode this is for *rendering*: V3 tokens
/// must parse as JSON, but a V4 token whose CBOR we cannot walk still
/// returns a generic `TokenInfo` (version "B", no amount) because the
/// payload may use encodings this minimal reader doesn't support an
/// unknown chip is fine for display.
///
/// In `strict` mode (used by the `/pay` SEND path) there is no permissive
/// fallback: the token must cleanly decode to a known version *and* carry
/// a positive amount, otherwise this returns nil. This stops base64 junk
/// and truncated V4 tokens from being relayed as if they were valid money.
static func decode(_ raw: String, strict: Bool = false) -> TokenInfo? {
guard let token = bareToken(from: raw) else { return nil }
let version = String(token[token.index(token.startIndex, offsetBy: 5)])
guard let payload = base64URLDecode(String(token.dropFirst(6))), !payload.isEmpty else {
return nil
}
let info: TokenInfo?
switch version {
case "A":
info = decodeV3(payload)
case "B":
if let walked = decodeV4(payload) {
info = walked
} else if strict {
// Couldn't cleanly walk the CBOR refuse to send it.
return nil
} else {
info = TokenInfo(version: "B", amount: nil, unit: nil, mintHost: nil, memo: nil)
}
default:
return nil
}
guard let info else { return nil }
if strict {
// A sendable token must resolve to a positive, sane amount.
guard let amount = info.amount, amount > 0 else { return nil }
}
return info
}
// MARK: - Base64url
private static func base64URLDecode(_ input: String) -> Data? {
var s = input
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
// Normalize padding (wallets emit both padded and unpadded forms)
s = s.replacingOccurrences(of: "=", with: "")
let remainder = s.count % 4
if remainder == 1 { return nil }
if remainder > 0 { s += String(repeating: "=", count: 4 - remainder) }
return Data(base64Encoded: s)
}
// MARK: - V3 (JSON)
private static func decodeV3(_ payload: Data) -> TokenInfo? {
guard let obj = (try? JSONSerialization.jsonObject(with: payload)) as? [String: Any],
let entries = obj["token"] as? [[String: Any]],
!entries.isEmpty else {
return nil
}
var total: Int64 = 0
var sawAmount = false
var mintHost: String?
for entry in entries {
if mintHost == nil, let mint = entry["mint"] as? String {
mintHost = sanitizedHost(from: mint)
}
for proof in (entry["proofs"] as? [[String: Any]]) ?? [] {
guard let number = proof["amount"] as? NSNumber else { continue }
let value = number.int64Value
guard value > 0, value <= maxAmount else { continue }
total += value
guard total <= maxAmount else { return nil }
sawAmount = true
}
}
return TokenInfo(
version: "A",
amount: sawAmount ? Int(total) : nil,
unit: sanitizedUnit(obj["unit"] as? String),
mintHost: mintHost,
memo: sanitizedMemo(obj["memo"] as? String)
)
}
// MARK: - V4 (CBOR)
/// Minimal walk of the NUT-00 TokenV4 CBOR map:
/// { "m": mint, "u": unit, "d": memo, "t": [ { "i": bytes, "p": [ { "a": amount, } ] } ] }
private static func decodeV4(_ payload: Data) -> TokenInfo? {
var reader = CBORReader(data: payload)
guard case .map(let pairs)? = reader.parseValue(depth: 0) else { return nil }
var mintHost: String?
var unit: String?
var memo: String?
var total: Int64 = 0
var sawAmount = false
for (key, value) in pairs {
guard case .text(let name) = key else { continue }
switch (name, value) {
case ("m", .text(let mint)):
mintHost = sanitizedHost(from: mint)
case ("u", .text(let u)):
unit = sanitizedUnit(u)
case ("d", .text(let d)):
memo = sanitizedMemo(d)
case ("t", .array(let groups)):
for case .map(let group) in groups {
for case (.text("p"), .array(let proofs)) in group {
for case .map(let proof) in proofs {
for case (.text("a"), .unsigned(let amount)) in proof {
guard amount > 0, amount <= UInt64(maxAmount) else { continue }
total += Int64(amount)
guard total <= maxAmount else { return nil }
sawAmount = true
}
}
}
}
default:
break
}
}
return TokenInfo(
version: "B",
amount: sawAmount ? Int(total) : nil,
unit: unit,
mintHost: mintHost,
memo: memo
)
}
// MARK: - Display Sanitization (values are attacker-controlled)
private static func sanitizedHost(from mint: String) -> String? {
guard mint.count <= 512, let host = URL(string: mint)?.host, !host.isEmpty else { return nil }
return String(host.lowercased().prefix(48))
}
private static func sanitizedUnit(_ unit: String?) -> String? {
guard let unit, !unit.isEmpty, unit.count <= 12,
unit.unicodeScalars.allSatisfy({ CharacterSet.alphanumerics.contains($0) }) else {
return nil
}
return unit
}
private static func sanitizedMemo(_ memo: String?) -> String? {
guard let memo, memo.count <= 512 else { return nil }
let stripped = CharacterSet.controlCharacters.union(.newlines)
var cleaned = ""
cleaned.unicodeScalars.append(contentsOf: memo.unicodeScalars.filter { !stripped.contains($0) })
cleaned = cleaned.trimmingCharacters(in: .whitespaces)
guard !cleaned.isEmpty else { return nil }
return String(cleaned.prefix(80))
}
}
// MARK: - Minimal CBOR Reader
/// Just enough definite-length CBOR to traverse a TokenV4 map. Bounded in
/// depth, item count, and byte length; indefinite-length items and anything
/// else exotic make the parse fail (the caller degrades to a generic chip).
private struct CBORReader {
indirect enum Value {
case unsigned(UInt64)
case text(String)
case array([Value])
case map([(Value, Value)])
/// Parsed-and-skipped content we don't need (byte strings, negatives, floats)
case opaque
}
private let bytes: [UInt8]
private var index = 0
/// Total item budget so hostile nesting can't run away.
private var itemBudget = 50_000
private static let maxDepth = 16
private static let maxContainerCount: UInt64 = 10_000
init(data: Data) {
bytes = [UInt8](data)
}
mutating func parseValue(depth: Int) -> Value? {
guard depth < Self.maxDepth, itemBudget > 0 else { return nil }
itemBudget -= 1
guard let (major, argument) = readHead() else { return nil }
switch major {
case 0: // unsigned int
return .unsigned(argument)
case 1: // negative int (argument already consumed)
return .opaque
case 2: // byte string
return readBytes(count: argument) != nil ? .opaque : nil
case 3: // text string
guard let raw = readBytes(count: argument) else { return nil }
return String(bytes: raw, encoding: .utf8).map(Value.text) ?? .opaque
case 4: // array
guard argument <= Self.maxContainerCount else { return nil }
var items: [Value] = []
items.reserveCapacity(Int(min(argument, 64)))
for _ in 0..<argument {
guard let item = parseValue(depth: depth + 1) else { return nil }
items.append(item)
}
return .array(items)
case 5: // map
guard argument <= Self.maxContainerCount else { return nil }
var pairs: [(Value, Value)] = []
pairs.reserveCapacity(Int(min(argument, 64)))
for _ in 0..<argument {
guard let key = parseValue(depth: depth + 1),
let value = parseValue(depth: depth + 1) else { return nil }
pairs.append((key, value))
}
return .map(pairs)
case 6: // tag: skip the tag number, parse the tagged value
return parseValue(depth: depth + 1)
case 7: // simple values / floats (payload consumed by readHead)
return .opaque
default:
return nil
}
}
/// Reads a CBOR head byte plus its argument. Rejects indefinite lengths.
private mutating func readHead() -> (major: UInt8, argument: UInt64)? {
guard index < bytes.count else { return nil }
let head = bytes[index]
index += 1
let major = head >> 5
let info = head & 0x1F
switch info {
case 0...23:
return (major, UInt64(info))
case 24:
return readUInt(width: 1).map { (major, $0) }
case 25:
return readUInt(width: 2).map { (major, $0) }
case 26:
return readUInt(width: 4).map { (major, $0) }
case 27:
return readUInt(width: 8).map { (major, $0) }
default: // 28-30 reserved, 31 indefinite
return nil
}
}
private mutating func readUInt(width: Int) -> UInt64? {
guard bytes.count - index >= width else { return nil }
var value: UInt64 = 0
for _ in 0..<width {
value = (value << 8) | UInt64(bytes[index])
index += 1
}
return value
}
private mutating func readBytes(count: UInt64) -> [UInt8]? {
guard count <= UInt64(bytes.count - index) else { return nil }
let length = Int(count)
let slice = Array(bytes[index..<(index + length)])
index += length
return slice
}
}
+218 -25
View File
@@ -22,6 +22,17 @@ struct CommandGeoParticipant {
let displayName: String let displayName: String
} }
/// The conversation a command was typed into, captured when the command is
/// issued so deferred output (e.g. an async /ping result, which can arrive
/// many seconds later) lands there even if the user switches chats first.
enum CommandOutputDestination: Equatable {
/// The #mesh public timeline. Commands that defer output (/ping) are
/// mesh-only, so a non-DM origin is always the mesh timeline.
case meshTimeline
/// The private chat that was open when the command was typed.
case privateChat(PeerID)
}
/// Protocol defining what CommandProcessor needs from its context. /// Protocol defining what CommandProcessor needs from its context.
/// This breaks the circular dependency between CommandProcessor and ChatViewModel. /// This breaks the circular dependency between CommandProcessor and ChatViewModel.
@MainActor @MainActor
@@ -45,14 +56,33 @@ protocol CommandContextProvider: AnyObject {
/// Empties the peer's chat (single-writer store intent for `/clear`). /// Empties the peer's chat (single-writer store intent for `/clear`).
func clearPrivateChat(_ peerID: PeerID) func clearPrivateChat(_ peerID: PeerID)
func sendPublicRaw(_ content: String) func sendPublicRaw(_ content: String)
/// Sends a normal public message (with local echo) to the active channel.
func sendPublicMessage(_ content: String)
// MARK: - System Messages // MARK: - System Messages
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID)
func addPublicSystemMessage(_ content: String) func addPublicSystemMessage(_ content: String)
/// The conversation the user is typing into right now. Commands that
/// finish asynchronously capture this BEFORE starting async work, so a
/// chat switch cannot misroute their deferred output.
func currentCommandDestination() -> CommandOutputDestination
/// Routes deferred command output (e.g. an async /ping result) into the
/// conversation captured when the command was issued.
func addCommandOutput(_ content: String, to destination: CommandOutputDestination)
// MARK: - Favorites // MARK: - Favorites
/// Toggles the favorite via the unified peer flow, which persists by the
/// real noise key and notifies the peer over mesh or Nostr.
func toggleFavorite(peerID: PeerID) func toggleFavorite(peerID: PeerID)
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
// MARK: - Groups
// Group logic lives in `ChatGroupCoordinator`; these forward the parsed
// /group subcommands.
func groupCreate(named name: String) -> CommandResult
func groupInvite(nickname: String) -> CommandResult
func groupRemove(nickname: String) -> CommandResult
func groupLeave() -> CommandResult
func groupList() -> CommandResult
} }
/// Processes chat commands in a focused, efficient way /// Processes chat commands in a focused, efficient way
@@ -99,17 +129,51 @@ final class CommandProcessor {
return handleBlock(args) return handleBlock(args)
case "/unblock": case "/unblock":
return handleUnblock(args) return handleUnblock(args)
case "/group":
if inGeoPublic || inGeoDM { return .error(message: "groups are only for mesh peers in #mesh") }
return handleGroup(args)
case "/fav": case "/fav":
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") } if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
return handleFavorite(args, add: true) return handleFavorite(args, add: true)
case "/unfav": case "/unfav":
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") } if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
return handleFavorite(args, add: false) return handleFavorite(args, add: false)
case "/ping":
if inGeoPublic || inGeoDM { return .error(message: "ping only works for mesh peers in #mesh") }
return handlePing(args)
case "/trace":
if inGeoPublic || inGeoDM { return .error(message: "trace only works for mesh peers in #mesh") }
return handleTrace(args)
case "/pay":
return handlePay(args)
case "/help":
return .success(message: Self.helpText)
default: default:
return .error(message: "unknown command: \(cmd)") return .error(message: "unknown command: \(cmd) — type /help for commands")
} }
} }
/// Local-only command reference, printed as a system message. The
/// suggestion panel hides once arguments are typed, and typos used to
/// dead-end in a bare "unknown command" this is the way out.
static let helpText = """
commands:
/msg @name [message] start a private chat
/who list who's here
/clear clear this chat
/hug @name send a hug
/slap @name slap with a large trout
/block @name · /unblock @name
/fav @name · /unfav @name favorites (mesh only)
/group create <name> start an encrypted group
/group invite @name · /group remove @name manage members (creator)
/group leave · /group list leave or list your groups
/ping @name measure round-trip time (mesh only)
/trace @name estimated mesh path (mesh only)
/pay <token> send a cashu ecash token in this chat
/help this list
"""
// MARK: - Command Handlers // MARK: - Command Handlers
private func handleMessage(_ args: String) -> CommandResult { private func handleMessage(_ args: String) -> CommandResult {
@@ -313,39 +377,168 @@ final class CommandProcessor {
return .error(message: "cannot unblock \(nickname): not found") return .error(message: "cannot unblock \(nickname): not found")
} }
private static let groupUsage = "usage: /group create <name> · invite @name · remove @name · leave · list"
private func handleGroup(_ args: String) -> CommandResult {
let parts = args.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
guard let subcommand = parts.first else {
return .error(message: Self.groupUsage)
}
let rest = parts.count > 1 ? String(parts[1]) : ""
guard let provider = contextProvider else { return .handled }
switch subcommand {
case "create":
return provider.groupCreate(named: rest)
case "invite":
return provider.groupInvite(nickname: rest)
case "remove":
return provider.groupRemove(nickname: rest)
case "leave":
return provider.groupLeave()
case "list":
return provider.groupList()
default:
return .error(message: Self.groupUsage)
}
}
// MARK: - Mesh Diagnostics
private enum MeshPeerResolution {
case resolved(peerID: PeerID, nickname: String)
case failed(CommandResult)
}
/// Resolves a mesh peer for /ping and /trace. Geohash identities are
/// rejected diagnostics measure the BLE mesh, not Nostr.
private func resolveMeshPeer(_ args: String, command: String) -> MeshPeerResolution {
let targetName = args.trimmed
guard !targetName.isEmpty else {
return .failed(.error(message: "usage: /\(command) <nickname>"))
}
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let peerID = contextProvider?.getPeerIDForNickname(nickname),
!peerID.isGeoDM, !peerID.isGeoChat else {
return .failed(.error(message: "cannot \(command) \(nickname): not found on mesh"))
}
return .resolved(peerID: peerID, nickname: nickname)
}
private func handlePing(_ args: String) -> CommandResult {
let target: (peerID: PeerID, nickname: String)
switch resolveMeshPeer(args, command: "ping") {
case .resolved(let peerID, let nickname): target = (peerID, nickname)
case .failed(let result): return result
}
let nickname = target.nickname
let currentProvider = contextProvider
// Capture the origin conversation now: the pong can arrive up to
// meshPingTimeoutSeconds later, and reading the selected chat at
// callback time would misroute the result after a chat switch.
let destination = contextProvider?.currentCommandDestination() ?? .meshTimeline
meshService?.sendMeshPing(to: target.peerID) { [weak currentProvider] result in
let provider = currentProvider
guard let result else {
provider?.addCommandOutput("no reply from \(nickname)", to: destination)
return
}
let hopText: String = result.hops.map { hops in
hops == 1 ? " · direct (1 hop)" : " · \(hops) hops"
} ?? ""
provider?.addCommandOutput("pong from \(nickname): \(result.rttMs) ms\(hopText)", to: destination)
}
return .success(message: "pinging \(nickname)")
}
private func handleTrace(_ args: String) -> CommandResult {
let target: (peerID: PeerID, nickname: String)
switch resolveMeshPeer(args, command: "trace") {
case .resolved(let peerID, let nickname): target = (peerID, nickname)
case .failed(let result): return result
}
guard let mesh = meshService,
let intermediates = mesh.computeMeshPath(to: target.peerID) else {
return .success(message: "no known path to \(target.nickname)")
}
// Graph-derived from gossiped neighbor claims, not route-recorded
// present it as an estimate.
let hopNames = intermediates.map { hop in
mesh.peerNickname(peerID: hop) ?? "\(hop.id.prefix(8))"
}
let chain = (["you"] + hopNames + [target.nickname]).joined(separator: "")
let hops = intermediates.count + 1
return .success(message: "estimated path: \(chain) (\(hops) hop\(hops == 1 ? "" : "s"))")
}
/// `/pay <cashu-token>` validates the token decodes, then sends it as
/// the message body in the current chat. Cashu tokens are bearer
/// instruments (whoever redeems first gets the funds), so posting one to
/// a public channel requires an explicit `/pay <token> public` confirm.
/// The app never contacts a mint; it only relays the string.
private func handlePay(_ args: String) -> CommandResult {
var parts = args.trimmed.split(separator: " ").map(String.init)
guard !parts.isEmpty else {
return .success(message: "usage: /pay <token> — paste a cashu token: /pay cashuA…")
}
let confirmedPublic = parts.count > 1 && parts.last?.lowercased() == "public"
if confirmedPublic { parts.removeLast() }
guard parts.count == 1, let token = CashuTokenDecoder.bareToken(from: parts[0]) else {
return .error(message: "that doesn't look like a cashu token — expected cashuA… or cashuB…")
}
guard let info = CashuTokenDecoder.decode(token, strict: true) else {
return .error(message: "invalid cashu token — it doesn't decode to a known token with an amount, not sending it")
}
let summary = info.displayAmount ?? "a cashu token"
if let peerID = contextProvider?.selectedPrivateChatPeer {
contextProvider?.sendPrivateMessage(token, to: peerID)
return .success(message: "sent \(summary) — cashu is a bearer token; whoever redeems it first gets the funds")
}
guard confirmedPublic else {
return .error(message: "this is a public channel — anyone reading it can redeem the token. send anyway: /pay <token> public")
}
contextProvider?.sendPublicMessage(token)
return .success(message: "sent \(summary) to the public channel — anyone here can redeem it")
}
private func handleFavorite(_ args: String, add: Bool) -> CommandResult { private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
let targetName = args.trimmed let targetName = args.trimmed
guard !targetName.isEmpty else { guard !targetName.isEmpty else {
return .error(message: "usage: /\(add ? "fav" : "unfav") <nickname>") return .error(message: "usage: /\(add ? "fav" : "unfav") <nickname>")
} }
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let peerID = contextProvider?.getPeerIDForNickname(nickname), guard let peerID = contextProvider?.getPeerIDForNickname(nickname) else {
let noisePublicKey = Data(hexString: peerID.id) else {
return .error(message: "can't find peer: \(nickname)") return .error(message: "can't find peer: \(nickname)")
} }
if add { // Resolve current state by the peer's real noise key. The resolved
let existingFavorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey) // peerID is either the short 16-hex mesh ID or the full 64-hex
FavoritesPersistenceService.shared.addFavorite( // noise-key ID (offline favorite row) never the noise key itself.
peerNoisePublicKey: noisePublicKey, let isCurrentlyFavorite: Bool
peerNostrPublicKey: existingFavorite?.peerNostrPublicKey, if let noiseKey = peerID.noiseKey {
peerNickname: nickname isCurrentlyFavorite = FavoritesPersistenceService.shared.isFavorite(noiseKey)
)
contextProvider?.toggleFavorite(peerID: peerID)
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: true)
return .success(message: "added \(nickname) to favorites")
} else { } else {
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey) isCurrentlyFavorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.isFavorite ?? false
contextProvider?.toggleFavorite(peerID: peerID)
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: false)
return .success(message: "removed \(nickname) from favorites")
} }
guard add != isCurrentlyFavorite else {
return .success(message: add ? "\(nickname) is already a favorite" : "\(nickname) is not a favorite")
}
// toggleFavorite persists by the real noise key and notifies the peer.
contextProvider?.toggleFavorite(peerID: peerID)
return .success(message: add ? "added \(nickname) to favorites" : "removed \(nickname) from favorites")
} }
} }
+358
View File
@@ -0,0 +1,358 @@
//
// CourierStore.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 Combine
import Foundation
/// Trust level of a courier deposit, decided by the caller's policy.
/// Favorites get the larger quota and are never evicted to make room for
/// verified-tier mail; verified (signature-verified announce, not a mutual
/// favorite) get a small quota so a crowd of strangers can still carry mail.
enum CourierDepositTier: String, Codable {
case favorite
case verified
}
/// Holds courier envelopes this device is carrying for offline third parties.
///
/// Envelopes are opaque ciphertext; this store never learns sender,
/// recipient, or content. Strict quotas keep the device from becoming a
/// public mailbag: bounded count, bounded per-depositor count by trust tier,
/// bounded size, and a 24-hour lifetime aligned with the outbox retention
/// policy. Carried mail is included in the panic wipe.
final class CourierStore {
struct StoredEnvelope: Codable, Equatable {
let recipientTag: Data
let expiry: UInt64
let ciphertext: Data
let depositorNoiseKey: Data
let storedAt: Date
var tier: CourierDepositTier
/// Remaining spray-and-wait budget (1 = carry-only).
var copies: UInt8
/// Couriers this envelope was already sprayed to, so a repeat announce
/// from the same peer doesn't burn budget on a copy they already hold.
var sprayedTo: Set<Data>
/// Last speculative multi-hop handover toward a relayed announce.
var lastRemoteHandoverAt: Date?
/// Prekey-sealed (envelope v2) discriminator; nil for static-sealed v1.
let prekeyID: UInt32?
var envelope: CourierEnvelope {
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies, prekeyID: prekeyID)
}
init(
recipientTag: Data,
expiry: UInt64,
ciphertext: Data,
depositorNoiseKey: Data,
storedAt: Date,
tier: CourierDepositTier,
copies: UInt8,
sprayedTo: Set<Data> = [],
lastRemoteHandoverAt: Date? = nil,
prekeyID: UInt32? = nil
) {
self.recipientTag = recipientTag
self.expiry = expiry
self.ciphertext = ciphertext
self.depositorNoiseKey = depositorNoiseKey
self.storedAt = storedAt
self.tier = tier
self.copies = copies
self.sprayedTo = sprayedTo
self.lastRemoteHandoverAt = lastRemoteHandoverAt
self.prekeyID = prekeyID
}
// Files written before tiers/spray lack the newer fields; treat that
// mail as favorite-tier carry-only, which is what it was.
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
recipientTag = try container.decode(Data.self, forKey: .recipientTag)
expiry = try container.decode(UInt64.self, forKey: .expiry)
ciphertext = try container.decode(Data.self, forKey: .ciphertext)
depositorNoiseKey = try container.decode(Data.self, forKey: .depositorNoiseKey)
storedAt = try container.decode(Date.self, forKey: .storedAt)
tier = try container.decodeIfPresent(CourierDepositTier.self, forKey: .tier) ?? .favorite
copies = try container.decodeIfPresent(UInt8.self, forKey: .copies) ?? 1
sprayedTo = try container.decodeIfPresent(Set<Data>.self, forKey: .sprayedTo) ?? []
lastRemoteHandoverAt = try container.decodeIfPresent(Date.self, forKey: .lastRemoteHandoverAt)
prekeyID = try container.decodeIfPresent(UInt32.self, forKey: .prekeyID)
}
}
enum Limits {
static let maxEnvelopes = 40
/// Verified-tier mail can never crowd out favorites' share.
static let maxVerifiedEnvelopes = 20
static let maxPerFavoriteDepositor = 5
static let maxPerVerifiedDepositor = 2
/// Slack on top of the 24h lifetime for depositor clock skew.
static let maxExpirySlack: TimeInterval = 60 * 60
}
static let shared = CourierStore()
/// Number of envelopes currently carried, published on the main thread
/// so the UI can show a "carrying mail" indicator.
@Published private(set) var carriedCount: Int = 0
/// Fast path so hot code (announce handling) can skip tag computation.
var isEmpty: Bool {
queue.sync { envelopes.isEmpty }
}
private var envelopes: [StoredEnvelope] = []
private let queue = DispatchQueue(label: "chat.bitchat.courier.store")
private let fileURL: URL?
private let now: () -> Date
/// - Parameter fileURL: Overrides the on-disk location (tests). Ignored
/// when `persistsToDisk` is false.
init(persistsToDisk: Bool = true, fileURL: URL? = nil, now: @escaping () -> Date = Date.init) {
self.now = now
self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
loadFromDisk()
}
// MARK: - Depositing (courier side)
/// Accept an envelope from a depositor. Returns false when quotas or
/// validity checks reject it. Trust policy (which tier a depositor gets,
/// if any) is the caller's responsibility; this store only enforces
/// resource bounds.
@discardableResult
func deposit(_ envelope: CourierEnvelope, from depositorNoiseKey: Data, tier: CourierDepositTier = .favorite) -> Bool {
let date = now()
guard envelope.recipientTag.count == CourierEnvelope.tagLength,
!envelope.ciphertext.isEmpty,
envelope.ciphertext.count <= CourierEnvelope.maxCiphertextBytes,
!envelope.isExpired(at: date) else {
return false
}
// Reject expiries beyond the policy lifetime so depositors can't pin
// storage longer than the outbox would retain the message itself.
let maxExpiry = date.addingTimeInterval(CourierEnvelope.maxLifetimeSeconds + Limits.maxExpirySlack)
guard envelope.expiry <= UInt64(maxExpiry.timeIntervalSince1970 * 1000) else {
return false
}
return queue.sync {
pruneExpiredLocked(at: date)
// Identical ciphertext is the same envelope; accept idempotently,
// keeping the larger spray budget (bounded by maxCopies either way).
if let existing = envelopes.firstIndex(where: { $0.ciphertext == envelope.ciphertext }) {
envelopes[existing].copies = max(envelopes[existing].copies, envelope.copies)
persistLocked()
return true
}
let perDepositorLimit = tier == .favorite ? Limits.maxPerFavoriteDepositor : Limits.maxPerVerifiedDepositor
guard envelopes.filter({ $0.depositorNoiseKey == depositorNoiseKey }).count < perDepositorLimit else {
SecureLogger.debug("📦 Courier deposit rejected: per-depositor quota reached (\(tier.rawValue))", category: .session)
return false
}
if tier == .verified,
envelopes.filter({ $0.tier == .verified }).count >= Limits.maxVerifiedEnvelopes {
SecureLogger.debug("📦 Courier deposit rejected: verified-tier pool full", category: .session)
return false
}
if envelopes.count >= Limits.maxEnvelopes {
// Oldest-first eviction, shedding verified-tier mail before
// favorites' so open couriering can't crowd out trusted mail.
// A verified deposit never displaces a favorite: when only
// favorite mail is stored, it is rejected instead.
if let victim = envelopes.firstIndex(where: { $0.tier == .verified }) {
let evicted = envelopes.remove(at: victim)
SecureLogger.debug("📦 Courier store full - evicted verified envelope stored at \(evicted.storedAt)", category: .session)
} else if tier == .favorite {
let evicted = envelopes.removeFirst()
SecureLogger.debug("📦 Courier store full - evicted favorite envelope stored at \(evicted.storedAt)", category: .session)
} else {
SecureLogger.debug("📦 Courier deposit rejected: store full of favorite-tier mail", category: .session)
return false
}
}
envelopes.append(StoredEnvelope(
recipientTag: envelope.recipientTag,
expiry: envelope.expiry,
ciphertext: envelope.ciphertext,
depositorNoiseKey: depositorNoiseKey,
storedAt: date,
tier: tier,
copies: envelope.copies,
prekeyID: envelope.prekeyID
))
persistLocked()
return true
}
}
// MARK: - Handover (on encountering a peer)
/// Remove and return all envelopes addressed to the given peer, matching
/// the rotating recipient tag across adjacent days. Envelopes are removed
/// optimistically: handover happens over a live link, and the depositor's
/// outbox still retains the original for direct delivery.
func takeEnvelopes(for noiseStaticKey: Data) -> [CourierEnvelope] {
let date = now()
let candidates = CourierEnvelope.candidateTags(noiseStaticKey: noiseStaticKey, around: date)
return queue.sync {
pruneExpiredLocked(at: date)
let matched = envelopes.filter { candidates.contains($0.recipientTag) }
guard !matched.isEmpty else { return [] }
envelopes.removeAll { stored in matched.contains(stored) }
persistLocked()
return matched.map(\.envelope)
}
}
/// Envelopes addressed to a recipient we heard from via a *relayed*
/// announce. Non-destructive: a multi-hop send is speculative, so the
/// envelope stays carried until a direct handover or expiry. The per-
/// envelope cooldown keeps repeated announces from re-flooding the mesh.
func envelopesForRemoteHandover(recipientNoiseKey: Data, cooldown: TimeInterval) -> [CourierEnvelope] {
let date = now()
let candidates = CourierEnvelope.candidateTags(noiseStaticKey: recipientNoiseKey, around: date)
return queue.sync {
pruneExpiredLocked(at: date)
var matched: [CourierEnvelope] = []
for index in envelopes.indices where candidates.contains(envelopes[index].recipientTag) {
if let last = envelopes[index].lastRemoteHandoverAt,
date.timeIntervalSince(last) < cooldown {
continue
}
envelopes[index].lastRemoteHandoverAt = date
// The delivered copy carries no spray budget.
matched.append(envelopes[index].envelope.withCopies(1))
}
if !matched.isEmpty { persistLocked() }
return matched
}
}
// MARK: - Spray-and-wait (on encountering another courier)
/// Envelopes to re-deposit with a courier we just encountered, each with
/// half its remaining budget (binary spray). Skips envelopes the courier
/// deposited, envelopes addressed to them (those ride the handover path),
/// carry-only envelopes, and couriers already sprayed.
func takeSprayCopies(for courierNoiseKey: Data) -> [CourierEnvelope] {
let date = now()
let courierTags = CourierEnvelope.candidateTags(noiseStaticKey: courierNoiseKey, around: date)
return queue.sync {
pruneExpiredLocked(at: date)
var sprayed: [CourierEnvelope] = []
for index in envelopes.indices {
let stored = envelopes[index]
guard stored.copies > 1,
stored.depositorNoiseKey != courierNoiseKey,
!stored.sprayedTo.contains(courierNoiseKey),
!courierTags.contains(stored.recipientTag) else { continue }
let given = stored.copies / 2
envelopes[index].copies = stored.copies - given
envelopes[index].sprayedTo.insert(courierNoiseKey)
sprayed.append(stored.envelope.withCopies(given))
}
if !sprayed.isEmpty { persistLocked() }
return sprayed
}
}
// MARK: - Maintenance
func pruneExpired() {
let date = now()
queue.sync {
pruneExpiredLocked(at: date)
persistLocked()
}
}
/// Panic wipe: drop all carried mail from memory and disk.
func wipe() {
queue.sync {
envelopes.removeAll()
if let fileURL {
try? FileManager.default.removeItem(at: fileURL)
}
publishCountLocked()
}
}
// MARK: - Internals (call only on `queue`)
private func pruneExpiredLocked(at date: Date) {
let before = envelopes.count
envelopes.removeAll { $0.envelope.isExpired(at: date) }
if envelopes.count != before {
SecureLogger.debug("📦 Courier store pruned \(before - envelopes.count) expired envelope(s)", category: .session)
}
}
private func publishCountLocked() {
let count = envelopes.count
DispatchQueue.main.async { [weak self] in
self?.carriedCount = count
}
}
private func persistLocked() {
publishCountLocked()
guard let fileURL else { return }
do {
if envelopes.isEmpty {
try? FileManager.default.removeItem(at: fileURL)
return
}
try FileManager.default.createDirectory(
at: fileURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
let data = try JSONEncoder().encode(envelopes)
var options: Data.WritingOptions = [.atomic]
#if os(iOS)
options.insert(.completeFileProtection)
#endif
try data.write(to: fileURL, options: options)
} catch {
SecureLogger.error("Failed to persist courier store: \(error)", category: .session)
}
}
private func loadFromDisk() {
guard let fileURL else { return }
queue.sync {
guard let data = try? Data(contentsOf: fileURL),
let stored = try? JSONDecoder().decode([StoredEnvelope].self, from: data) else {
return
}
envelopes = stored
pruneExpiredLocked(at: now())
publishCountLocked()
}
}
private static func defaultFileURL() -> URL? {
guard let base = try? FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
) else { return nil }
return base
.appendingPathComponent("courier", isDirectory: true)
.appendingPathComponent("envelopes.json")
}
}
@@ -0,0 +1,156 @@
//
// MessageOutboxStore.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 CryptoKit
import Foundation
import Security
/// Disk persistence for the MessageRouter outbox, so private messages queued
/// for an offline peer survive an app kill instead of silently evaporating.
///
/// Nothing else in the app persists message plaintext, and this store keeps
/// that property: the outbox is sealed with a ChaChaPoly key that lives only
/// in the Keychain (after-first-unlock, this device only), on top of iOS file
/// protection. Wiped on panic alongside the courier store.
final class MessageOutboxStore {
struct QueuedMessage: Codable, Equatable {
let content: String
let nickname: String
let messageID: String
let timestamp: Date
var sendAttempts: Int
/// Noise keys of couriers already carrying this message, so deposit
/// retries add couriers instead of re-burning the same ones.
var depositedCourierKeys: Set<Data>
init(
content: String,
nickname: String,
messageID: String,
timestamp: Date,
sendAttempts: Int = 0,
depositedCourierKeys: Set<Data> = []
) {
self.content = content
self.nickname = nickname
self.messageID = messageID
self.timestamp = timestamp
self.sendAttempts = sendAttempts
self.depositedCourierKeys = depositedCourierKeys
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
content = try container.decode(String.self, forKey: .content)
nickname = try container.decode(String.self, forKey: .nickname)
messageID = try container.decode(String.self, forKey: .messageID)
timestamp = try container.decode(Date.self, forKey: .timestamp)
sendAttempts = try container.decodeIfPresent(Int.self, forKey: .sendAttempts) ?? 0
depositedCourierKeys = try container.decodeIfPresent(Set<Data>.self, forKey: .depositedCourierKeys) ?? []
}
}
private static let keychainService = "chat.bitchat.outbox"
private static let keychainKey = "outbox-encryption-key"
private let fileURL: URL?
private let keychain: KeychainManagerProtocol
init(keychain: KeychainManagerProtocol, fileURL: URL? = nil) {
self.keychain = keychain
self.fileURL = fileURL ?? Self.defaultFileURL()
}
// MARK: - API (call from the router's actor; IO is small and atomic)
func load() -> [PeerID: [QueuedMessage]] {
guard let fileURL,
let sealed = try? Data(contentsOf: fileURL),
let key = encryptionKey(createIfMissing: false),
let box = try? ChaChaPoly.SealedBox(combined: sealed),
let plaintext = try? ChaChaPoly.open(box, using: key),
let decoded = try? JSONDecoder().decode([String: [QueuedMessage]].self, from: plaintext) else {
return [:]
}
var outbox: [PeerID: [QueuedMessage]] = [:]
for (peerID, queue) in decoded where !queue.isEmpty {
outbox[PeerID(str: peerID)] = queue
}
return outbox
}
func save(_ outbox: [PeerID: [QueuedMessage]]) {
guard let fileURL else { return }
let flattened = outbox.filter { !$0.value.isEmpty }
guard !flattened.isEmpty else {
try? FileManager.default.removeItem(at: fileURL)
return
}
guard let key = encryptionKey(createIfMissing: true) else {
SecureLogger.error("Outbox not persisted: no encryption key available", category: .session)
return
}
do {
let keyed = Dictionary(uniqueKeysWithValues: flattened.map { ($0.key.id, $0.value) })
let plaintext = try JSONEncoder().encode(keyed)
let sealed = try ChaChaPoly.seal(plaintext, using: key).combined
try FileManager.default.createDirectory(
at: fileURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
var options: Data.WritingOptions = [.atomic]
#if os(iOS)
options.insert(.completeFileProtection)
#endif
try sealed.write(to: fileURL, options: options)
} catch {
SecureLogger.error("Failed to persist outbox: \(error)", category: .session)
}
}
/// Panic wipe: drop the queued mail and the key that could ever read it.
func wipe() {
if let fileURL {
try? FileManager.default.removeItem(at: fileURL)
}
keychain.delete(key: Self.keychainKey, service: Self.keychainService)
}
// MARK: - Internals
private func encryptionKey(createIfMissing: Bool) -> SymmetricKey? {
if let data = keychain.load(key: Self.keychainKey, service: Self.keychainService), data.count == 32 {
return SymmetricKey(data: data)
}
guard createIfMissing else { return nil }
let key = SymmetricKey(size: .bits256)
let data = key.withUnsafeBytes { Data($0) }
// After-first-unlock so queued mail can flush from background BLE wakes.
keychain.save(
key: Self.keychainKey,
data: data,
service: Self.keychainService,
accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
)
return key
}
private static func defaultFileURL() -> URL? {
guard let base = try? FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
) else { return nil }
return base
.appendingPathComponent("courier", isDirectory: true)
.appendingPathComponent("outbox.sealed")
}
}
@@ -0,0 +1,74 @@
//
// StoreAndForwardMetrics.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import Foundation
/// Privacy-safe local counters for the store-and-forward stack: bare event
/// tallies with no message IDs, peer identities, or timestamps, so delivery
/// behavior can be measured on-device without recording who talked to whom.
/// Log-only surface nothing here ever leaves the device.
final class StoreAndForwardMetrics {
enum Event: String, CaseIterable {
/// A private message entered the outbox (no prompt route available).
case outboxQueued = "outbox.queued"
/// A retained message was re-sent on a flush.
case outboxResent = "outbox.resent"
/// A delivery/read ack cleared a retained message.
case outboxDelivered = "outbox.delivered"
/// A retained message was dropped (attempt cap, TTL, or overflow).
case outboxDropped = "outbox.dropped"
/// We handed sealed mail to a courier.
case courierDeposited = "courier.deposited"
/// We accepted sealed mail to carry for a third party.
case courierAccepted = "courier.accepted"
/// We handed carried mail to its recipient over a direct link.
case courierHandedOver = "courier.handedOver"
/// We pushed carried mail toward a recipient heard via relay.
case courierRemoteHandover = "courier.remoteHandover"
/// We split spray copies to another courier.
case courierSprayed = "courier.sprayed"
/// Couriered mail addressed to us was opened and delivered.
case courierOpened = "courier.opened"
}
static let shared = StoreAndForwardMetrics()
private let lock = NSLock()
private var counts: [String: Int]
private let defaults: UserDefaults
private static let defaultsKey = "chat.bitchat.storeAndForwardMetrics"
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
self.counts = defaults.dictionary(forKey: Self.defaultsKey) as? [String: Int] ?? [:]
}
func record(_ event: Event) {
lock.lock()
let total = (counts[event.rawValue] ?? 0) + 1
counts[event.rawValue] = total
defaults.set(counts, forKey: Self.defaultsKey)
lock.unlock()
SecureLogger.debug("📊 S&F \(event.rawValue)\(total)", category: .session)
}
func snapshot() -> [String: Int] {
lock.lock()
defer { lock.unlock() }
return counts
}
/// Included in the panic wipe alongside the stores it describes.
func reset() {
lock.lock()
counts = [:]
defaults.removeObject(forKey: Self.defaultsKey)
lock.unlock()
}
}
@@ -141,7 +141,13 @@ final class FavoritesPersistenceService: ObservableObject {
peerNostrPublicKey: String? = nil peerNostrPublicKey: String? = nil
) { ) {
let existing = favorites[peerNoisePublicKey] let existing = favorites[peerNoisePublicKey]
let displayName = peerNickname ?? existing?.peerNickname ?? "Unknown" // Callers that can't resolve the live nickname pass the "Unknown"
// placeholder (e.g. a notification arriving before the announce);
// never let it clobber a real stored nickname.
let incoming = peerNickname.flatMap { name in
(name.isEmpty || name == "Unknown") ? nil : name
}
let displayName = incoming ?? existing?.peerNickname ?? "Unknown"
SecureLogger.info("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us", category: .session) SecureLogger.info("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us", category: .session)
@@ -0,0 +1,492 @@
//
// GatewayService.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 Combine
import Foundation
/// Policy engine for gateway mode: an opt-in "share my internet with the
/// mesh" bridge. While the toggle is on, this device advertises the
/// `.gateway` capability bit, publishes signed geohash events deposited by
/// mesh-only peers to Nostr relays (uplink), and rebroadcasts inbound relay
/// events onto the mesh (downlink) so mesh-only peers can take part in the
/// local geohash channel. Mesh-only peers need no toggle: their uplink
/// engages automatically when relays are unreachable and a gateway peer
/// exists.
///
/// Threat model:
/// - Keys never leave the originating device. Mesh-only senders sign events
/// locally with their per-geohash ephemeral identity; the gateway carries
/// only the finished, signed event.
/// - The gateway cannot forge or alter events: every carried event is
/// Schnorr-verified here before it is published or rebroadcast, and again
/// independently by relays and receivers.
/// - Carried contents are public geohash chat, already plaintext on Nostr,
/// so the mesh carrier adds no confidentiality loss.
///
/// Loop-prevention rules:
/// 1. An event learned from a `fromGateway` mesh broadcast is never
/// re-published to relays, never re-uplinked, and never rebroadcast
/// (`meshBroadcastEventIDs`), so a second gateway on the same mesh cannot
/// echo mesh-carried traffic back out. Mesh-level propagation of the
/// original broadcast packet is the TTL relay's job, not ours.
/// 2. An uplink deposit is published at most once (`publishedEventIDs`) and
/// a relay event is rebroadcast at most once (`rebroadcastEventIDs`), so
/// repeat deposits and relay echoes are absorbed. An event this gateway
/// itself uplinked (`publishedEventIDs`) is additionally never
/// downlink-rebroadcast: it originated on this mesh, so echoing it back
/// when our own relay subscription redelivers it would double BLE airtime
/// (the device-confirmed self-echo bug).
/// 3. Uplink is only attempted for locally composed events at the send site
/// (`GeohashSubscriptionManager.sendGeohash`); events received over the
/// carrier never re-enter the uplink path. This is a call-site convention;
/// the `meshBroadcastEventIDs`/`publishedEventIDs` backstops in
/// `uplinkViaMesh` enforce it defensively and are unit-tested.
/// Rules 1 and 2 are enforced here and unit-tested.
/// Rebroadcast storms at the mesh layer are additionally bounded by the BLE
/// `MessageDeduplicator` and packet TTL, and receivers dedup carried events
/// against their own relay subscriptions via the Nostr event-ID cache in
/// `NostrInboundPipeline`.
///
/// All dependencies are closure-injected (repo convention) so the policy
/// layer is unit-testable without relays or radios.
@MainActor
final class GatewayService: ObservableObject {
enum Limits {
/// Uplink deposits held while relays are unreachable (CourierStore-style
/// bounded mailbag: bounded total, bounded per depositor).
static let maxQueuedUplinks = 20
static let maxQueuedUplinksPerDepositor = 5
/// Uplink deposits accepted per depositor per minute.
static let uplinkEventsPerMinutePerDepositor = 10
/// Downlink mesh rebroadcasts per minute BLE airtime is precious.
/// Beyond the budget events queue (bounded, drop-oldest) and drain on
/// a scheduled timer once the window frees (also re-driven by the next
/// inbound relay event); a quiet channel does not strand its backlog.
static let downlinkEventsPerMinute = 30
static let maxPendingDownlinks = 30
/// Accepted clock skew for a carried ephemeral event; anything older
/// is stale replay the relays would drop anyway.
static let maxEventAgeSeconds: TimeInterval = 15 * 60
/// Bounded loop-prevention ID caches (oldest evicted).
static let maxTrackedEventIDs = 512
}
struct QueuedUplink {
let depositor: PeerID
let geohash: String
let event: NostrEvent
let queuedAt: Date
}
static let shared = GatewayService()
/// The user toggle. While true this device advertises `.gateway` and
/// bridges mesh <-> Nostr for geohash channels.
@Published private(set) var isEnabled: Bool
// MARK: Wiring (set once by the bootstrapper; fakes in tests)
/// Publishes a verified event to the geo relays for a geohash.
var publishToRelays: (@MainActor (NostrEvent, String) -> Void)?
/// Broadcasts an encoded `fromGateway` carrier payload on the mesh.
var broadcastToMesh: (@MainActor (Data) -> Void)?
/// Sends an encoded `toGateway` carrier payload directed to a gateway
/// peer. Returns false when the transport could not accept it.
var sendToGatewayPeer: (@MainActor (Data, PeerID) -> Bool)?
/// Reachable mesh peers currently advertising the `.gateway` capability.
var availableGatewayPeers: (@MainActor () -> [PeerID])?
/// Whether any Nostr relay connection is currently working.
var relaysConnected: (@MainActor () -> Bool)?
/// The geohash channel the local user is viewing, if any.
var currentGeohash: (@MainActor () -> String?)?
/// Injects a verified carried event into the same inbound pipeline as
/// relay-received events (blocking, rate limits, dedup, rendering).
var injectInbound: (@MainActor (NostrEvent) -> Void)?
/// Fired on toggle changes (advertise/withdraw the capability bit and
/// force a re-announce).
var onEnabledChanged: (@MainActor (Bool) -> Void)?
/// Schedules a downlink-drain closure to run after a delay. Injected so
/// the drain timer is deterministic in tests; nil arms a real `Task`.
var scheduleDrainTimer: (@MainActor (TimeInterval, @escaping @MainActor () -> Void) -> Void)?
// MARK: State
/// Loop rule 1: event IDs seen in `fromGateway` mesh broadcasts.
private var meshBroadcastEventIDs: BoundedIDSet
/// Loop rule 2 (uplink): event IDs this gateway already published.
private var publishedEventIDs: BoundedIDSet
/// Loop rule 2 (downlink): event IDs this gateway already rebroadcast.
private var rebroadcastEventIDs: BoundedIDSet
private(set) var queuedUplinks: [QueuedUplink] = []
private var uplinkDepositTimes: [PeerID: [Date]] = [:]
private var downlinkSendTimes: [Date] = []
private var pendingDownlinks: [(event: NostrEvent, geohash: String)] = []
/// True while a drain timer is armed, so a burst schedules at most one.
private var downlinkDrainScheduled = false
private let defaults: UserDefaults
private let now: () -> Date
private static let enabledKey = "gateway.userEnabled"
init(defaults: UserDefaults = .standard, now: @escaping () -> Date = Date.init) {
self.defaults = defaults
self.now = now
self.isEnabled = defaults.bool(forKey: Self.enabledKey)
self.meshBroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
self.publishedEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
self.rebroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
}
// MARK: - Toggle
func setEnabled(_ enabled: Bool) {
guard enabled != isEnabled else { return }
isEnabled = enabled
defaults.set(enabled, forKey: Self.enabledKey)
if !enabled {
queuedUplinks.removeAll()
pendingDownlinks.removeAll()
uplinkDepositTimes.removeAll()
}
SecureLogger.info("🌐 Gateway mode \(enabled ? "enabled" : "disabled")", category: .session)
onEnabledChanged?(enabled)
}
// MARK: - Mesh carrier ingress (both roles)
/// Entry point for received `nostrCarrier` packets. `directedToUs` is
/// true for packets addressed to this device (uplink deposits); false
/// for broadcasts (downlink rebroadcasts from a gateway).
func handleMeshCarrier(_ payload: Data, from peerID: PeerID, directedToUs: Bool) {
guard let carrier = NostrCarrierPacket.decode(payload) else {
SecureLogger.debug("🌐 Gateway: dropping undecodable carrier from \(peerID.id.prefix(8))", category: .session)
return
}
switch carrier.direction {
case .toGateway:
// Uplink deposits are directed; a broadcast toGateway is malformed.
guard directedToUs else { return }
handleUplinkDeposit(carrier, from: peerID)
case .fromGateway:
// Downlink rides broadcast only; a directed fromGateway is malformed.
guard !directedToUs else { return }
handleDownlinkBroadcast(carrier)
}
}
// MARK: - Uplink (gateway role: mesh peer -> internet)
private func handleUplinkDeposit(_ carrier: NostrCarrierPacket, from depositor: PeerID) {
guard isEnabled else { return }
// Cheap structural checks first (parse, size, geohash, kind, #g tag,
// age) no crypto so junk and stale replays are dropped before we
// ever pay for a MainActor Schnorr verify.
guard let event = structurallyValidEvent(from: carrier) else {
SecureLogger.debug("🌐 Gateway: rejected uplink deposit from \(depositor.id.prefix(8))… (failed validation)", category: .security)
return
}
// Dedup by the carried event ID BEFORE verification. Loop rule 1: a
// fromGateway-learned event is mesh-carried and must never be
// re-published. Loop rule 2: repeat deposits of an already handled
// event are absorbed. A replay of one valid deposit is short-circuited
// here without a per-packet signature verify.
guard !meshBroadcastEventIDs.contains(event.id),
!publishedEventIDs.contains(event.id),
!queuedUplinks.contains(where: { $0.event.id == event.id }) else {
return
}
// Consume the per-depositor rate token BEFORE the expensive verify so
// a flood of distinct forged/junk deposits is bounded by cheap work,
// not by main-actor Schnorr verifications.
guard allowUplinkDeposit(from: depositor) else {
SecureLogger.debug("🌐 Gateway: rate-limited uplink deposit from \(depositor.id.prefix(8))", category: .session)
return
}
// Only now pay for cryptographic verification; receivers verify again.
guard event.isValidSignature() else {
SecureLogger.debug("🌐 Gateway: rejected uplink deposit from \(depositor.id.prefix(8))… (bad signature)", category: .security)
return
}
let accepted: Bool
if relaysConnected?() ?? false {
publish(event, geohash: carrier.geohash)
accepted = true
} else {
accepted = enqueueUplink(QueuedUplink(depositor: depositor, geohash: carrier.geohash, event: event, queuedAt: now()))
}
// Only render on our own timeline what we actually accepted for
// publish or queue: a quota-dropped deposit is never published and,
// being directed, no other peer will ever see it, so showing it would
// diverge our timeline permanently from what reached the channel.
if accepted, currentGeohash?() == carrier.geohash {
injectInbound?(event)
}
}
/// Publish everything queued while relays were unreachable. Called when
/// relay connectivity comes back.
func flushQueuedUplinks() {
guard isEnabled, relaysConnected?() ?? false, !queuedUplinks.isEmpty else { return }
let queued = queuedUplinks
queuedUplinks.removeAll()
for item in queued where !publishedEventIDs.contains(item.event.id) {
publish(item.event, geohash: item.geohash)
}
}
private func publish(_ event: NostrEvent, geohash: String) {
publishedEventIDs.insert(event.id)
publishToRelays?(event, geohash)
SecureLogger.info("🌐 Gateway: published carried event \(event.id.prefix(8))… to relays for #\(geohash)", category: .session)
}
/// Returns true when the item was actually stored for later publish.
@discardableResult
private func enqueueUplink(_ item: QueuedUplink) -> Bool {
let fromDepositor = queuedUplinks.filter { $0.depositor == item.depositor }.count
guard fromDepositor < Limits.maxQueuedUplinksPerDepositor else {
SecureLogger.debug("🌐 Gateway: uplink queue quota reached for \(item.depositor.id.prefix(8))", category: .session)
return false
}
if queuedUplinks.count >= Limits.maxQueuedUplinks {
queuedUplinks.removeFirst(queuedUplinks.count - Limits.maxQueuedUplinks + 1)
}
queuedUplinks.append(item)
return true
}
private func allowUplinkDeposit(from depositor: PeerID) -> Bool {
let cutoff = now().addingTimeInterval(-60)
var times = uplinkDepositTimes[depositor, default: []]
times.removeAll { $0 < cutoff }
guard times.count < Limits.uplinkEventsPerMinutePerDepositor else {
uplinkDepositTimes[depositor] = times
return false
}
times.append(now())
uplinkDepositTimes[depositor] = times
// Bound the tracker itself against a churn of spoofed depositors.
if uplinkDepositTimes.count > Limits.maxTrackedEventIDs {
uplinkDepositTimes = uplinkDepositTimes.filter { !$0.value.isEmpty && $0.value.contains { $0 >= cutoff } }
}
return true
}
// MARK: - Downlink (gateway role: internet -> mesh)
/// Called for every event the gateway's own geohash-channel subscription
/// delivers. Wraps it in a `fromGateway` carrier and broadcasts it on
/// the mesh, within the airtime budget.
func rebroadcastRelayEvent(_ event: NostrEvent, geohash: String) {
guard isEnabled, broadcastToMesh != nil else { return }
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
// Freshness + geohash gate BEFORE spending any budget. A channel
// (re)subscribe backfills up to an hour of history (limit 200), but
// every receiver's `validatedEvent` drops anything older than the
// same window so rebroadcasting backfill would burn the whole
// per-minute budget on events no mesh peer accepts. Also require the
// event's own `#g` tag to match the carrier geohash.
guard isFresh(event),
event.tags.contains(where: { $0.count >= 2 && $0[0] == "g" && $0[1] == geohash }) else {
return
}
// Loop rule 1: never rebroadcast mesh-carried events back onto the
// mesh. Loop rule 2 (self-echo): never rebroadcast an event this
// gateway itself uplinked (`publishedEventIDs`) it originated on this
// very mesh, so our own relay subscription echoing it back must not
// double the BLE airtime by pushing it out again. Loop rule 2
// (downlink): rebroadcast each genuine inbound relay event at most once
// but mark only AFTER it is actually sent (in `drainPendingDownlinks`),
// so an event dropped by the queue overflow stays retryable on relay
// redelivery. Guard against a redelivery re-queueing an event that is
// still waiting to be sent.
guard !meshBroadcastEventIDs.contains(event.id),
!publishedEventIDs.contains(event.id),
!rebroadcastEventIDs.contains(event.id),
!pendingDownlinks.contains(where: { $0.event.id == event.id }) else {
return
}
// Verify before spending BLE airtime; receivers verify again.
guard event.isValidSignature() else { return }
pendingDownlinks.append((event, geohash))
if pendingDownlinks.count > Limits.maxPendingDownlinks {
// Bandwidth guard: drop-oldest fresher chat is worth more. The
// dropped event is not yet in `rebroadcastEventIDs`, so a later
// relay redelivery can still carry it.
pendingDownlinks.removeFirst(pendingDownlinks.count - Limits.maxPendingDownlinks)
}
drainPendingDownlinks()
}
private func drainPendingDownlinks() {
let cutoff = now().addingTimeInterval(-60)
downlinkSendTimes.removeAll { $0 < cutoff }
while !pendingDownlinks.isEmpty,
downlinkSendTimes.count < Limits.downlinkEventsPerMinute {
let (event, geohash) = pendingDownlinks.removeFirst()
// A queued event may have aged past the window while it waited;
// don't burn airtime on what receivers would now drop.
guard isFresh(event) else { continue }
guard let carrier = NostrCarrierPacket(direction: .fromGateway, geohash: geohash, event: event),
let payload = carrier.encode() else { continue }
broadcastToMesh?(payload)
// Mark-after-send: only now is the relay event definitively
// rebroadcast (loop rule 2).
rebroadcastEventIDs.insert(event.id)
downlinkSendTimes.append(now())
}
// Budget exhausted with events still queued: arm a timer to drain when
// the window frees, instead of stranding them until the next inbound
// relay event (which may never come on a channel that went quiet).
scheduleDownlinkDrainIfNeeded()
}
/// Arms a single timer to drain the backlog once the per-minute window
/// frees. No-op when nothing is pending or a drain is already scheduled.
private func scheduleDownlinkDrainIfNeeded() {
guard !pendingDownlinks.isEmpty, !downlinkDrainScheduled else { return }
// The window frees when the oldest recorded send ages out of 60s.
let oldest = downlinkSendTimes.min() ?? now()
let delay = max(0.05, 60 - now().timeIntervalSince(oldest))
downlinkDrainScheduled = true
let fire: @MainActor () -> Void = { [weak self] in
guard let self else { return }
self.downlinkDrainScheduled = false
self.drainPendingDownlinks()
}
if let scheduleDrainTimer {
scheduleDrainTimer(delay, fire)
} else {
Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
fire()
}
}
}
// MARK: - Downlink (receiver role: carried event arrives over mesh)
private func handleDownlinkBroadcast(_ carrier: NostrCarrierPacket) {
guard let event = validatedEvent(from: carrier) else { return }
// Mark only AFTER signature verification, so a forged copy carrying a
// real event's ID cannot poison the never-republish set, and use the
// marking as dedup: the same broadcast relayed along several mesh
// paths injects once (the pipeline's Nostr event-ID cache additionally
// dedups against our own relay subscription).
guard meshBroadcastEventIDs.insert(event.id) else { return }
// Only inject events for the channel we're viewing; the inbound
// pipeline files public messages under the current geohash.
guard currentGeohash?() == carrier.geohash else { return }
injectInbound?(event)
}
// MARK: - Uplink (sender role: mesh-only peer with no relays)
/// Hands a locally signed event to a mesh gateway peer when we have no
/// working relay connection. Returns true when the event was sent.
///
/// v1 is deliberately fire-and-forget: no gateway ack. The event also
/// stays in `NostrRelayManager`'s own pending queue, so if our internet
/// comes back the relays dedup the duplicate publish by event ID.
///
/// Loop rule 3: call sites only pass freshly composed events (see
/// `GeohashSubscriptionManager.sendGeohash`); received carrier events
/// never reach this path, and the mesh-carried guard below backstops it.
func uplinkViaMesh(event: NostrEvent, geohash: String) -> Bool {
if relaysConnected?() ?? true { return false }
guard !meshBroadcastEventIDs.contains(event.id),
!publishedEventIDs.contains(event.id) else {
return false
}
// A single gateway is enough relays fan out from there, and BLE
// airtime is precious.
guard let gateway = availableGatewayPeers?().first else { return false }
guard let carrier = NostrCarrierPacket(direction: .toGateway, geohash: geohash, event: event),
let payload = carrier.encode() else {
return false
}
guard sendToGatewayPeer?(payload, gateway) ?? false else { return false }
SecureLogger.info("🌐 Gateway: uplinked event \(event.id.prefix(8))… for #\(geohash) via mesh gateway \(gateway.id.prefix(8))", category: .session)
return true
}
// MARK: - Validation
/// Structural and cryptographic checks every carried event must pass
/// before a gateway publishes it or a receiver displays it. Ordered
/// cheap-first; Schnorr verification runs last.
private func validatedEvent(from carrier: NostrCarrierPacket) -> NostrEvent? {
guard let event = structurallyValidEvent(from: carrier),
event.isValidSignature() else {
return nil
}
return event
}
/// The cheap half of `validatedEvent`: parse + size + geohash + kind +
/// `#g` tag + freshness, with NO signature verification. Callers that can
/// dedup or rate-limit on the carried ID run this first so the expensive
/// Schnorr verify is reached only for events that survive the cheap gates.
private func structurallyValidEvent(from carrier: NostrCarrierPacket) -> NostrEvent? {
guard carrier.eventJSON.count <= NostrCarrierPacket.maxEventJSONBytes,
Self.isValidGeohash(carrier.geohash),
let event = carrier.event(),
event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue,
event.tags.contains(where: { $0.count >= 2 && $0[0] == "g" && $0[1] == carrier.geohash }),
isFresh(event) else {
return nil
}
return event
}
/// True when `event.created_at` is within the accepted clock skew the
/// SAME freshness window receivers enforce, so a gateway never spends
/// airtime on events every receiver would drop as stale.
private func isFresh(_ event: NostrEvent) -> Bool {
abs(now().timeIntervalSince1970 - TimeInterval(event.created_at)) <= Limits.maxEventAgeSeconds
}
static func isValidGeohash(_ geohash: String) -> Bool {
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
return (1...NostrCarrierPacket.maxGeohashLength).contains(geohash.count)
&& geohash.allSatisfy { allowed.contains($0) }
}
}
/// Insertion-ordered string set with a fixed capacity; the oldest entry is
/// evicted when full.
private struct BoundedIDSet {
private var members: Set<String> = []
private var order: [String] = []
let capacity: Int
init(capacity: Int) {
self.capacity = capacity
}
func contains(_ id: String) -> Bool {
members.contains(id)
}
/// Returns false when the ID was already present.
@discardableResult
mutating func insert(_ id: String) -> Bool {
guard members.insert(id).inserted else { return false }
order.append(id)
if order.count > capacity {
members.remove(order.removeFirst())
}
return true
}
}
+569
View File
@@ -0,0 +1,569 @@
//
// GroupProtocol.swift
// bitchat
//
// Wire formats and crypto for private groups: creator-signed group state
// (invites and key updates over Noise) and ChaCha20-Poly1305 group messages
// broadcast as MessageType.groupMessage (0x25).
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import CryptoKit
import Foundation
// MARK: - Models
/// A member of a private group as pinned in the creator-signed roster.
struct GroupMember: Codable, Equatable {
/// SHA-256 fingerprint (64 hex chars) of the member's Noise static key.
let fingerprint: String
/// The member's Ed25519 signing public key (32 bytes, from their announce).
let signingKey: Data
/// Nickname at invite time; display fallback when the peer is offline.
var nickname: String
}
/// Creator-managed encrypted group. Metadata only the symmetric key lives
/// in the keychain (see `GroupStore`).
struct BitchatGroup: Codable, Equatable {
static let maxMembers = 16
static let groupIDLength = 16
static let keyLength = 32
/// 16 random bytes; travels in cleartext on group message packets so
/// relays can dedup/filter without membership.
let groupID: Data
var name: String
/// Bumps on every key rotation; messages are bound to the epoch they
/// were sealed under.
var epoch: UInt32
var members: [GroupMember]
/// Fingerprint of the creator the only identity allowed to sign group
/// state (invites, key updates) in v1.
let creatorFingerprint: String
/// Virtual conversation ID this group's chat is keyed under.
var peerID: PeerID { PeerID(groupID: groupID) }
var creator: GroupMember? {
members.first { $0.fingerprint == creatorFingerprint }
}
func isMember(fingerprint: String) -> Bool {
members.contains { $0.fingerprint == fingerprint }
}
func member(withSigningKey signingKey: Data) -> GroupMember? {
members.first { $0.signingKey == signingKey }
}
}
// MARK: - TLV helpers
enum GroupTLVError: Error, Equatable {
/// A TLV value exceeded the 16-bit length field. Encoding fails instead
/// of silently truncating (which would ship a value the receiver drops).
case valueTooLong
}
private enum GroupTLV {
/// Appends a (type, 16-bit length, value) triple. Throws rather than
/// truncating when `value` does not fit the 16-bit length field, so an
/// oversize field surfaces a send failure instead of a silently truncated
/// blob the recipient rejects during decrypt/verify.
static func put(_ type: UInt8, _ value: Data, into out: inout Data) throws {
guard value.count <= Int(UInt16.max) else { throw GroupTLVError.valueTooLong }
out.append(type)
let length = UInt16(value.count)
out.append(UInt8((length >> 8) & 0xFF))
out.append(UInt8(length & 0xFF))
out.append(value)
}
/// Iterates (type, value) pairs; returns nil on malformed framing.
static func parse(_ data: Data) -> [(type: UInt8, value: Data)]? {
var fields: [(UInt8, Data)] = []
var offset = data.startIndex
while offset < data.endIndex {
guard data.distance(from: offset, to: data.endIndex) >= 3 else { return nil }
let type = data[offset]
let high = Int(data[data.index(offset, offsetBy: 1)])
let low = Int(data[data.index(offset, offsetBy: 2)])
let length = (high << 8) | low
let valueStart = data.index(offset, offsetBy: 3)
guard data.distance(from: valueStart, to: data.endIndex) >= length else { return nil }
let valueEnd = data.index(valueStart, offsetBy: length)
fields.append((type, Data(data[valueStart..<valueEnd])))
offset = valueEnd
}
return fields
}
static func epochData(_ epoch: UInt32) -> Data {
var bigEndian = epoch.bigEndian
return withUnsafeBytes(of: &bigEndian) { Data($0) }
}
static func epoch(from data: Data) -> UInt32? {
guard data.count == 4 else { return nil }
return data.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
}
static func timestampData(_ timestampMs: UInt64) -> Data {
var bigEndian = timestampMs.bigEndian
return withUnsafeBytes(of: &bigEndian) { Data($0) }
}
static func timestamp(from data: Data) -> UInt64? {
guard data.count == 8 else { return nil }
return data.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) }
}
}
// MARK: - Roster wire form
enum GroupRosterCoding {
private static let fingerprintLength = 32
private static let signingKeyLength = 32
private static let maxNicknameBytes = 64
/// Deterministic roster blob: count byte, then per member the raw 32-byte
/// fingerprint, 32-byte signing key, and length-prefixed UTF-8 nickname.
/// The creator signature covers the SHA-256 of these exact bytes.
static func encode(_ members: [GroupMember]) -> Data? {
guard members.count <= BitchatGroup.maxMembers else { return nil }
var out = Data([UInt8(members.count)])
for member in members {
guard let fingerprintData = Data(hexString: member.fingerprint),
fingerprintData.count == fingerprintLength,
member.signingKey.count == signingKeyLength else { return nil }
out.append(fingerprintData)
out.append(member.signingKey)
// Truncate on a Character boundary so the byte prefix is always
// valid UTF-8; a raw byte-prefix could split a multi-byte scalar
// and make the whole signed roster undecodable on the recipient.
let nickname = truncatedNicknameBytes(member.nickname)
out.append(UInt8(nickname.count))
out.append(nickname)
}
return out
}
static func decode(_ data: Data) -> [GroupMember]? {
guard let count = data.first, count <= UInt8(BitchatGroup.maxMembers) else { return nil }
var members: [GroupMember] = []
var offset = data.index(after: data.startIndex)
for _ in 0..<count {
let fixed = fingerprintLength + signingKeyLength + 1
guard data.distance(from: offset, to: data.endIndex) >= fixed else { return nil }
let fingerprintEnd = data.index(offset, offsetBy: fingerprintLength)
let fingerprint = Data(data[offset..<fingerprintEnd]).hexEncodedString()
let signingKeyEnd = data.index(fingerprintEnd, offsetBy: signingKeyLength)
let signingKey = Data(data[fingerprintEnd..<signingKeyEnd])
let nickLength = Int(data[signingKeyEnd])
let nickStart = data.index(after: signingKeyEnd)
guard data.distance(from: nickStart, to: data.endIndex) >= nickLength else { return nil }
let nickEnd = data.index(nickStart, offsetBy: nickLength)
guard let nickname = String(data: Data(data[nickStart..<nickEnd]), encoding: .utf8) else { return nil }
members.append(GroupMember(fingerprint: fingerprint, signingKey: signingKey, nickname: nickname))
offset = nickEnd
}
guard offset == data.endIndex else { return nil }
return members
}
/// UTF-8 bytes of `nickname` trimmed to at most `maxNicknameBytes`,
/// dropping whole Characters so the result is never split mid-scalar.
private static func truncatedNicknameBytes(_ nickname: String) -> Data {
var candidate = nickname
while Data(candidate.utf8).count > maxNicknameBytes {
candidate.removeLast()
}
return Data(candidate.utf8)
}
}
// MARK: - Group state payload (groupInvite / groupKeyUpdate over Noise)
/// Creator-signed group state. The same wire form serves invites (0x06) and
/// key updates (0x07); receivers verify the creator signature computed over
/// "bitchat-group-v1" | groupID | epoch | SHA256(key) | SHA256(roster)
/// against the creator's signing key pinned in the roster, and require the
/// Noise session peer to BE the creator before accepting any state.
struct GroupStatePayload: Equatable {
let groupID: Data
let name: String
/// Symmetric ChaCha20-Poly1305 group key (32 bytes) for `epoch`.
let key: Data
let epoch: UInt32
let members: [GroupMember]
let creatorFingerprint: String
/// Ed25519 signature by the creator.
let signature: Data
private enum FieldType: UInt8 {
case groupID = 0x01
case name = 0x02
case key = 0x03
case epoch = 0x04
case roster = 0x05
case creatorFingerprint = 0x06
case signature = 0x07
}
static let signingDomain = Data("bitchat-group-v1".utf8)
/// The bytes the creator signs. Binding the key, roster, and name by hash
/// keeps the signed content fixed-size. The name is covered so a relay
/// that caches/replays a signed state (e.g. store-and-forward) cannot swap
/// the display name while keeping a valid creator signature.
static func signingContent(groupID: Data, epoch: UInt32, key: Data, rosterBlob: Data, name: String) -> Data {
var content = signingDomain
content.append(groupID)
content.append(GroupTLV.epochData(epoch))
content.append(key.sha256Hash())
content.append(rosterBlob.sha256Hash())
content.append(Data(name.utf8).sha256Hash())
return content
}
/// Builds a signed state payload. Returns nil when the roster cannot be
/// encoded (over cap, malformed member) or signing fails.
static func makeSigned(
group: BitchatGroup,
key: Data,
sign: (Data) -> Data?
) -> GroupStatePayload? {
guard let rosterBlob = GroupRosterCoding.encode(group.members) else { return nil }
let content = signingContent(groupID: group.groupID, epoch: group.epoch, key: key, rosterBlob: rosterBlob, name: group.name)
guard let signature = sign(content) else { return nil }
return GroupStatePayload(
groupID: group.groupID,
name: group.name,
key: key,
epoch: group.epoch,
members: group.members,
creatorFingerprint: group.creatorFingerprint,
signature: signature
)
}
func encode() -> Data? {
guard let rosterBlob = GroupRosterCoding.encode(members),
let fingerprintData = Data(hexString: creatorFingerprint),
fingerprintData.count == 32 else { return nil }
var out = Data()
do {
try GroupTLV.put(FieldType.groupID.rawValue, groupID, into: &out)
try GroupTLV.put(FieldType.name.rawValue, Data(name.utf8), into: &out)
try GroupTLV.put(FieldType.key.rawValue, key, into: &out)
try GroupTLV.put(FieldType.epoch.rawValue, GroupTLV.epochData(epoch), into: &out)
try GroupTLV.put(FieldType.roster.rawValue, rosterBlob, into: &out)
try GroupTLV.put(FieldType.creatorFingerprint.rawValue, fingerprintData, into: &out)
try GroupTLV.put(FieldType.signature.rawValue, signature, into: &out)
} catch {
return nil
}
return out
}
static func decode(_ data: Data) -> GroupStatePayload? {
guard let fields = GroupTLV.parse(data) else { return nil }
var groupID: Data?
var name: String?
var key: Data?
var epoch: UInt32?
var rosterBlob: Data?
var members: [GroupMember]?
var creatorFingerprint: String?
var signature: Data?
for (type, value) in fields {
switch FieldType(rawValue: type) {
case .groupID where value.count == BitchatGroup.groupIDLength:
groupID = value
case .name:
name = String(data: value, encoding: .utf8)
case .key where value.count == BitchatGroup.keyLength:
key = value
case .epoch:
epoch = GroupTLV.epoch(from: value)
case .roster:
rosterBlob = value
members = GroupRosterCoding.decode(value)
case .creatorFingerprint where value.count == 32:
creatorFingerprint = value.hexEncodedString()
case .signature where value.count == 64:
signature = value
default:
break // forward compatible; ignore unknown TLVs
}
}
guard let groupID, let name, let key, let epoch,
rosterBlob != nil, let members, !members.isEmpty,
let creatorFingerprint, let signature else { return nil }
return GroupStatePayload(
groupID: groupID,
name: name,
key: key,
epoch: epoch,
members: members,
creatorFingerprint: creatorFingerprint,
signature: signature
)
}
/// Verifies the creator signature against the creator's signing key
/// pinned in the roster, and that the creator is actually in the roster.
func verifyCreatorSignature() -> Bool {
guard members.count <= BitchatGroup.maxMembers,
let creator = members.first(where: { $0.fingerprint == creatorFingerprint }),
let rosterBlob = GroupRosterCoding.encode(members) else { return false }
let content = GroupStatePayload.signingContent(groupID: groupID, epoch: epoch, key: key, rosterBlob: rosterBlob, name: name)
return GroupCrypto.verify(signature: signature, for: content, publicKey: creator.signingKey)
}
var asGroup: BitchatGroup {
BitchatGroup(
groupID: groupID,
name: name,
epoch: epoch,
members: members,
creatorFingerprint: creatorFingerprint
)
}
}
// MARK: - Group message envelope (MessageType 0x25 payload)
/// Cleartext framing of a group message broadcast. Only the group ID, epoch,
/// and nonce are visible to relays; everything about the message sender,
/// content, timestamps is inside the ChaCha20-Poly1305 ciphertext.
struct GroupMessageEnvelope: Equatable {
let groupID: Data
let epoch: UInt32
let nonce: Data
/// ChaChaPoly ciphertext || 16-byte tag.
let ciphertext: Data
private enum FieldType: UInt8 {
case groupID = 0x01
case epoch = 0x02
case nonce = 0x03
case ciphertext = 0x04
}
func encode() throws -> Data {
var out = Data()
try GroupTLV.put(FieldType.groupID.rawValue, groupID, into: &out)
try GroupTLV.put(FieldType.epoch.rawValue, GroupTLV.epochData(epoch), into: &out)
try GroupTLV.put(FieldType.nonce.rawValue, nonce, into: &out)
try GroupTLV.put(FieldType.ciphertext.rawValue, ciphertext, into: &out)
return out
}
static func decode(_ data: Data) -> GroupMessageEnvelope? {
guard let fields = GroupTLV.parse(data) else { return nil }
var groupID: Data?
var epoch: UInt32?
var nonce: Data?
var ciphertext: Data?
for (type, value) in fields {
switch FieldType(rawValue: type) {
case .groupID where value.count == BitchatGroup.groupIDLength:
groupID = value
case .epoch:
epoch = GroupTLV.epoch(from: value)
case .nonce where value.count == 12:
nonce = value
case .ciphertext where !value.isEmpty:
ciphertext = value
default:
break
}
}
guard let groupID, let epoch, let nonce, let ciphertext else { return nil }
return GroupMessageEnvelope(groupID: groupID, epoch: epoch, nonce: nonce, ciphertext: ciphertext)
}
}
/// Decrypted, signature-verified inner content of a group message.
struct GroupMessagePlaintext: Equatable {
let messageID: String
let senderSigningKey: Data
let senderNickname: String
let timestampMs: UInt64
let content: String
}
// MARK: - Crypto
enum GroupCryptoError: Error, Equatable {
case malformedPayload
case signingFailed
case sealFailed
case wrongEpoch
case decryptionFailed
case badSenderSignature
}
enum GroupCrypto {
static let messageSigningDomain = Data("bitchat-group-msg-v1".utf8)
private enum InnerField: UInt8 {
case messageID = 0x01
case senderSigningKey = 0x02
case senderNickname = 0x03
case timestamp = 0x04
case content = 0x05
case signature = 0x06
}
/// Bytes the sender signs: domain | groupID | epoch | messageID | timestamp | content.
/// Covering the epoch stops a current member from re-sealing another
/// member's decrypted inner bytes under a later epoch key (the signature
/// would no longer verify at the new epoch).
static func messageSigningContent(groupID: Data, epoch: UInt32, messageID: String, timestampMs: UInt64, content: String) -> Data {
var data = messageSigningDomain
data.append(groupID)
data.append(GroupTLV.epochData(epoch))
data.append(Data(messageID.utf8))
data.append(GroupTLV.timestampData(timestampMs))
data.append(Data(content.utf8))
return data
}
static func verify(signature: Data, for data: Data, publicKey: Data) -> Bool {
guard let key = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKey) else { return false }
return key.isValidSignature(signature, for: data)
}
/// Seals a group message: builds the signed inner TLV and encrypts it with
/// the epoch key. The cleartext group ID and epoch are bound into the AEAD
/// as additional data so ciphertext cannot be replayed across groups or
/// epochs. Returns the encoded 0x25 packet payload.
static func sealMessage(
content: String,
messageID: String,
senderNickname: String,
senderSigningKey: Data,
timestampMs: UInt64,
groupID: Data,
epoch: UInt32,
key: Data,
sign: (Data) -> Data?
) throws -> Data {
let signingContent = messageSigningContent(
groupID: groupID,
epoch: epoch,
messageID: messageID,
timestampMs: timestampMs,
content: content
)
guard let signature = sign(signingContent), signature.count == 64 else {
throw GroupCryptoError.signingFailed
}
var inner = Data()
try GroupTLV.put(InnerField.messageID.rawValue, Data(messageID.utf8), into: &inner)
try GroupTLV.put(InnerField.senderSigningKey.rawValue, senderSigningKey, into: &inner)
try GroupTLV.put(InnerField.senderNickname.rawValue, Data(senderNickname.utf8), into: &inner)
try GroupTLV.put(InnerField.timestamp.rawValue, GroupTLV.timestampData(timestampMs), into: &inner)
try GroupTLV.put(InnerField.content.rawValue, Data(content.utf8), into: &inner)
try GroupTLV.put(InnerField.signature.rawValue, signature, into: &inner)
do {
let symmetricKey = SymmetricKey(data: key)
var aad = groupID
aad.append(GroupTLV.epochData(epoch))
let sealed = try ChaChaPoly.seal(inner, using: symmetricKey, authenticating: aad)
var ciphertext = sealed.ciphertext
ciphertext.append(sealed.tag)
let envelope = GroupMessageEnvelope(
groupID: groupID,
epoch: epoch,
nonce: Data(sealed.nonce),
ciphertext: ciphertext
)
return try envelope.encode()
} catch {
throw GroupCryptoError.sealFailed
}
}
/// Opens a group message envelope with the epoch key: decrypts, parses the
/// inner TLV, and verifies the sender's Ed25519 signature. Roster
/// membership of the sender is the CALLER's check this function only
/// proves the payload was authored by `senderSigningKey`.
static func openMessage(_ envelope: GroupMessageEnvelope, key: Data) throws -> GroupMessagePlaintext {
let inner: Data
do {
let symmetricKey = SymmetricKey(data: key)
var aad = envelope.groupID
aad.append(GroupTLV.epochData(envelope.epoch))
let nonce = try ChaChaPoly.Nonce(data: envelope.nonce)
guard envelope.ciphertext.count > 16 else { throw GroupCryptoError.decryptionFailed }
let tag = envelope.ciphertext.suffix(16)
let body = envelope.ciphertext.prefix(envelope.ciphertext.count - 16)
let sealedBox = try ChaChaPoly.SealedBox(nonce: nonce, ciphertext: body, tag: tag)
inner = try ChaChaPoly.open(sealedBox, using: symmetricKey, authenticating: aad)
} catch {
throw GroupCryptoError.decryptionFailed
}
guard let fields = GroupTLV.parse(inner) else { throw GroupCryptoError.malformedPayload }
var messageID: String?
var senderSigningKey: Data?
var senderNickname: String?
var timestampMs: UInt64?
var content: String?
var signature: Data?
for (type, value) in fields {
switch InnerField(rawValue: type) {
case .messageID:
messageID = String(data: value, encoding: .utf8)
case .senderSigningKey where value.count == 32:
senderSigningKey = value
case .senderNickname:
senderNickname = String(data: value, encoding: .utf8)
case .timestamp:
timestampMs = GroupTLV.timestamp(from: value)
case .content:
content = String(data: value, encoding: .utf8)
case .signature where value.count == 64:
signature = value
default:
break
}
}
guard let messageID, !messageID.isEmpty,
let senderSigningKey,
let senderNickname,
let timestampMs,
let content,
let signature else { throw GroupCryptoError.malformedPayload }
let signingContent = messageSigningContent(
groupID: envelope.groupID,
epoch: envelope.epoch,
messageID: messageID,
timestampMs: timestampMs,
content: content
)
guard verify(signature: signature, for: signingContent, publicKey: senderSigningKey) else {
throw GroupCryptoError.badSenderSignature
}
return GroupMessagePlaintext(
messageID: messageID,
senderSigningKey: senderSigningKey,
senderNickname: senderNickname,
timestampMs: timestampMs,
content: content
)
}
}
+194
View File
@@ -0,0 +1,194 @@
//
// GroupStore.swift
// bitchat
//
// Persistence for private groups: symmetric keys in the keychain, metadata
// (roster, name, epoch) as protected JSON in Application Support. Both are
// dropped by the panic wipe.
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import BitLogger
import Combine
import Foundation
import Security
@MainActor
final class GroupStore: ObservableObject {
/// All groups this device is a member of, in creation/join order.
@Published private(set) var groups: [BitchatGroup] = []
private let keychain: KeychainManagerProtocol
private let fileURL: URL?
/// - Parameter fileURL: Overrides the on-disk location (tests). Ignored
/// when `persistsToDisk` is false.
init(keychain: KeychainManagerProtocol, persistsToDisk: Bool = true, fileURL: URL? = nil) {
self.keychain = keychain
self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
loadFromDisk()
}
// MARK: - Reads
func group(withID groupID: Data) -> BitchatGroup? {
groups.first { $0.groupID == groupID }
}
func group(for peerID: PeerID) -> BitchatGroup? {
guard let groupID = peerID.groupIDData else { return nil }
return group(withID: groupID)
}
/// Current-epoch symmetric key for the group, from the keychain.
func key(forGroupID groupID: Data) -> Data? {
keychain.getIdentityKey(forKey: Self.keychainKey(for: groupID))
}
// MARK: - Mutations
/// Creates a new group with a random 16-byte ID and 32-byte key at
/// epoch 1, with the creator as sole member. Returns nil when key
/// generation or persistence fails.
func createGroup(named name: String, creator: GroupMember) -> BitchatGroup? {
guard let groupID = Self.randomBytes(BitchatGroup.groupIDLength),
let key = Self.randomBytes(BitchatGroup.keyLength) else { return nil }
let group = BitchatGroup(
groupID: groupID,
name: name,
epoch: 1,
members: [creator],
creatorFingerprint: creator.fingerprint
)
guard upsert(group, key: key) else { return nil }
return group
}
/// Inserts or replaces a group and its current key. Rejects rosters over
/// the hard cap or groups whose creator is missing from the roster.
@discardableResult
func upsert(_ group: BitchatGroup, key: Data) -> Bool {
guard group.groupID.count == BitchatGroup.groupIDLength,
key.count == BitchatGroup.keyLength,
!group.members.isEmpty,
group.members.count <= BitchatGroup.maxMembers,
group.creator != nil else { return false }
guard keychain.saveIdentityKey(key, forKey: Self.keychainKey(for: group.groupID)) else {
SecureLogger.error("Failed to store group key in keychain", category: .security)
return false
}
if let index = groups.firstIndex(where: { $0.groupID == group.groupID }) {
groups[index] = group
} else {
groups.append(group)
}
persist()
return true
}
/// Updates the roster of an existing group without changing key or epoch
/// (creator-side invite). Enforces the member cap.
@discardableResult
func updateRoster(groupID: Data, members: [GroupMember]) -> BitchatGroup? {
guard let index = groups.firstIndex(where: { $0.groupID == groupID }),
!members.isEmpty,
members.count <= BitchatGroup.maxMembers,
members.contains(where: { $0.fingerprint == groups[index].creatorFingerprint }) else { return nil }
groups[index].members = members
persist()
return groups[index]
}
/// Rotates the group key (creator-side removal/rotation): new random key,
/// epoch + 1, and the given roster. Returns the updated group and new key.
func rotateKey(groupID: Data, members: [GroupMember]) -> (group: BitchatGroup, key: Data)? {
guard let existing = group(withID: groupID),
let newKey = Self.randomBytes(BitchatGroup.keyLength) else { return nil }
var rotated = existing
rotated.epoch = existing.epoch &+ 1
rotated.members = members
guard upsert(rotated, key: newKey) else { return nil }
return (rotated, newKey)
}
func removeGroup(withID groupID: Data) {
groups.removeAll { $0.groupID == groupID }
_ = keychain.deleteIdentityKey(forKey: Self.keychainKey(for: groupID))
persist()
}
/// Panic wipe: drop all group keys and metadata from memory and disk.
/// (The panic flow also nukes the whole keychain; deleting per-group keys
/// here keeps the store safe to wipe on its own.)
func wipe() {
for group in groups {
_ = keychain.deleteIdentityKey(forKey: Self.keychainKey(for: group.groupID))
}
groups.removeAll()
if let fileURL {
try? FileManager.default.removeItem(at: fileURL)
}
}
// MARK: - Internals
private static func keychainKey(for groupID: Data) -> String {
"groupKey-\(groupID.hexEncodedString())"
}
private static func randomBytes(_ count: Int) -> Data? {
var bytes = Data(count: count)
let status = bytes.withUnsafeMutableBytes { buffer -> OSStatus in
guard let baseAddress = buffer.baseAddress else { return errSecParam }
return SecRandomCopyBytes(kSecRandomDefault, count, baseAddress)
}
return status == errSecSuccess ? bytes : nil
}
private func persist() {
guard let fileURL else { return }
do {
if groups.isEmpty {
try? FileManager.default.removeItem(at: fileURL)
return
}
try FileManager.default.createDirectory(
at: fileURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
let data = try JSONEncoder().encode(groups)
var options: Data.WritingOptions = [.atomic]
#if os(iOS)
options.insert(.completeFileProtection)
#endif
try data.write(to: fileURL, options: options)
} catch {
SecureLogger.error("Failed to persist group store: \(error)", category: .session)
}
}
private func loadFromDisk() {
guard let fileURL,
let data = try? Data(contentsOf: fileURL),
let stored = try? JSONDecoder().decode([BitchatGroup].self, from: data) else {
return
}
// Only groups whose key survived in the keychain are usable.
groups = stored.filter { key(forGroupID: $0.groupID) != nil }
}
private static func defaultFileURL() -> URL? {
guard let base = try? FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
) else { return nil }
return base
.appendingPathComponent("groups", isDirectory: true)
.appendingPathComponent("groups.json")
}
}
+46 -3
View File
@@ -15,7 +15,50 @@ final class KeychainManager: KeychainManagerProtocol {
// Use consistent service name for all keychain items // Use consistent service name for all keychain items
private let service = BitchatApp.bundleID private let service = BitchatApp.bundleID
private let appGroup = "group.\(BitchatApp.bundleID)" private let appGroup = "group.\(BitchatApp.bundleID)"
// AfterFirstUnlock, not WhenUnlocked: the mesh keeps running with the
// device locked (identity-cache saves failed with -25308 throughout
// locked-phone testing), and a wake-on-proximity relaunch via BLE state
// restoration must be able to read the noise keys before the user
// unlocks. Backup/sync semantics are unchanged (not ThisDeviceOnly).
private static let itemAccessibility = kSecAttrAccessibleAfterFirstUnlock
init() {
#if os(iOS)
migrateAccessibilityIfNeeded()
#endif
}
#if os(iOS)
/// One-time upgrade of items created under WhenUnlocked. New saves get
/// the right class on their own (saves are delete-then-add), but the
/// long-lived identity keys are written once and would otherwise stay
/// unreadable while the device is locked.
private func migrateAccessibilityIfNeeded() {
let flag = "keychain.accessibility.afterFirstUnlock.migrated"
guard !UserDefaults.standard.bool(forKey: flag) else { return }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service
]
let update: [String: Any] = [
kSecAttrAccessible as String: Self.itemAccessibility
]
let status = SecItemUpdate(query as CFDictionary, update as CFDictionary)
switch status {
case errSecSuccess, errSecItemNotFound:
// Nothing to migrate on a fresh install; both are terminal.
UserDefaults.standard.set(true, forKey: flag)
SecureLogger.info("Keychain accessibility migrated to AfterFirstUnlock (status \(status))", category: .keychain)
default:
// Likely errSecInteractionNotAllowed (relaunched while locked)
// leave the flag unset so the next launch retries.
SecureLogger.warning("Keychain accessibility migration deferred (status \(status))", category: .keychain)
}
}
#endif
// MARK: - Identity Keys // MARK: - Identity Keys
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
@@ -62,7 +105,7 @@ final class KeychainManager: KeychainManagerProtocol {
kSecAttrAccount as String: key, kSecAttrAccount as String: key,
kSecValueData as String: data, kSecValueData as String: data,
kSecAttrService as String: service, kSecAttrService as String: service,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked, kSecAttrAccessible as String: Self.itemAccessibility,
kSecAttrLabel as String: "bitchat-\(key)" kSecAttrLabel as String: "bitchat-\(key)"
] ]
#if os(macOS) #if os(macOS)
@@ -227,7 +270,7 @@ final class KeychainManager: KeychainManagerProtocol {
kSecAttrAccount as String: key, kSecAttrAccount as String: key,
kSecValueData as String: data, kSecValueData as String: data,
kSecAttrService as String: service, kSecAttrService as String: service,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked, kSecAttrAccessible as String: Self.itemAccessibility,
kSecAttrLabel as String: "bitchat-\(key)" kSecAttrLabel as String: "bitchat-\(key)"
] ]
#if os(macOS) #if os(macOS)
+46 -10
View File
@@ -67,6 +67,9 @@ final class LocationNotesManager: ObservableObject {
let content: String let content: String
let createdAt: Date let createdAt: Date
let nickname: String? let nickname: String?
/// The matched `g` tag: the cell the note was posted to, which can be
/// a neighbor of the subscribed geohash.
let geohash: String
var displayName: String { var displayName: String {
let suffix = String(pubkey.suffix(4)) let suffix = String(pubkey.suffix(4))
@@ -82,6 +85,8 @@ final class LocationNotesManager: ObservableObject {
@Published private(set) var initialLoadComplete: Bool = false @Published private(set) var initialLoadComplete: Bool = false
@Published private(set) var state: State = .loading @Published private(set) var state: State = .loading
@Published private(set) var errorMessage: String? @Published private(set) var errorMessage: String?
/// Public key of our per-geohash Nostr identity; identifies our own notes.
private var ownPubkey: String?
private var subscriptionID: String? private var subscriptionID: String?
private var noteIDs = Set<String>() // O(1) duplicate detection private var noteIDs = Set<String>() // O(1) duplicate detection
private var directoryUpdateCancellable: AnyCancellable? private var directoryUpdateCancellable: AnyCancellable?
@@ -104,10 +109,10 @@ final class LocationNotesManager: ObservableObject {
let norm = geohash.lowercased() let norm = geohash.lowercased()
self.geohash = norm self.geohash = norm
self.dependencies = dependencies self.dependencies = dependencies
// Validate geohash (building-level precision: 8 chars) if !Geohash.isValidGeohash(norm) {
if !Geohash.isValidBuildingGeohash(norm) { SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 1-12 valid base32 chars)", category: .session)
SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
} }
ownPubkey = (try? dependencies.deriveIdentity(norm))?.publicKeyHex
subscribe() subscribe()
// The relay directory may load after init (remote fetch over Tor); // The relay directory may load after init (remote fetch over Tor);
// retry automatically instead of staying stuck on "no relays". // retry automatically instead of staying stuck on "no relays".
@@ -123,9 +128,8 @@ final class LocationNotesManager: ObservableObject {
func setGeohash(_ newGeohash: String) { func setGeohash(_ newGeohash: String) {
let norm = newGeohash.lowercased() let norm = newGeohash.lowercased()
guard norm != geohash else { return } guard norm != geohash else { return }
// Validate geohash (building-level precision: 8 chars) guard Geohash.isValidGeohash(norm) else {
guard Geohash.isValidBuildingGeohash(norm) else { SecureLogger.warning("LocationNotesManager: rejecting invalid geohash '\(norm)' (expected 1-12 valid base32 chars)", category: .session)
SecureLogger.warning("LocationNotesManager: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
return return
} }
if let sub = subscriptionID { if let sub = subscriptionID {
@@ -137,6 +141,7 @@ final class LocationNotesManager: ObservableObject {
initialLoadComplete = false initialLoadComplete = false
errorMessage = nil errorMessage = nil
geohash = norm geohash = norm
ownPubkey = (try? dependencies.deriveIdentity(norm))?.publicKeyHex
notes.removeAll() notes.removeAll()
noteIDs.removeAll() noteIDs.removeAll()
subscribe() subscribe()
@@ -193,14 +198,14 @@ final class LocationNotesManager: ObservableObject {
guard let self = self else { return } guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return } guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
// Ensure matching tag - accept any of our 9 geohashes // Ensure matching tag - accept any of our 9 geohashes
guard event.tags.contains(where: { tag in guard let matchedGeohash = event.tags.first(where: { tag in
tag.count >= 2 && tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased()) tag.count >= 2 && tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased())
}) else { return } })?[1].lowercased() else { return }
guard !self.noteIDs.contains(event.id) else { return } guard !self.noteIDs.contains(event.id) else { return }
self.noteIDs.insert(event.id) self.noteIDs.insert(event.id)
let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first
let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at)) let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick) let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick, geohash: matchedGeohash)
self.notes.append(note) self.notes.append(note)
self.notes.sort { $0.createdAt > $1.createdAt } self.notes.sort { $0.createdAt > $1.createdAt }
self.enforceMemoryCap() self.enforceMemoryCap()
@@ -239,7 +244,8 @@ final class LocationNotesManager: ObservableObject {
pubkey: id.publicKeyHex, pubkey: id.publicKeyHex,
content: trimmed, content: trimmed,
createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)), createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)),
nickname: nickname nickname: nickname,
geohash: geohash
) )
self.noteIDs.insert(event.id) self.noteIDs.insert(event.id)
self.notes.insert(echo, at: 0) self.notes.insert(echo, at: 0)
@@ -252,6 +258,36 @@ final class LocationNotesManager: ObservableObject {
} }
} }
/// Whether the note was published by this device's identity for the
/// current geohash (and can therefore be deleted with NIP-09).
func isOwnNote(_ note: Note) -> Bool {
guard let ownPubkey else { return false }
return note.pubkey == ownPubkey
}
/// Requests NIP-09 deletion of one of our own notes and removes it locally.
@discardableResult
func delete(note: Note) -> Bool {
guard isOwnNote(note) else { return false }
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else {
state = .noRelays
errorMessage = Strings.noRelays
return false
}
do {
let identity = try dependencies.deriveIdentity(geohash)
let deletion = try NostrProtocol.createDeleteEvent(ofEventID: note.id, senderIdentity: identity)
dependencies.sendEvent(deletion, relays)
// Keep the id in noteIDs so a relay replay can't resurrect it.
notes.removeAll { $0.id == note.id }
return true
} catch {
SecureLogger.error("LocationNotesManager: failed to delete note: \(error)", category: .session)
return false
}
}
/// Enforces defensive memory cap on notes array (keeps newest). /// Enforces defensive memory cap on notes array (keeps newest).
private func enforceMemoryCap() { private func enforceMemoryCap() {
if notes.count > maxNotesInMemory { if notes.count > maxNotesInMemory {
+43 -8
View File
@@ -10,6 +10,10 @@ final class MeshTopologyTracker {
private var claims: [RoutingID: Set<RoutingID>] = [:] private var claims: [RoutingID: Set<RoutingID>] = [:]
// Last time we received an update from a node // Last time we received an update from a node
private var lastSeen: [RoutingID: Date] = [:] private var lastSeen: [RoutingID: Date] = [:]
// Highest protocol version observed from each node's decoded packets.
// Nodes absent from this map are assumed v1-only and are never used as
// hops (or targets) for version-gated routes.
private var observedVersions: [RoutingID: (version: UInt8, seenAt: Date)] = [:]
// Maximum age for topology claims to be considered fresh for routing // Maximum age for topology claims to be considered fresh for routing
// Routes computed using stale topology can fail when the network has changed // Routes computed using stale topology can fail when the network has changed
@@ -19,48 +23,75 @@ final class MeshTopologyTracker {
queue.sync(flags: .barrier) { queue.sync(flags: .barrier) {
self.claims.removeAll() self.claims.removeAll()
self.lastSeen.removeAll() self.lastSeen.removeAll()
self.observedVersions.removeAll()
} }
} }
/// Update the topology with a node's self-reported neighbor list /// Update the topology with a node's self-reported neighbor list
func updateNeighbors(for sourceData: Data?, neighbors: [Data]) { func updateNeighbors(for sourceData: Data?, neighbors: [Data], at now: Date = Date()) {
guard let source = sanitize(sourceData) else { return } guard let source = sanitize(sourceData) else { return }
// Sanitize neighbors and exclude self-loops // Sanitize neighbors and exclude self-loops
let validNeighbors = Set(neighbors.compactMap { sanitize($0) }).subtracting([source]) let validNeighbors = Set(neighbors.compactMap { sanitize($0) }).subtracting([source])
queue.sync(flags: .barrier) { queue.sync(flags: .barrier) {
self.claims[source] = validNeighbors self.claims[source] = validNeighbors
self.lastSeen[source] = Date() self.lastSeen[source] = now
} }
} }
/// Record the protocol version observed on a decoded packet from a node.
/// Only versions above the v1 baseline are stored; the highest wins.
func recordObservedVersion(_ version: UInt8, for peerData: Data?, at now: Date = Date()) {
guard version > 1, let peer = sanitize(peerData) else { return }
queue.sync(flags: .barrier) {
let current = self.observedVersions[peer]?.version ?? 1
self.observedVersions[peer] = (version: max(version, current), seenAt: now)
}
}
/// Raw directed neighbor claims, for diagnostics (topology map, /trace).
/// Callers treat the claims as advisory: announces cap `directNeighbors`
/// at 10, so an edge may be claimed by only one of its endpoints.
func adjacencySnapshot() -> [Data: Set<Data>] {
queue.sync { claims }
}
func removePeer(_ data: Data?) { func removePeer(_ data: Data?) {
guard let peer = sanitize(data) else { return } guard let peer = sanitize(data) else { return }
queue.sync(flags: .barrier) { queue.sync(flags: .barrier) {
self.claims.removeValue(forKey: peer) self.claims.removeValue(forKey: peer)
self.lastSeen.removeValue(forKey: peer) self.lastSeen.removeValue(forKey: peer)
self.observedVersions.removeValue(forKey: peer)
} }
} }
/// Prune nodes that haven't updated their topology in `age` seconds /// Prune nodes that haven't updated their topology in `age` seconds
func prune(olderThan age: TimeInterval) { func prune(olderThan age: TimeInterval, now: Date = Date()) {
let deadline = Date().addingTimeInterval(-age) let deadline = now.addingTimeInterval(-age)
queue.sync(flags: .barrier) { queue.sync(flags: .barrier) {
let stale = self.lastSeen.filter { $0.value < deadline } let stale = self.lastSeen.filter { $0.value < deadline }
for (peer, _) in stale { for (peer, _) in stale {
self.claims.removeValue(forKey: peer) self.claims.removeValue(forKey: peer)
self.lastSeen.removeValue(forKey: peer) self.lastSeen.removeValue(forKey: peer)
} }
self.observedVersions = self.observedVersions.filter { $0.value.seenAt >= deadline }
} }
} }
func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 10) -> [Data]? { /// BFS over confirmed, fresh edges. When `requiringVersion` is set, every
/// node on the path except the source (i.e. all intermediate hops and the
/// target) must have been observed speaking at least that protocol
/// version a v1-only hop cannot decode a v2 routed packet.
func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 10, requiringVersion: UInt8? = nil, now: Date = Date()) -> [Data]? {
guard let source = sanitize(start), let target = sanitize(goal) else { return nil } guard let source = sanitize(start), let target = sanitize(goal) else { return nil }
if source == target { return [] } // Direct connection, no intermediate hops if source == target { return [] } // Direct connection, no intermediate hops
return queue.sync { return queue.sync {
let now = Date()
let freshnessDeadline = now.addingTimeInterval(-Self.routeFreshnessThreshold) let freshnessDeadline = now.addingTimeInterval(-Self.routeFreshnessThreshold)
func meetsRequiredVersion(_ peer: RoutingID) -> Bool {
guard let requiringVersion else { return true }
return (observedVersions[peer]?.version ?? 1) >= requiringVersion
}
// BFS // BFS
var visited: Set<RoutingID> = [source] var visited: Set<RoutingID> = [source]
@@ -86,6 +117,10 @@ final class MeshTopologyTracker {
for neighbor in neighbors { for neighbor in neighbors {
if visited.contains(neighbor) { continue } if visited.contains(neighbor) { continue }
// Version gate: skip nodes not known to speak the
// required protocol version.
guard meetsRequiredVersion(neighbor) else { continue }
// CONFIRMED EDGE CHECK: // CONFIRMED EDGE CHECK:
// 'last' claims 'neighbor' (checked above) // 'last' claims 'neighbor' (checked above)
// Does 'neighbor' claim 'last'? // Does 'neighbor' claim 'last'?
@@ -172,17 +172,37 @@ final class MessageDeduplicationService {
/// Cache for Nostr ACK deduplication (messageId:ackType:senderPubkey format) /// Cache for Nostr ACK deduplication (messageId:ackType:senderPubkey format)
private let nostrAckCache: LRUDeduplicationCache<Bool> private let nostrAckCache: LRUDeduplicationCache<Bool>
/// Optional cross-launch persistence for the Nostr event cache. NIP-59
/// randomizes gift-wrap timestamps, so DM subscriptions look back 24h and
/// relays redeliver the same events on every launch; without this record
/// each relaunch reprocesses old PMs and acks. Nil (tests, macOS callers
/// that don't opt in) keeps the cache purely in-memory.
private let nostrEventStore: NostrProcessedEventStore?
private let nostrEventCapacity: Int
private var persistScheduled = false
private var pendingPersistIDs: [String] = []
/// Creates a new deduplication service with specified capacities. /// Creates a new deduplication service with specified capacities.
/// - Parameters: /// - Parameters:
/// - contentCapacity: Max entries for content cache /// - contentCapacity: Max entries for content cache
/// - nostrEventCapacity: Max entries for Nostr event cache /// - nostrEventCapacity: Max entries for Nostr event cache
/// - nostrEventStore: Optional disk store preloading and persisting
/// processed Nostr event IDs across launches
init( init(
contentCapacity: Int = TransportConfig.contentLRUCap, contentCapacity: Int = TransportConfig.contentLRUCap,
nostrEventCapacity: Int = TransportConfig.uiProcessedNostrEventsCap nostrEventCapacity: Int = TransportConfig.uiProcessedNostrEventsCap,
nostrEventStore: NostrProcessedEventStore? = nil
) { ) {
self.contentCache = LRUDeduplicationCache(capacity: contentCapacity) self.contentCache = LRUDeduplicationCache(capacity: contentCapacity)
self.nostrEventCache = LRUDeduplicationCache(capacity: nostrEventCapacity) self.nostrEventCache = LRUDeduplicationCache(capacity: nostrEventCapacity)
self.nostrAckCache = LRUDeduplicationCache(capacity: nostrEventCapacity) self.nostrAckCache = LRUDeduplicationCache(capacity: nostrEventCapacity)
self.nostrEventStore = nostrEventStore
self.nostrEventCapacity = nostrEventCapacity
if let nostrEventStore {
for eventID in nostrEventStore.load() {
nostrEventCache.record(eventID, value: true)
}
}
} }
// MARK: - Content Deduplication // MARK: - Content Deduplication
@@ -239,6 +259,26 @@ final class MessageDeduplicationService {
/// - Parameter eventId: The event ID /// - Parameter eventId: The event ID
func recordNostrEvent(_ eventId: String) { func recordNostrEvent(_ eventId: String) {
nostrEventCache.record(eventId, value: true) nostrEventCache.record(eventId, value: true)
if nostrEventStore != nil {
pendingPersistIDs.append(eventId)
schedulePersistIfNeeded()
}
}
/// Debounced persistence: bursts of inbound events (reconnect redelivery)
/// collapse into one append. Append-merge rather than snapshot, so a
/// transient in-memory clear between flushes can't shrink the disk record.
private func schedulePersistIfNeeded() {
guard let nostrEventStore, !persistScheduled else { return }
persistScheduled = true
Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 2_000_000_000)
guard let self else { return }
self.persistScheduled = false
let newIDs = self.pendingPersistIDs
self.pendingPersistIDs.removeAll()
nostrEventStore.append(newIDs, cap: self.nostrEventCapacity)
}
} }
// MARK: - Nostr ACK Deduplication // MARK: - Nostr ACK Deduplication
@@ -263,14 +303,20 @@ final class MessageDeduplicationService {
// MARK: - Clear // MARK: - Clear
/// Clears all caches /// Clears all caches. This is the wipe/panic path: the persisted
/// gift-wrap record goes with everything else.
func clearAll() { func clearAll() {
contentCache.clear() contentCache.clear()
nostrEventCache.clear() nostrEventCache.clear()
nostrAckCache.clear() nostrAckCache.clear()
pendingPersistIDs.removeAll()
nostrEventStore?.wipe()
} }
/// Clears only the Nostr caches (events and ACKs) /// Clears only the in-memory Nostr caches (events and ACKs). Runs on
/// every geohash channel switch, so the disk record deliberately
/// survives wiping it here would forfeit cross-launch gift-wrap dedup
/// each time the user changes channels (flagged by Codex on #1398).
func clearNostrCaches() { func clearNostrCaches() {
nostrEventCache.clear() nostrEventCache.clear()
nostrAckCache.clear() nostrAckCache.clear()
+203 -16
View File
@@ -2,11 +2,43 @@ import BitLogger
import BitFoundation import BitFoundation
import Foundation import Foundation
/// Trust and identity lookups the router needs to pick couriers. Backed by
/// the favorites store in production; injectable for tests.
struct CourierDirectory {
/// Noise static key for a peer we can address while they're offline.
var noiseKey: (PeerID) -> Data?
/// Whether a peer (by Noise static key) is a mutual favorite the
/// preferred courier tier. Verified non-favorites are the fallback tier,
/// read off the transport snapshot.
var isTrustedCourier: (Data) -> Bool
@MainActor
static func favoritesBacked() -> CourierDirectory {
CourierDirectory(
noiseKey: { peerID in
// Offline favorites are addressed by the full 64-hex
// noise-key ID, which carries the key itself; the favorites
// lookup only resolves short 16-hex IDs.
peerID.noiseKey
?? FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNoisePublicKey
},
isTrustedCourier: { noiseKey in
FavoritesPersistenceService.shared.isMutualFavorite(noiseKey)
}
)
}
}
/// Routes messages using available transports (Mesh, Nostr, etc.) /// Routes messages using available transports (Mesh, Nostr, etc.)
@MainActor @MainActor
final class MessageRouter { final class MessageRouter {
typealias QueuedMessage = MessageOutboxStore.QueuedMessage
private let transports: [Transport] private let transports: [Transport]
private let now: () -> Date private let now: () -> Date
private let courierDirectory: CourierDirectory
private let outboxStore: MessageOutboxStore?
private let metrics: StoreAndForwardMetrics?
/// Invoked whenever a retained private message is dropped without a /// Invoked whenever a retained private message is dropped without a
/// delivery ack (attempt cap, TTL expiry, or per-peer overflow eviction) /// delivery ack (attempt cap, TTL expiry, or per-peer overflow eviction)
@@ -14,14 +46,11 @@ final class MessageRouter {
/// stale "sending/sent" state forever. /// stale "sending/sent" state forever.
var onMessageDropped: ((_ messageID: String, _ peerID: PeerID) -> Void)? var onMessageDropped: ((_ messageID: String, _ peerID: PeerID) -> Void)?
// Outbox entry with timestamp for TTL-based eviction /// Invoked when a message with no reachable transport was handed to at
private struct QueuedMessage { /// least one courier (a connected peer who will physically carry the
let content: String /// sealed envelope). Delivery stays best-effort: the outbox retains the
let nickname: String /// message until an ack arrives.
let messageID: String var onMessageCarried: ((_ messageID: String, _ peerID: PeerID) -> Void)?
let timestamp: Date
var sendAttempts: Int = 0
}
private var outbox: [PeerID: [QueuedMessage]] = [:] private var outbox: [PeerID: [QueuedMessage]] = [:]
@@ -31,10 +60,22 @@ final class MessageRouter {
// Bound resends of messages sent on a weak reachability signal that never // Bound resends of messages sent on a weak reachability signal that never
// get a delivery ack (e.g. peer on an old client that doesn't ack). // get a delivery ack (e.g. peer on an old client that doesn't ack).
private static let maxSendAttempts = 8 private static let maxSendAttempts = 8
// Redundant couriers improve delivery odds; receivers dedup by message ID.
private static let maxCouriersPerMessage = 3
init(transports: [Transport], now: @escaping () -> Date = Date.init) { init(
transports: [Transport],
now: @escaping () -> Date = Date.init,
courierDirectory: CourierDirectory? = nil,
outboxStore: MessageOutboxStore? = nil,
metrics: StoreAndForwardMetrics? = nil
) {
self.transports = transports self.transports = transports
self.now = now self.now = now
self.courierDirectory = courierDirectory ?? .favoritesBacked()
self.outboxStore = outboxStore
self.metrics = metrics
self.outbox = outboxStore?.load() ?? [:]
// Observe favorites changes to learn Nostr mapping and flush queued messages // Observe favorites changes to learn Nostr mapping and flush queued messages
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
@@ -51,7 +92,7 @@ final class MessageRouter {
} }
// Handle key updates // Handle key updates
if let newKey = note.userInfo?["peerPublicKey"] as? Data, if let newKey = note.userInfo?["peerPublicKey"] as? Data,
let _ = note.userInfo?["isKeyUpdate"] as? Bool { note.userInfo?["isKeyUpdate"] is Bool {
let peerID = PeerID(publicKey: newKey) let peerID = PeerID(publicKey: newKey)
Task { @MainActor in Task { @MainActor in
self.flushOutbox(for: peerID) self.flushOutbox(for: peerID)
@@ -89,20 +130,143 @@ final class MessageRouter {
SecureLogger.debug("Routing PM via \(type(of: transport)) (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session) SecureLogger.debug("Routing PM via \(type(of: transport)) (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
enqueue(message, for: peerID) enqueue(message, for: peerID)
// "Reachable" without prompt delivery means the send only joined
// a queue (Nostr with relays down): also hand a sealed copy to
// any connected couriers rather than waiting for internet that
// may never come. Double delivery is harmless receivers dedup
// by message ID, and delivered/read acks never downgrade.
if !transport.canDeliverPromptly(to: peerID) {
attemptCourierDeposit(messageID: messageID, for: peerID)
}
} else { } else {
var unsent = message var unsent = message
unsent.sendAttempts = 0 unsent.sendAttempts = 0
enqueue(unsent, for: peerID) enqueue(unsent, for: peerID)
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session) SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session)
attemptCourierDeposit(messageID: messageID, for: peerID)
} }
} }
// MARK: - Couriers
/// Last resort when no transport can deliver promptly the peer is
/// unreachable, or only reachable through a send queue waiting on
/// internet: seal the message to their known static key and hand it to
/// connected couriers who may physically encounter them. Mutual favorites
/// are preferred; signature-verified strangers fill remaining slots so a
/// crowd without favorites can still carry mail (envelopes are opaque
/// either way). The queued copy stays retained, so direct delivery still
/// wins if the peer reappears first (receivers dedup by message ID).
private func attemptCourierDeposit(messageID: String, for peerID: PeerID) {
guard let recipientKey = courierDirectory.noiseKey(peerID),
let entry = queuedMessage(messageID, for: peerID) else { return }
let remainingSlots = Self.maxCouriersPerMessage - entry.depositedCourierKeys.count
guard remainingSlots > 0 else { return }
for transport in transports {
let couriers = eligibleCouriers(
on: transport,
recipientKey: recipientKey,
excluding: entry.depositedCourierKeys,
limit: remainingSlots
)
guard !couriers.isEmpty else { continue }
if transport.sendCourierMessage(entry.content, messageID: messageID, recipientNoiseKey: recipientKey, via: couriers.map(\.peerID)) {
SecureLogger.debug("📦 PM \(messageID.prefix(8))… handed to \(couriers.count) courier(s) for \(peerID.id.prefix(8))", category: .session)
recordCourierDeposit(messageID: messageID, for: peerID, courierKeys: couriers.map(\.noiseKey))
onMessageCarried?(messageID, peerID)
return
}
}
}
/// A courier candidate just connected: hand them any queued mail they are
/// not already carrying. This is what turns couriering from "a favorite
/// happened to be around at send time" into eventual spread deposits
/// retry as eligible peers appear, until each message rides with
/// `maxCouriersPerMessage` distinct couriers or expires.
func courierBecameAvailable(_ peerID: PeerID) {
for transport in transports {
guard transport.isPeerConnected(peerID),
let snapshot = transport.currentPeerSnapshots().first(where: { $0.peerID == peerID && $0.isConnected }),
let courierKey = snapshot.noisePublicKey,
courierDirectory.isTrustedCourier(courierKey) || snapshot.isVerified else { continue }
let currentDate = now()
for (recipient, queue) in outbox {
// Mail *to* this peer flushes directly on connect.
guard recipient != peerID,
let recipientKey = courierDirectory.noiseKey(recipient),
recipientKey != courierKey else { continue }
for message in queue {
guard message.depositedCourierKeys.count < Self.maxCouriersPerMessage,
!message.depositedCourierKeys.contains(courierKey),
currentDate.timeIntervalSince(message.timestamp) <= Self.messageTTLSeconds else { continue }
if transport.sendCourierMessage(message.content, messageID: message.messageID, recipientNoiseKey: recipientKey, via: [peerID]) {
SecureLogger.debug("📦 Deposit retry: PM \(message.messageID.prefix(8))… handed to \(peerID.id.prefix(8))… for \(recipient.id.prefix(8))", category: .session)
recordCourierDeposit(messageID: message.messageID, for: recipient, courierKeys: [courierKey])
onMessageCarried?(message.messageID, recipient)
}
}
}
return
}
}
private struct CourierCandidate {
let peerID: PeerID
let noiseKey: Data
}
private func eligibleCouriers(
on transport: Transport,
recipientKey: Data,
excluding excludedKeys: Set<Data>,
limit: Int
) -> [CourierCandidate] {
guard limit > 0 else { return [] }
let candidates = transport.currentPeerSnapshots().compactMap { snapshot -> (CourierCandidate, isFavorite: Bool)? in
guard snapshot.isConnected,
let key = snapshot.noisePublicKey,
key != recipientKey,
!excludedKeys.contains(key) else { return nil }
let isFavorite = courierDirectory.isTrustedCourier(key)
guard isFavorite || snapshot.isVerified else { return nil }
return (CourierCandidate(peerID: snapshot.peerID, noiseKey: key), isFavorite)
}
return candidates
.sorted { $0.isFavorite && !$1.isFavorite }
.prefix(limit)
.map(\.0)
}
private func queuedMessage(_ messageID: String, for peerID: PeerID) -> QueuedMessage? {
outbox[peerID]?.first { $0.messageID == messageID }
}
private func recordCourierDeposit(messageID: String, for peerID: PeerID, courierKeys: [Data]) {
metrics?.record(.courierDeposited)
guard var queue = outbox[peerID],
let index = queue.firstIndex(where: { $0.messageID == messageID }) else { return }
queue[index].depositedCourierKeys.formUnion(courierKeys)
outbox[peerID] = queue
persistOutbox()
}
// MARK: - Outbox Management
/// A delivery or read ack confirms receipt; stop retaining the message. /// A delivery or read ack confirms receipt; stop retaining the message.
func markDelivered(_ messageID: String) { func markDelivered(_ messageID: String) {
var cleared = false
for (peerID, queue) in outbox { for (peerID, queue) in outbox {
let filtered = queue.filter { $0.messageID != messageID } let filtered = queue.filter { $0.messageID != messageID }
guard filtered.count != queue.count else { continue } guard filtered.count != queue.count else { continue }
outbox[peerID] = filtered.isEmpty ? nil : filtered outbox[peerID] = filtered.isEmpty ? nil : filtered
cleared = true
}
if cleared {
metrics?.record(.outboxDelivered)
persistOutbox()
} }
} }
@@ -116,9 +280,26 @@ final class MessageRouter {
if queue.count > Self.maxMessagesPerPeer { if queue.count > Self.maxMessagesPerPeer {
let evicted = queue.removeFirst() let evicted = queue.removeFirst()
SecureLogger.warning("📤 Outbox overflow for \(peerID.id.prefix(8))… - evicted oldest message: \(evicted.messageID.prefix(8))", category: .session) SecureLogger.warning("📤 Outbox overflow for \(peerID.id.prefix(8))… - evicted oldest message: \(evicted.messageID.prefix(8))", category: .session)
onMessageDropped?(evicted.messageID, peerID) dropMessage(evicted.messageID, for: peerID)
} }
outbox[peerID] = queue outbox[peerID] = queue
metrics?.record(.outboxQueued)
persistOutbox()
}
private func dropMessage(_ messageID: String, for peerID: PeerID) {
metrics?.record(.outboxDropped)
onMessageDropped?(messageID, peerID)
}
private func persistOutbox() {
outboxStore?.save(outbox)
}
/// Panic wipe: forget queued mail on disk and in memory.
func wipeOutbox() {
outbox.removeAll()
outboxStore?.wipe()
} }
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
@@ -145,8 +326,6 @@ final class MessageRouter {
} }
} }
// MARK: - Outbox Management
func flushOutbox(for peerID: PeerID) { func flushOutbox(for peerID: PeerID) {
guard let queued = outbox[peerID], !queued.isEmpty else { return } guard let queued = outbox[peerID], !queued.isEmpty else { return }
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session) SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
@@ -158,7 +337,7 @@ final class MessageRouter {
// Skip expired messages (TTL exceeded) // Skip expired messages (TTL exceeded)
if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds { if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds {
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… (age: \(Int(now.timeIntervalSince(message.timestamp)))s)", category: .session) SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… (age: \(Int(now.timeIntervalSince(message.timestamp)))s)", category: .session)
onMessageDropped?(message.messageID, peerID) dropMessage(message.messageID, for: peerID)
continue continue
} }
@@ -166,16 +345,18 @@ final class MessageRouter {
// Live link: send and stop retaining. // Live link: send and stop retaining.
SecureLogger.debug("Outbox -> \(type(of: transport)) (connected) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))", category: .session) SecureLogger.debug("Outbox -> \(type(of: transport)) (connected) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID) transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
metrics?.record(.outboxResent)
} else if let transport = reachableTransport(for: peerID) { } else if let transport = reachableTransport(for: peerID) {
// Weak signal: send but keep retaining until an ack clears it, // Weak signal: send but keep retaining until an ack clears it,
// bounded by attempt count for peers that never ack. // bounded by attempt count for peers that never ack.
guard message.sendAttempts < Self.maxSendAttempts else { guard message.sendAttempts < Self.maxSendAttempts else {
SecureLogger.warning("📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session) SecureLogger.warning("📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session)
onMessageDropped?(message.messageID, peerID) dropMessage(message.messageID, for: peerID)
continue continue
} }
SecureLogger.debug("Outbox -> \(type(of: transport)) (reachable) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))", category: .session) SecureLogger.debug("Outbox -> \(type(of: transport)) (reachable) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID) transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
metrics?.record(.outboxResent)
var retained = message var retained = message
retained.sendAttempts += 1 retained.sendAttempts += 1
remaining.append(retained) remaining.append(retained)
@@ -189,6 +370,7 @@ final class MessageRouter {
} else { } else {
outbox[peerID] = remaining outbox[peerID] = remaining
} }
persistOutbox()
} }
func flushAllOutbox() { func flushAllOutbox() {
@@ -198,6 +380,7 @@ final class MessageRouter {
/// Periodically clean up expired messages from all outboxes /// Periodically clean up expired messages from all outboxes
func cleanupExpiredMessages() { func cleanupExpiredMessages() {
let now = now() let now = now()
var droppedAny = false
for peerID in Array(outbox.keys) { for peerID in Array(outbox.keys) {
var expiredMessageIDs: [String] = [] var expiredMessageIDs: [String] = []
outbox[peerID]?.removeAll { message in outbox[peerID]?.removeAll { message in
@@ -210,8 +393,12 @@ final class MessageRouter {
} }
for messageID in expiredMessageIDs { for messageID in expiredMessageIDs {
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session) SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
onMessageDropped?(messageID, peerID) dropMessage(messageID, for: peerID)
droppedAny = true
} }
} }
if droppedAny {
persistOutbox()
}
} }
} }
@@ -25,14 +25,20 @@ extension NostrRelayManager: NetworkActivationRelayControlling {}
extension TorURLSession: NetworkActivationProxyControlling {} extension TorURLSession: NetworkActivationProxyControlling {}
/// Coordinates when the app is allowed to start Tor and connect to Nostr relays. /// Coordinates when the app is allowed to start Tor and connect to Nostr relays.
/// Policy: permit start when either location permissions are authorized OR /// Policy: permit start when (location permissions are authorized OR there
/// there exists at least one mutual favorite. Otherwise, do not start. /// exists at least one mutual favorite) AND the device has a usable network
/// path. When there is provably no network at all we do not bootstrap Tor or
/// spin relay reconnects that only wastes battery on a mesh-only/offline
/// device. BLE mesh is entirely independent of this gate.
@MainActor @MainActor
final class NetworkActivationService: ObservableObject { final class NetworkActivationService: ObservableObject {
static let shared = NetworkActivationService() static let shared = NetworkActivationService()
@Published private(set) var activationAllowed: Bool = false @Published private(set) var activationAllowed: Bool = false
@Published private(set) var userTorEnabled: Bool = true @Published private(set) var userTorEnabled: Bool = true
/// Coarse, debounced network reachability. `false` only when the OS reports
/// no usable interface at all. Surfaced for UI ("offline" vs "connecting").
@Published private(set) var isNetworkReachable: Bool = true
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var started = false private var started = false
@@ -43,6 +49,7 @@ final class NetworkActivationService: ObservableObject {
private let mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never> private let mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>
private let permissionProvider: () -> LocationChannelManager.PermissionState private let permissionProvider: () -> LocationChannelManager.PermissionState
private let mutualFavoritesProvider: () -> Set<Data> private let mutualFavoritesProvider: () -> Set<Data>
private let reachabilityMonitor: NetworkReachabilityMonitoring
private let torController: NetworkActivationTorControlling private let torController: NetworkActivationTorControlling
// Resolved lazily: NostrRelayManager.init() reads NetworkActivationService.shared // Resolved lazily: NostrRelayManager.init() reads NetworkActivationService.shared
// (via its live dependencies), so capturing NostrRelayManager.shared here would // (via its live dependencies), so capturing NostrRelayManager.shared here would
@@ -58,6 +65,7 @@ final class NetworkActivationService: ObservableObject {
mutualFavoritesPublisher = FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher() mutualFavoritesPublisher = FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher()
permissionProvider = { LocationChannelManager.shared.permissionState } permissionProvider = { LocationChannelManager.shared.permissionState }
mutualFavoritesProvider = { FavoritesPersistenceService.shared.mutualFavorites } mutualFavoritesProvider = { FavoritesPersistenceService.shared.mutualFavorites }
reachabilityMonitor = NWPathReachabilityMonitor()
torController = TorManager.shared torController = TorManager.shared
relayControllerProvider = { NostrRelayManager.shared } relayControllerProvider = { NostrRelayManager.shared }
proxyController = TorURLSession.shared proxyController = TorURLSession.shared
@@ -70,6 +78,7 @@ final class NetworkActivationService: ObservableObject {
mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>, mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>,
permissionProvider: @escaping () -> LocationChannelManager.PermissionState, permissionProvider: @escaping () -> LocationChannelManager.PermissionState,
mutualFavoritesProvider: @escaping () -> Set<Data>, mutualFavoritesProvider: @escaping () -> Set<Data>,
reachabilityMonitor: NetworkReachabilityMonitoring,
torController: NetworkActivationTorControlling, torController: NetworkActivationTorControlling,
relayController: NetworkActivationRelayControlling, relayController: NetworkActivationRelayControlling,
proxyController: NetworkActivationProxyControlling, proxyController: NetworkActivationProxyControlling,
@@ -80,6 +89,7 @@ final class NetworkActivationService: ObservableObject {
self.mutualFavoritesPublisher = mutualFavoritesPublisher self.mutualFavoritesPublisher = mutualFavoritesPublisher
self.permissionProvider = permissionProvider self.permissionProvider = permissionProvider
self.mutualFavoritesProvider = mutualFavoritesProvider self.mutualFavoritesProvider = mutualFavoritesProvider
self.reachabilityMonitor = reachabilityMonitor
self.torController = torController self.torController = torController
self.relayControllerProvider = { relayController } self.relayControllerProvider = { relayController }
self.proxyController = proxyController self.proxyController = proxyController
@@ -96,8 +106,12 @@ final class NetworkActivationService: ObservableObject {
userTorEnabled = true userTorEnabled = true
} }
// Begin (idempotent) reachability monitoring and seed initial state.
reachabilityMonitor.start()
isNetworkReachable = reachabilityMonitor.isReachable
// Initial compute // Initial compute
let allowed = basePolicyAllowed() let allowed = effectiveAllowed()
activationAllowed = allowed activationAllowed = allowed
torAutoStartDesired = allowed && userTorEnabled torAutoStartDesired = allowed && userTorEnabled
torController.setAutoStartAllowed(torAutoStartDesired) torController.setAutoStartAllowed(torAutoStartDesired)
@@ -123,6 +137,21 @@ final class NetworkActivationService: ObservableObject {
self?.reevaluate() self?.reevaluate()
} }
.store(in: &cancellables) .store(in: &cancellables)
// React to network reachability changes (debounced, unsatisfied-only).
reachabilityMonitor.reachabilityPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] reachable in
guard let self else { return }
guard reachable != self.isNetworkReachable else { return }
self.isNetworkReachable = reachable
SecureLogger.info(
"NetworkActivationService: isNetworkReachable -> \(reachable)",
category: .session
)
self.reevaluate()
}
.store(in: &cancellables)
} }
func setUserTorEnabled(_ enabled: Bool) { func setUserTorEnabled(_ enabled: Bool) {
@@ -138,7 +167,7 @@ final class NetworkActivationService: ObservableObject {
} }
private func reevaluate() { private func reevaluate() {
let allowed = basePolicyAllowed() let allowed = effectiveAllowed()
let torDesired = allowed && userTorEnabled let torDesired = allowed && userTorEnabled
let statusChanged = allowed != activationAllowed let statusChanged = allowed != activationAllowed
let torChanged = torDesired != torAutoStartDesired let torChanged = torDesired != torAutoStartDesired
@@ -163,12 +192,20 @@ final class NetworkActivationService: ObservableObject {
} }
} }
/// Base policy: who is allowed to use the network at all (permission or a
/// mutual favorite), ignoring current link state.
private func basePolicyAllowed() -> Bool { private func basePolicyAllowed() -> Bool {
let permOK = permissionProvider() == .authorized let permOK = permissionProvider() == .authorized
let hasMutual = !mutualFavoritesProvider().isEmpty let hasMutual = !mutualFavoritesProvider().isEmpty
return permOK || hasMutual return permOK || hasMutual
} }
/// Effective gate: base policy AND a usable network path. When there is
/// provably no network, Tor bootstrap and relay reconnects are suppressed.
private func effectiveAllowed() -> Bool {
basePolicyAllowed() && reachabilityMonitor.isReachable
}
private func applyTorState(torDesired: Bool) { private func applyTorState(torDesired: Bool) {
proxyController.setProxyMode(useTor: torDesired) proxyController.setProxyMode(useTor: torDesired)
if torDesired { if torDesired {
@@ -0,0 +1,179 @@
import Foundation
import Combine
import BitLogger
#if canImport(Network)
import Network
#endif
/// Coarse, conservative network-reachability signal used to gate Tor bootstrap
/// and Nostr relay connections.
///
/// Policy (deliberately conservative):
/// - Reports `false` only when the OS says there is *no* usable interface at
/// all (`NWPath.Status.unsatisfied`). A flaky-but-present link stays
/// `true` because Tor tolerates intermittent connectivity, and tearing down
/// on the first hiccup would cost more battery/latency than it saves.
/// - Transitions are debounced (see `ReachabilityDebounce`) so path flapping
/// does not thrash Tor/relay startup.
/// - Starts optimistic (`true`) so nothing is ever suppressed before the first
/// path evaluation arrives.
///
/// BLE mesh must never consult this monitor the mesh works fully offline.
@MainActor
protocol NetworkReachabilityMonitoring: AnyObject {
/// Current debounced coarse reachability.
var isReachable: Bool { get }
/// Emits the debounced reachability whenever it changes (main-actor).
var reachabilityPublisher: AnyPublisher<Bool, Never> { get }
/// Begin monitoring. Idempotent.
func start()
}
/// Pure debounce/decision logic for reachability, split out so it can be
/// unit-tested without the Network framework or real timers.
///
/// A candidate state only becomes the committed state once it has been stable
/// (uninterrupted) for `interval`. Any observation matching the committed state
/// cancels a pending opposite change, which is what makes flapping a no-op.
struct ReachabilityDebounce {
let interval: TimeInterval
private(set) var committed: Bool
private var pending: (value: Bool, since: Date)?
init(interval: TimeInterval, initial: Bool) {
self.interval = interval
self.committed = initial
}
/// Whether a change is currently waiting out the debounce window.
var hasPendingChange: Bool { pending != nil }
/// Time left before the pending change may commit, or `nil` when nothing
/// is pending. Lets callers schedule a flush at the true deadline instead
/// of a full interval from "now" (duplicate observations must not push
/// the deadline out).
func pendingRemaining(at now: Date) -> TimeInterval? {
guard let pending else { return nil }
return max(0, interval - now.timeIntervalSince(pending.since))
}
/// Feed a raw observation. Returns the new committed value if it changed,
/// otherwise `nil`.
mutating func observe(reachable: Bool, at now: Date) -> Bool? {
if reachable == committed {
// Already in this state cancel any pending opposite change.
pending = nil
return nil
}
// Differs from committed: (re)arm the pending change, preserving the
// timestamp if we're already waiting on this same target value.
if pending?.value != reachable {
pending = (reachable, now)
}
return commitIfAged(at: now)
}
/// Called from a timer to commit a pending change once it has aged past
/// `interval`. Returns the new committed value if it changed, else `nil`.
mutating func flush(at now: Date) -> Bool? {
commitIfAged(at: now)
}
private mutating func commitIfAged(at now: Date) -> Bool? {
guard let pending else { return nil }
guard now.timeIntervalSince(pending.since) >= interval else { return nil }
committed = pending.value
self.pending = nil
return committed
}
}
/// Always-reachable stub. Used as the default in tests and as the fallback on
/// platforms without the Network framework, so reachability never suppresses
/// startup by itself.
@MainActor
final class AlwaysReachableMonitor: NetworkReachabilityMonitoring {
var isReachable: Bool { true }
var reachabilityPublisher: AnyPublisher<Bool, Never> {
Empty(completeImmediately: false).eraseToAnyPublisher()
}
func start() {}
}
/// `NWPathMonitor`-backed reachability. All state lives on the main actor; the
/// background path callback hops here before touching the debounce.
@MainActor
final class NWPathReachabilityMonitor: NetworkReachabilityMonitoring {
private let subject: CurrentValueSubject<Bool, Never>
private var debounce: ReachabilityDebounce
private var flushWorkItem: DispatchWorkItem?
private var started = false
private let now: () -> Date
#if canImport(Network)
private var monitor: NWPathMonitor?
private let monitorQueue = DispatchQueue(label: "chat.bitchat.reachability")
#endif
init(debounceInterval: TimeInterval = 2.5, now: @escaping () -> Date = Date.init) {
self.now = now
self.debounce = ReachabilityDebounce(interval: debounceInterval, initial: true)
self.subject = CurrentValueSubject(true)
}
var isReachable: Bool { subject.value }
var reachabilityPublisher: AnyPublisher<Bool, Never> {
subject.removeDuplicates().dropFirst().eraseToAnyPublisher()
}
func start() {
guard !started else { return }
started = true
#if canImport(Network)
let monitor = NWPathMonitor()
self.monitor = monitor
monitor.pathUpdateHandler = { [weak self] path in
// Conservative: only "no interface at all" counts as unreachable.
let reachable = path.status != .unsatisfied
Task { @MainActor in
self?.ingest(reachable: reachable)
}
}
monitor.start(queue: monitorQueue)
#else
// No Network framework: never suppress startup.
#endif
}
/// Feed an observation into the debounce and publish committed changes.
/// Exposed internally so higher layers/tests could drive it if needed.
func ingest(reachable: Bool) {
flushWorkItem?.cancel()
flushWorkItem = nil
if let committed = debounce.observe(reachable: reachable, at: now()) {
publish(committed)
} else if debounce.hasPendingChange {
scheduleFlush()
}
}
private func scheduleFlush() {
let work = DispatchWorkItem { [weak self] in
guard let self else { return }
if let committed = self.debounce.flush(at: self.now()) {
self.publish(committed)
}
}
flushWorkItem = work
// Fire at the pending change's real deadline (pending.since + interval):
// duplicate path updates re-enter here and must not restart the window.
let delay = debounce.pendingRemaining(at: now()) ?? debounce.interval
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
}
private func publish(_ reachable: Bool) {
SecureLogger.info("NWPathReachabilityMonitor: network reachable -> \(reachable)", category: .session)
subject.send(reachable)
}
}
+152 -3
View File
@@ -172,6 +172,10 @@ final class NoiseEncryptionService {
// Security components // Security components
private let rateLimiter = NoiseRateLimiter() private let rateLimiter = NoiseRateLimiter()
private let keychain: KeychainManagerProtocol private let keychain: KeychainManagerProtocol
// One-time prekeys for forward-secret courier sealing (lazy generation
// inside the store; the batch is minted on first bundle build).
private let localPrekeys: LocalPrekeyStore
// Session maintenance // Session maintenance
private var rekeyTimer: Timer? private var rekeyTimer: Timer?
@@ -200,6 +204,7 @@ final class NoiseEncryptionService {
init(keychain: KeychainManagerProtocol) { init(keychain: KeychainManagerProtocol) {
self.keychain = keychain self.keychain = keychain
self.localPrekeys = LocalPrekeyStore(keychain: keychain)
// BCH-01-009: Load or create static identity key with proper error handling // BCH-01-009: Load or create static identity key with proper error handling
let loadedKey: Curve25519.KeyAgreement.PrivateKey let loadedKey: Curve25519.KeyAgreement.PrivateKey
@@ -369,13 +374,154 @@ final class NoiseEncryptionService {
func getPeerPublicKeyData(_ peerID: PeerID) -> Data? { func getPeerPublicKeyData(_ peerID: PeerID) -> Data? {
return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation
} }
// MARK: - Courier Envelopes (one-way Noise X)
/// Domain separation for courier envelopes so X-pattern transcripts can
/// never be confused with interactive XX handshakes.
private static let courierPrologue = Data("bitchat-courier-v1".utf8)
/// Encrypt a payload to a peer's known static key without an interactive
/// handshake (Noise X pattern). Used for store-and-forward envelopes
/// carried by couriers while the recipient is offline.
/// - Warning: One-way messages have no forward secrecy: a later compromise
/// of the recipient's static key exposes envelopes captured in transit.
/// Use established sessions whenever the peer is reachable.
func sealCourierPayload(_ payload: Data, recipientStaticKey: Data) throws -> Data {
let remoteKey = try NoiseHandshakeState.validatePublicKey(recipientStaticKey)
let handshake = NoiseHandshakeState(
role: .initiator,
pattern: .X,
keychain: keychain,
localStaticKey: staticIdentityKey,
remoteStaticKey: remoteKey,
prologue: Self.courierPrologue
)
return try handshake.writeMessage(payload: payload)
}
/// Decrypt a courier envelope addressed to our static key. Returns the
/// payload and the sender's authenticated static public key (the `ss`
/// DH in the X pattern binds the sender's identity to the ciphertext).
func openCourierPayload(_ envelopeCiphertext: Data) throws -> (payload: Data, senderStaticKey: Data) {
let handshake = NoiseHandshakeState(
role: .responder,
pattern: .X,
keychain: keychain,
localStaticKey: staticIdentityKey,
prologue: Self.courierPrologue
)
let payload = try handshake.readMessage(envelopeCiphertext)
guard let senderKey = handshake.getRemoteStaticPublicKey() else {
throw NoiseError.missingKeys
}
return (payload: payload, senderStaticKey: senderKey.rawRepresentation)
}
// MARK: - One-Time Prekey Envelopes (forward-secret Noise X)
/// Domain separation for prekey-sealed envelopes: distinct from both the
/// interactive XX transcripts and static-sealed courier envelopes, and
/// bound to the specific prekey ID so a ciphertext cannot be replayed
/// against a different prekey.
private static let prekeyProloguePrefix = Data("bitchat-prekey-v1".utf8)
private static func prekeyPrologue(for prekeyID: UInt32) -> Data {
var prologue = prekeyProloguePrefix
var big = prekeyID.bigEndian
withUnsafeBytes(of: &big) { prologue.append(contentsOf: $0) }
return prologue
}
/// Encrypt a payload to one of the recipient's gossiped one-time prekeys
/// (Noise X where the responder static is the prekey, not the identity
/// key). Unlike `sealCourierPayload`, this is forward secret: once the
/// recipient consumes the prekey and its grace window lapses, the private
/// key is deleted and captured ciphertext becomes undecryptable even if
/// the recipient's identity key is later compromised. The initiator's
/// static still rides inside (encrypted), so the recipient authenticates
/// the sender exactly as with static-sealed envelopes.
func sealPrekeyPayload(_ payload: Data, recipientPrekey: PrekeyBundle.Prekey) throws -> Data {
let remoteKey = try NoiseHandshakeState.validatePublicKey(recipientPrekey.publicKey)
let handshake = NoiseHandshakeState(
role: .initiator,
pattern: .X,
keychain: keychain,
localStaticKey: staticIdentityKey,
remoteStaticKey: remoteKey,
prologue: Self.prekeyPrologue(for: recipientPrekey.id)
)
return try handshake.writeMessage(payload: payload)
}
/// Decrypt an envelope sealed to one of our one-time prekeys. On success
/// the prekey is marked consumed (its private key survives a 48h grace
/// window for spray-and-wait redeliveries, then is deleted for good).
/// Returns the payload, the sender's authenticated static key (same
/// contract as `openCourierPayload`), and whether this open actually
/// retired the prekey false for a redelivery of already-consumed mail
/// so the caller can re-gossip the shrunken bundle only when it changed.
func openPrekeyPayload(_ envelopeCiphertext: Data, prekeyID: UInt32) throws -> (payload: Data, senderStaticKey: Data, consumedPrekey: Bool) {
guard let prekeyPrivate = localPrekeys.privateKey(for: prekeyID) else {
throw NoiseEncryptionError.unknownPrekey
}
let handshake = NoiseHandshakeState(
role: .responder,
pattern: .X,
keychain: keychain,
localStaticKey: prekeyPrivate,
prologue: Self.prekeyPrologue(for: prekeyID)
)
let payload = try handshake.readMessage(envelopeCiphertext)
guard let senderKey = handshake.getRemoteStaticPublicKey() else {
throw NoiseError.missingKeys
}
let consumedPrekey = localPrekeys.markConsumed(prekeyID)
return (payload: payload, senderStaticKey: senderKey.rawRepresentation, consumedPrekey: consumedPrekey)
}
/// Current signed prekey bundle for gossip, minting the initial batch on
/// first use. Nil only when signing fails.
func currentPrekeyBundle() -> PrekeyBundle? {
let (prekeys, generatedAt) = localPrekeys.currentBundlePrekeys()
guard !prekeys.isEmpty else { return nil }
let unsigned = PrekeyBundle(
noiseStaticPublicKey: getStaticPublicKeyData(),
prekeys: prekeys,
generatedAt: generatedAt,
signature: Data(count: PrekeyBundle.signatureLength)
)
guard let signature = signData(unsigned.signableBytes()) else { return nil }
return PrekeyBundle(
noiseStaticPublicKey: unsigned.noiseStaticPublicKey,
prekeys: prekeys,
generatedAt: generatedAt,
signature: signature
)
}
/// Verify a peer's bundle signature against their announce-bound Ed25519
/// signing key.
func verifyPrekeyBundleSignature(_ bundle: PrekeyBundle, signingPublicKey: Data) -> Bool {
verifySignature(bundle.signature, for: bundle.signableBytes(), publicKey: signingPublicKey)
}
/// Prune dead prekeys and top the batch back up when consumption runs it
/// low. Returns true when the published bundle changed and should be
/// re-gossiped.
@discardableResult
func replenishPrekeysIfNeeded() -> Bool {
localPrekeys.replenishIfNeeded()
}
/// Clear persistent identity (for panic mode) /// Clear persistent identity (for panic mode)
func clearPersistentIdentity() { func clearPersistentIdentity() {
// Clear from keychain // Clear from keychain
let deletedStatic = keychain.deleteIdentityKey(forKey: "noiseStaticKey") let deletedStatic = keychain.deleteIdentityKey(forKey: "noiseStaticKey")
let deletedSigning = keychain.deleteIdentityKey(forKey: "ed25519SigningKey") let deletedSigning = keychain.deleteIdentityKey(forKey: "ed25519SigningKey")
SecureLogger.logKeyOperation(.delete, keyType: "identity keys", success: deletedStatic && deletedSigning) SecureLogger.logKeyOperation(.delete, keyType: "identity keys", success: deletedStatic && deletedSigning)
// One-time prekey privates go with the identity they were bound to.
localPrekeys.wipe()
SecureLogger.warning("Panic mode activated - identity cleared", category: .security) SecureLogger.warning("Panic mode activated - identity cleared", category: .security)
// Stop rekey timer // Stop rekey timer
stopRekeyTimer() stopRekeyTimer()
@@ -428,7 +574,7 @@ final class NoiseEncryptionService {
private func canonicalAnnounceBytes(peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64) -> Data { private func canonicalAnnounceBytes(peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64) -> Data {
var out = Data() var out = Data()
// context // context
let context = "bitchat-announce-v1".data(using: .utf8) ?? Data() let context = Data("bitchat-announce-v1".utf8)
out.append(UInt8(min(context.count, 255))) out.append(UInt8(min(context.count, 255)))
out.append(context.prefix(255)) out.append(context.prefix(255))
// peerID (expect 8 bytes; pad/truncate to 8 for canonicalization) // peerID (expect 8 bytes; pad/truncate to 8 for canonicalization)
@@ -444,7 +590,7 @@ final class NoiseEncryptionService {
out.append(ed32) out.append(ed32)
if ed32.count < 32 { out.append(Data(repeating: 0, count: 32 - ed32.count)) } if ed32.count < 32 { out.append(Data(repeating: 0, count: 32 - ed32.count)) }
// nickname length + bytes // nickname length + bytes
let nickData = nickname.data(using: .utf8) ?? Data() let nickData = Data(nickname.utf8)
out.append(UInt8(min(nickData.count, 255))) out.append(UInt8(min(nickData.count, 255)))
out.append(nickData.prefix(255)) out.append(nickData.prefix(255))
// timestamp // timestamp
@@ -769,4 +915,7 @@ struct NoiseMessage: Codable {
enum NoiseEncryptionError: Error { enum NoiseEncryptionError: Error {
case handshakeRequired case handshakeRequired
case sessionNotEstablished case sessionNotEstablished
/// Envelope references a prekey ID we don't hold (never ours, already
/// deleted after its grace window, or wiped in a panic).
case unknownPrekey
} }
@@ -0,0 +1,106 @@
//
// NostrProcessedEventStore.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import Foundation
/// Disk persistence for processed gift-wrap event IDs. NIP-59 randomizes
/// gift-wrap timestamps, so DM subscriptions must look back generously (24h)
/// and relays redeliver the same events on every launch without a
/// cross-launch record, each relaunch reprocesses old PMs and acks
/// (re-sent DELIVERED bursts, "delivered ack for unknown mid" noise).
///
/// Contents are event IDs already visible to every relay, so
/// until-first-unlock file protection is the right at-rest posture the
/// file must also load during a locked-background restoration relaunch.
/// Wiped on panic via the dedup service's clear paths.
final class NostrProcessedEventStore {
private let fileURL: URL?
// All file access is serialized here: appends are read-modify-write, and
// overlapping debounced flushes would otherwise race and drop IDs.
private let ioQueue = DispatchQueue(label: "chat.bitchat.nostr-processed-events", qos: .utility)
init(fileURL: URL? = nil) {
self.fileURL = fileURL ?? Self.defaultFileURL()
}
/// Processed event IDs, oldest first (insertion order).
func load() -> [String] {
ioQueue.sync { loadLocked() }
}
/// Merge new IDs onto the persisted record, oldest-first, trimming from
/// the front past `cap`. Append-merge (not snapshot-overwrite) so the
/// in-memory cache being cleared transiently (channel switches) can
/// never shrink the on-disk record.
func append(_ newIDs: [String], cap: Int) {
guard !newIDs.isEmpty else { return }
ioQueue.async { [self] in
var merged = loadLocked()
var known = Set(merged)
for id in newIDs where !known.contains(id) {
merged.append(id)
known.insert(id)
}
if merged.count > cap {
merged.removeFirst(merged.count - cap)
}
saveLocked(merged)
}
}
func wipe() {
ioQueue.async { [self] in
guard let fileURL else { return }
try? FileManager.default.removeItem(at: fileURL)
}
}
private func loadLocked() -> [String] {
guard let fileURL,
let data = try? Data(contentsOf: fileURL),
let ids = try? JSONDecoder().decode([String].self, from: data) else {
return []
}
return ids
}
private func saveLocked(_ eventIDs: [String]) {
guard let fileURL else { return }
guard !eventIDs.isEmpty else {
try? FileManager.default.removeItem(at: fileURL)
return
}
do {
try FileManager.default.createDirectory(
at: fileURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
let data = try JSONEncoder().encode(eventIDs)
var options: Data.WritingOptions = [.atomic]
#if os(iOS)
options.insert(.completeFileProtectionUntilFirstUserAuthentication)
#endif
try data.write(to: fileURL, options: options)
} catch {
SecureLogger.error("Failed to persist processed Nostr events: \(error)", category: .session)
}
}
private static func defaultFileURL() -> URL? {
guard let base = try? FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
) else { return nil }
return base
.appendingPathComponent("nostr", isDirectory: true)
.appendingPathComponent("processed-events.json")
}
}
+164 -70
View File
@@ -14,7 +14,43 @@ final class NostrTransport: Transport, @unchecked Sendable {
let registerPendingGiftWrap: @MainActor (String) -> Void let registerPendingGiftWrap: @MainActor (String) -> Void
let sendEvent: @MainActor (NostrEvent) -> Void let sendEvent: @MainActor (NostrEvent) -> Void
let scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void let scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
/// Emits whether a relay that carries private messages is up
/// (fail-closed behind Tor). A connected geohash/custom relay alone
/// doesn't count: DM sends target the default relay set and would
/// still queue.
let relayConnectivity: @MainActor () -> AnyPublisher<Bool, Never>
/// Paces outbound acks. Defaults to an isolated pacer so tests don't
/// serialize behind each other; `live` passes the process-wide one.
let ackPacer: AckPacer
init(
notificationCenter: NotificationCenter,
loadFavorites: @escaping @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship],
favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?,
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?,
currentIdentity: @escaping @MainActor () throws -> NostrIdentity?,
registerPendingGiftWrap: @escaping @MainActor (String) -> Void,
sendEvent: @escaping @MainActor (NostrEvent) -> Void,
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void,
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never>,
ackPacer: AckPacer? = nil
) {
self.notificationCenter = notificationCenter
self.loadFavorites = loadFavorites
self.favoriteStatusForNoiseKey = favoriteStatusForNoiseKey
self.favoriteStatusForPeerID = favoriteStatusForPeerID
self.currentIdentity = currentIdentity
self.registerPendingGiftWrap = registerPendingGiftWrap
self.sendEvent = sendEvent
self.scheduleAfter = scheduleAfter
self.relayConnectivity = relayConnectivity
// Default pacer drives its throttle through the same injected
// scheduler, so tests that step scheduleAfter manually keep
// control of the ack cadence.
self.ackPacer = ackPacer ?? AckPacer(scheduleAfter: scheduleAfter)
}
@MainActor
static func live(idBridge: NostrIdentityBridge) -> Dependencies { static func live(idBridge: NostrIdentityBridge) -> Dependencies {
Dependencies( Dependencies(
notificationCenter: .default, notificationCenter: .default,
@@ -26,7 +62,9 @@ final class NostrTransport: Transport, @unchecked Sendable {
sendEvent: { NostrRelayManager.shared.sendEvent($0) }, sendEvent: { NostrRelayManager.shared.sendEvent($0) },
scheduleAfter: { delay, action in scheduleAfter: { delay, action in
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action) DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
} },
relayConnectivity: { NostrRelayManager.shared.$isDMRelayConnected.eraseToAnyPublisher() },
ackPacer: NostrTransport.sharedAckPacer
) )
} }
} }
@@ -34,14 +72,64 @@ final class NostrTransport: Transport, @unchecked Sendable {
// Provide BLE short peer ID for BitChat embedding // Provide BLE short peer ID for BitChat embedding
var senderPeerID = PeerID(str: "") var senderPeerID = PeerID(str: "")
// Throttle READ receipts to avoid relay rate limits // Throttle outbound acks READ receipts and DELIVERED acks, direct and
private struct QueuedRead { // geohash to avoid relay rate limits. Reconnect redelivery produces a
let receipt: ReadReceipt // burst of acks at once: 8 DELIVERED in under a second tripped damus's
let peerID: PeerID // "noting too much" during July 2026 device testing.
private enum QueuedAck {
case readDirect(ReadReceipt, PeerID)
case deliveredDirect(messageID: String, peerID: PeerID)
case deliveredGeohash(messageID: String, recipientHex: String, identity: NostrIdentity)
case readGeohash(messageID: String, recipientHex: String, identity: NostrIdentity)
} }
private var readQueue: [QueuedRead] = []
private var isSendingReadAcks = false /// Ack pacing shared across transport instances. Geohash acks are sent
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval /// through short-lived transports created per ack
/// (`makeGeohashNostrTransport()`), so a per-instance queue would only
/// ever hold one item and never pace a burst (flagged by Codex on
/// #1398). Production wires `sharedAckPacer` via `Dependencies.live`;
/// tests get an isolated instance per `Dependencies` by default.
/// @unchecked Sendable: all mutable state (`pending`, `isSending`) is
/// confined to the serial `queue`; the class is only touched via
/// `enqueue` and the scheduler callback, both of which hop onto it.
final class AckPacer: @unchecked Sendable {
typealias Scheduler = @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
private let queue = DispatchQueue(label: "chat.bitchat.nostr-ack-pacer")
private var pending: [() -> Void] = []
private var isSending = false
private let interval: TimeInterval = TransportConfig.nostrReadAckInterval
private let scheduleAfter: Scheduler
init(scheduleAfter: @escaping Scheduler = { delay, action in
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay, execute: action)
}) {
self.scheduleAfter = scheduleAfter
}
func enqueue(_ send: @escaping () -> Void) {
queue.async {
self.pending.append(send)
self.processNext()
}
}
/// Must be called on `queue`.
private func processNext() {
guard !isSending, !pending.isEmpty else { return }
isSending = true
let send = pending.removeFirst()
send()
scheduleAfter(interval) { [weak self] in
guard let self else { return }
self.queue.async {
self.isSending = false
self.processNext()
}
}
}
}
static let sharedAckPacer = AckPacer()
private let keychain: KeychainManagerProtocol private let keychain: KeychainManagerProtocol
private let idBridge: NostrIdentityBridge private let idBridge: NostrIdentityBridge
private let dependencies: Dependencies private let dependencies: Dependencies
@@ -49,6 +137,10 @@ final class NostrTransport: Transport, @unchecked Sendable {
// Reachability Cache (thread-safe) // Reachability Cache (thread-safe)
private var reachablePeers: Set<PeerID> = [] private var reachablePeers: Set<PeerID> = []
// Mirror of the relay manager's connection state, cached here because
// canDeliverPromptly is called synchronously off the main actor.
private var relaysConnected = false
private var relayConnectivityCancellable: AnyCancellable?
private let queue = DispatchQueue(label: "nostr.transport.state", attributes: .concurrent) private let queue = DispatchQueue(label: "nostr.transport.state", attributes: .concurrent)
@MainActor @MainActor
@@ -72,6 +164,12 @@ final class NostrTransport: Transport, @unchecked Sendable {
queue.sync(flags: .barrier) { queue.sync(flags: .barrier) {
self.reachablePeers = Set(reachable) self.reachablePeers = Set(reachable)
} }
relayConnectivityCancellable = self.dependencies.relayConnectivity()
.sink { [weak self] connected in
guard let self else { return }
self.queue.async(flags: .barrier) { self.relaysConnected = connected }
}
} }
deinit { deinit {
@@ -125,19 +223,25 @@ final class NostrTransport: Transport, @unchecked Sendable {
func isPeerConnected(_ peerID: PeerID) -> Bool { false } func isPeerConnected(_ peerID: PeerID) -> Bool { false }
func isPeerReachable(_ peerID: PeerID) -> Bool { func isPeerReachable(_ peerID: PeerID) -> Bool {
queue.sync { // Callers address peers by either the short 16-hex ID or the full
// Check if exact match // 64-hex noise key (offline favorites), so compare in short form.
let short = peerID.toShort()
return queue.sync {
if reachablePeers.contains(peerID) { return true } if reachablePeers.contains(peerID) { return true }
// Check for short ID match return reachablePeers.contains(where: { $0.toShort() == short })
if peerID.isShort {
return reachablePeers.contains(where: { $0.toShort() == peerID })
}
return false
} }
} }
func canDeliverPromptly(to peerID: PeerID) -> Bool {
// A known npub makes a peer "reachable", but with no relay
// connection a send only joins the local queue. Answering honestly
// here lets the router hand a sealed copy to a courier in parallel
// instead of waiting for internet that may never come.
isPeerReachable(peerID) && queue.sync { relaysConnected }
}
func peerNickname(peerID: PeerID) -> String? { nil } func peerNickname(peerID: PeerID) -> String? { nil }
func getPeerNicknames() -> [PeerID : String] { [:] } func getPeerNicknames() -> [PeerID: String] { [:] }
func getFingerprint(for peerID: PeerID) -> String? { nil } func getFingerprint(for peerID: PeerID) -> String? { nil }
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none } func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
@@ -164,12 +268,14 @@ final class NostrTransport: Transport, @unchecked Sendable {
} }
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
// Enqueue and process with throttling to avoid relay rate limits enqueueAck(.readDirect(receipt, peerID))
// Use barrier to synchronize access to readQueue }
queue.async(flags: .barrier) {
self.readQueue.append(QueuedRead(receipt: receipt, peerID: peerID)) /// Enqueue an ack for paced sending. Captures self strongly on purpose:
self.processReadQueueIfNeeded() /// geohash acks ride throwaway transport instances that must stay alive
} /// until their ack leaves the queue.
private func enqueueAck(_ ack: QueuedAck) {
dependencies.ackPacer.enqueue { self.sendAckItem(ack) }
} }
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
@@ -189,17 +295,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
func sendBroadcastAnnounce() { /* no-op for Nostr */ } func sendBroadcastAnnounce() { /* no-op for Nostr */ }
func sendDeliveryAck(for messageID: String, to peerID: PeerID) { func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
Task { @MainActor in enqueueAck(.deliveredDirect(messageID: messageID, peerID: peerID))
guard let recipientNpub = resolveRecipientNpub(for: peerID),
let recipientHex = npubToHex(recipientNpub),
let senderIdentity = try? dependencies.currentIdentity() else { return }
SecureLogger.debug("NostrTransport: preparing DELIVERED ack id=\(messageID.prefix(8))", category: .session)
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
return
}
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
}
} }
} }
@@ -209,19 +305,11 @@ extension NostrTransport {
// MARK: Geohash ACK helpers // MARK: Geohash ACK helpers
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
Task { @MainActor in enqueueAck(.deliveredGeohash(messageID: messageID, recipientHex: recipientHex, identity: identity))
SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
}
} }
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
Task { @MainActor in enqueueAck(.readGeohash(messageID: messageID, recipientHex: recipientHex, identity: identity))
SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
}
} }
// MARK: Geohash DMs (per-geohash identity) // MARK: Geohash DMs (per-geohash identity)
@@ -267,36 +355,42 @@ extension NostrTransport {
dependencies.sendEvent(event) dependencies.sendEvent(event)
} }
/// Must be called within a barrier on `queue`
private func processReadQueueIfNeeded() {
guard !isSendingReadAcks else { return }
guard !readQueue.isEmpty else { return }
isSendingReadAcks = true
let item = readQueue.removeFirst()
sendReadAckItem(item)
}
/// Sends a single read ack item (called after extraction from queue within barrier) /// Sends a single ack item (invoked by the pacer, one per interval)
private func sendReadAckItem(_ item: QueuedRead) { private func sendAckItem(_ item: QueuedAck) {
Task { @MainActor in Task { @MainActor in
defer { scheduleNextReadAck() } switch item {
guard let recipientNpub = resolveRecipientNpub(for: item.peerID), case .readDirect(let receipt, let peerID):
let recipientHex = npubToHex(recipientNpub), guard let recipientNpub = resolveRecipientNpub(for: peerID),
let senderIdentity = try? dependencies.currentIdentity() else { return } let recipientHex = npubToHex(recipientNpub),
SecureLogger.debug("NostrTransport: preparing READ ack id=\(item.receipt.originalMessageID.prefix(8))", category: .session) let senderIdentity = try? dependencies.currentIdentity() else { return }
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else { SecureLogger.debug("NostrTransport: preparing READ ack id=\(receipt.originalMessageID.prefix(8))", category: .session)
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session) guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: receipt.originalMessageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
return SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
} return
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity) }
} sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
}
private func scheduleNextReadAck() { case .deliveredDirect(let messageID, let peerID):
dependencies.scheduleAfter(readAckInterval) { [weak self] in guard let recipientNpub = resolveRecipientNpub(for: peerID),
self?.queue.async(flags: .barrier) { [weak self] in let recipientHex = npubToHex(recipientNpub),
self?.isSendingReadAcks = false let senderIdentity = try? dependencies.currentIdentity() else { return }
self?.processReadQueueIfNeeded() SecureLogger.debug("NostrTransport: preparing DELIVERED ack id=\(messageID.prefix(8))", category: .session)
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
return
}
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
case .deliveredGeohash(let messageID, let recipientHex, let identity):
SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
case .readGeohash(let messageID, let recipientHex, let identity):
SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
} }
} }
} }
+1 -1
View File
@@ -111,7 +111,7 @@ final class NotificationService {
func requestAuthorization() { func requestAuthorization() {
guard !isRunningTests else { return } guard !isRunningTests else { return }
authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
if granted { if granted {
// Permission granted // Permission granted
} else { } else {
@@ -0,0 +1,228 @@
//
// LocalPrekeyStore.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 CryptoKit
import Foundation
/// Owns this device's one-time Curve25519 prekey private keys.
///
/// Privates persist in the Keychain (single blob, same protection class as
/// the identity keys). A batch of `batchSize` unconsumed prekeys backs the
/// gossiped bundle; when consumption drops the unconsumed count below
/// `replenishThreshold`, the batch tops back up and the bundle's
/// `generatedAt` bumps so peers replace their cached copy.
///
/// Redelivery grace: spray-and-wait means the same prekey-sealed ciphertext
/// (or a re-seal of the same message to the same prekey ID) can arrive via
/// several couriers days apart. A consumed prekey's private key is therefore
/// retained for `consumedGraceSeconds` after first use and only then deleted.
/// Tradeoff: during the grace window a compromise of the device still exposes
/// mail sealed to that prekey the forward-secrecy clock starts at deletion,
/// not at first open. Refusing new ciphertexts while accepting redeliveries
/// is not possible (the recipient cannot distinguish them), so the window is
/// kept short and fixed.
final class LocalPrekeyStore {
struct Record: Codable {
let id: UInt32
let privateKey: Data
let createdAt: Date
var consumedAt: Date?
}
private struct Persisted: Codable {
var records: [Record]
var nextID: UInt32
var generatedAt: UInt64
}
enum Policy {
static let batchSize = PrekeyBundle.maxPrekeys
static let replenishThreshold = 3
/// How long a consumed prekey private survives for duplicate courier
/// deliveries of mail sealed to it.
static let consumedGraceSeconds: TimeInterval = 48 * 60 * 60
/// Unconsumed prekeys older than this are rotated out: no honest
/// sender seals to a bundle that stale (see
/// `PrekeyBundleStore.Limits.maxBundleAgeForSealingSeconds`).
static let unconsumedRetentionSeconds: TimeInterval = 30 * 24 * 60 * 60
}
private static let keychainKey = "prekeysV1"
private let keychain: KeychainManagerProtocol
private let now: () -> Date
private let queue = DispatchQueue(label: "chat.bitchat.prekeys.local")
// Guarded by `queue`.
private var records: [Record] = []
private var nextID: UInt32 = 0
private var generatedAt: UInt64 = 0
private var loaded = false
init(keychain: KeychainManagerProtocol, now: @escaping () -> Date = Date.init) {
self.keychain = keychain
self.now = now
}
// MARK: - Bundle contents (public prekeys)
/// Unconsumed public prekeys for the gossiped bundle, generating the
/// initial batch on first use. Sorted by ID for canonical signing bytes.
func currentBundlePrekeys() -> (prekeys: [PrekeyBundle.Prekey], generatedAt: UInt64) {
queue.sync {
loadLocked()
_ = replenishLocked()
let prekeys = records
.filter { $0.consumedAt == nil }
.sorted { $0.id < $1.id }
.compactMap { record -> PrekeyBundle.Prekey? in
guard let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: record.privateKey) else { return nil }
return PrekeyBundle.Prekey(id: record.id, publicKey: key.publicKey.rawRepresentation)
}
return (prekeys, generatedAt)
}
}
// MARK: - Opening (private prekeys)
/// Private key for a prekey ID: unconsumed, or consumed within the
/// redelivery grace window.
func privateKey(for id: UInt32) -> Curve25519.KeyAgreement.PrivateKey? {
queue.sync {
loadLocked()
let date = now()
guard let record = records.first(where: { $0.id == id }) else { return nil }
if let consumedAt = record.consumedAt,
date.timeIntervalSince(consumedAt) > Policy.consumedGraceSeconds {
return nil
}
return try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: record.privateKey)
}
}
/// Marks a prekey consumed (starts its grace clock). Idempotent: a
/// redelivery within the grace window does not restart the clock.
///
/// Returns true when this call actually retired a prekey, i.e. the
/// published bundle shrank. Consuming a prekey drops it from
/// `currentBundlePrekeys()`, so `generatedAt` must advance strictly too:
/// otherwise peers that cached the old bundle reject the same-`generatedAt`
/// replacement in `PrekeyBundleStore.ingest`, keep assigning the consumed
/// ID, and their mail starts failing `unknownPrekey` once the 48h grace
/// lapses. The caller re-gossips on a true result.
@discardableResult
func markConsumed(_ id: UInt32) -> Bool {
queue.sync {
loadLocked()
guard let index = records.firstIndex(where: { $0.id == id }),
records[index].consumedAt == nil else { return false }
records[index].consumedAt = now()
advanceGeneratedAtLocked()
persistLocked()
return true
}
}
/// Prunes dead prekeys and tops the unconsumed batch back up when it runs
/// low. Returns true when the published bundle changed (caller should
/// re-gossip).
@discardableResult
func replenishIfNeeded() -> Bool {
queue.sync {
loadLocked()
return replenishLocked()
}
}
var unconsumedCount: Int {
queue.sync {
loadLocked()
return records.filter { $0.consumedAt == nil }.count
}
}
/// Panic wipe: drop all prekey privates from memory and the Keychain.
func wipe() {
queue.sync {
records.removeAll()
nextID = 0
generatedAt = 0
loaded = true
_ = keychain.deleteIdentityKey(forKey: Self.keychainKey)
}
}
// MARK: - Internals (call only on `queue`)
private func replenishLocked() -> Bool {
let date = now()
// Consumed prekeys past the grace window are gone for good; stale
// unconsumed ones rotate out (their bundle is too old to seal to).
let recordsBefore = records.count
let unconsumedBefore = records.filter { $0.consumedAt == nil }.count
records.removeAll { record in
if let consumedAt = record.consumedAt {
return date.timeIntervalSince(consumedAt) > Policy.consumedGraceSeconds
}
return date.timeIntervalSince(record.createdAt) > Policy.unconsumedRetentionSeconds
}
// Only a change to the *unconsumed* set alters the published bundle;
// grace-expired consumed keys were never in it.
let unconsumed = records.filter { $0.consumedAt == nil }.count
var bundleChanged = unconsumed != unconsumedBefore
if unconsumed < Policy.replenishThreshold {
for _ in unconsumed..<Policy.batchSize {
let key = Curve25519.KeyAgreement.PrivateKey()
records.append(Record(id: nextID, privateKey: key.rawRepresentation, createdAt: date, consumedAt: nil))
nextID &+= 1
}
advanceGeneratedAtLocked()
bundleChanged = true
SecureLogger.debug("🔑 Replenished one-time prekeys (unconsumed was \(unconsumed))", category: .security)
}
if bundleChanged || records.count != recordsBefore { persistLocked() }
return bundleChanged
}
/// Advance `generatedAt` strictly monotonically. Uses wall-clock millis but
/// never repeats or regresses, so two changes within the same millisecond
/// still produce distinct, increasing stamps that peers' monotonic ingest
/// accepts.
private func advanceGeneratedAtLocked() {
let nowMillis = UInt64(max(0, now().timeIntervalSince1970 * 1000))
generatedAt = max(nowMillis, generatedAt &+ 1)
}
private func loadLocked() {
guard !loaded else { return }
loaded = true
guard let data = keychain.getIdentityKey(forKey: Self.keychainKey),
let persisted = try? JSONDecoder().decode(Persisted.self, from: data) else {
return
}
records = persisted.records
nextID = persisted.nextID
generatedAt = persisted.generatedAt
}
private func persistLocked() {
let persisted = Persisted(records: records, nextID: nextID, generatedAt: generatedAt)
guard let data = try? JSONEncoder().encode(persisted) else {
SecureLogger.error("Failed to encode prekey store", category: .keychain)
return
}
if !keychain.saveIdentityKey(data, forKey: Self.keychainKey) {
SecureLogger.error("Failed to persist prekey store", category: .keychain)
}
}
}
@@ -0,0 +1,210 @@
//
// PrekeyBundleStore.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
/// Signature-verified one-time prekey bundles received from other peers.
///
/// One bundle per Noise static key: a newer `generatedAt` replaces the cached
/// copy, keeping the IDs we already sealed with marked used so a prekey is
/// never reused across messages. Assignments are remembered per message ID so
/// deposit retries of the same message re-use its prekey (and its budget)
/// instead of burning a fresh one per courier.
///
/// Only public key material lives here; it persists to disk so a sender can
/// prekey-seal for recipients met long ago. Included in the panic wipe.
final class PrekeyBundleStore {
struct StoredBundle: Codable {
let noiseKey: Data
var generatedAt: UInt64
var prekeyIDs: [UInt32]
var prekeyPublicKeys: [Data]
/// IDs this device already sealed with (never reused).
var usedIDs: Set<UInt32>
/// messageID prekey ID, so re-deposits of one message share one prekey.
var assignments: [String: UInt32]
var updatedAt: Date
}
enum Limits {
static let maxPeers = 200
/// Don't seal to bundles older than this: the owner may have rotated
/// the unconsumed keys out (see `LocalPrekeyStore.Policy`).
static let maxBundleAgeForSealingSeconds: TimeInterval = 7 * 24 * 60 * 60
}
static let shared = PrekeyBundleStore()
private var bundles: [Data: StoredBundle] = [:]
private let queue = DispatchQueue(label: "chat.bitchat.prekeys.bundles")
private let fileURL: URL?
private let maxPeers: Int
private let now: () -> Date
/// - Parameter fileURL: Overrides the on-disk location (tests). Ignored
/// when `persistsToDisk` is false.
init(
persistsToDisk: Bool = true,
fileURL: URL? = nil,
maxPeers: Int = Limits.maxPeers,
now: @escaping () -> Date = Date.init
) {
self.now = now
self.maxPeers = maxPeers
self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
loadFromDisk()
}
// MARK: - Ingest
/// Stores a bundle whose signature the caller has already verified
/// against the owner's announce-bound signing key. Returns false when an
/// equal-or-newer bundle is already cached (nothing changed).
@discardableResult
func ingest(_ bundle: PrekeyBundle) -> Bool {
guard bundle.noiseStaticPublicKey.count == PrekeyBundle.keyLength,
!bundle.prekeys.isEmpty else { return false }
return queue.sync {
if let existing = bundles[bundle.noiseStaticPublicKey],
existing.generatedAt >= bundle.generatedAt {
return false
}
let previous = bundles[bundle.noiseStaticPublicKey]
let newIDs = Set(bundle.prekeys.map(\.id))
// Keep consumption state for IDs the fresh bundle still offers
// (a top-up keeps the owner's unconsumed keys); drop the rest.
let carriedUsed = (previous?.usedIDs ?? []).intersection(newIDs)
let carriedAssignments = (previous?.assignments ?? [:]).filter { newIDs.contains($0.value) }
bundles[bundle.noiseStaticPublicKey] = StoredBundle(
noiseKey: bundle.noiseStaticPublicKey,
generatedAt: bundle.generatedAt,
prekeyIDs: bundle.prekeys.map(\.id),
prekeyPublicKeys: bundle.prekeys.map(\.publicKey),
usedIDs: carriedUsed,
assignments: carriedAssignments,
updatedAt: now()
)
enforceCapLocked()
persistLocked()
return true
}
}
// MARK: - Sealing support
/// Whether an unexpired bundle with sealable prekeys is cached for a peer.
func hasUsableBundle(for noiseKey: Data) -> Bool {
queue.sync {
guard let bundle = bundles[noiseKey], isFreshLocked(bundle) else { return false }
return bundle.usedIDs.count < bundle.prekeyIDs.count
}
}
/// The prekey to seal a message with: the message's existing assignment if
/// any (re-deposits reuse it), else the lowest unused ID, which is then
/// marked used. Nil when no fresh bundle is cached or all its prekeys are
/// spent callers fall back to static sealing.
func assignPrekey(messageID: String, recipientNoiseKey: Data) -> PrekeyBundle.Prekey? {
queue.sync {
guard var bundle = bundles[recipientNoiseKey], isFreshLocked(bundle) else { return nil }
if let assigned = bundle.assignments[messageID],
let index = bundle.prekeyIDs.firstIndex(of: assigned) {
return PrekeyBundle.Prekey(id: assigned, publicKey: bundle.prekeyPublicKeys[index])
}
guard let index = bundle.prekeyIDs.indices
.filter({ !bundle.usedIDs.contains(bundle.prekeyIDs[$0]) })
.min(by: { bundle.prekeyIDs[$0] < bundle.prekeyIDs[$1] }) else {
return nil
}
let id = bundle.prekeyIDs[index]
bundle.usedIDs.insert(id)
bundle.assignments[messageID] = id
bundle.updatedAt = now()
bundles[recipientNoiseKey] = bundle
persistLocked()
return PrekeyBundle.Prekey(id: id, publicKey: bundle.prekeyPublicKeys[index])
}
}
// MARK: - Maintenance
/// Panic wipe: drop all cached bundles from memory and disk.
func wipe() {
queue.sync {
bundles.removeAll()
if let fileURL {
try? FileManager.default.removeItem(at: fileURL)
}
}
}
// MARK: - Internals (call only on `queue`)
private func isFreshLocked(_ bundle: StoredBundle) -> Bool {
let ageSeconds = now().timeIntervalSince1970 - Double(bundle.generatedAt) / 1000
return ageSeconds <= Limits.maxBundleAgeForSealingSeconds
}
private func enforceCapLocked() {
while bundles.count > maxPeers {
guard let victim = bundles.min(by: { $0.value.updatedAt < $1.value.updatedAt }) else { return }
bundles.removeValue(forKey: victim.key)
}
}
private func persistLocked() {
guard let fileURL else { return }
do {
if bundles.isEmpty {
try? FileManager.default.removeItem(at: fileURL)
return
}
try FileManager.default.createDirectory(
at: fileURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
let data = try JSONEncoder().encode(Array(bundles.values))
var options: Data.WritingOptions = [.atomic]
#if os(iOS)
options.insert(.completeFileProtection)
#endif
try data.write(to: fileURL, options: options)
} catch {
SecureLogger.error("Failed to persist prekey bundle store: \(error)", category: .security)
}
}
private func loadFromDisk() {
guard let fileURL else { return }
queue.sync {
guard let data = try? Data(contentsOf: fileURL),
let stored = try? JSONDecoder().decode([StoredBundle].self, from: data) else {
return
}
for bundle in stored where bundle.prekeyIDs.count == bundle.prekeyPublicKeys.count {
bundles[bundle.noiseKey] = bundle
}
}
}
private static func defaultFileURL() -> URL? {
guard let base = try? FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
) else { return nil }
return base
.appendingPathComponent("prekeys", isDirectory: true)
.appendingPathComponent("bundles.json")
}
}
+1 -1
View File
@@ -209,7 +209,7 @@ final class PrivateChatManager: ObservableObject {
case .read, .delivered: case .read, .delivered:
externalReceipts.insert(message.id) externalReceipts.insert(message.id)
sentReadReceipts.insert(message.id) sentReadReceipts.insert(message.id)
case .failed, .partiallyDelivered, .sending, .sent: case .failed, .partiallyDelivered, .sending, .sent, .carried:
break break
} }
} }
+10 -2
View File
@@ -18,10 +18,18 @@ struct RelayController {
isDirectedFragment: Bool, isDirectedFragment: Bool,
isHandshake: Bool, isHandshake: Bool,
isAnnounce: Bool, isAnnounce: Bool,
isRequestSync: Bool = false,
isUrgentBoardPost: Bool = false,
degree: Int, degree: Int,
highDegreeThreshold: Int) -> RelayDecision { highDegreeThreshold: Int) -> RelayDecision {
let ttlCap = min(ttl, TransportConfig.messageTTLDefault) let ttlCap = min(ttl, TransportConfig.messageTTLDefault)
// REQUEST_SYNC is link-local: never relay it, even when a peer crafts
// one with TTL headroom to turn every reachable node into a responder.
if isRequestSync {
return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0)
}
// Suppress obvious non-relays // Suppress obvious non-relays
if ttlCap <= 1 || senderIsSelf || recipientIsSelf { if ttlCap <= 1 || senderIsSelf || recipientIsSelf {
return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0) return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0)
@@ -57,7 +65,7 @@ struct RelayController {
// - Dense graphs: keep lower but still allow multi-hop bridging // - Dense graphs: keep lower but still allow multi-hop bridging
// - Thin chains (degree <= 2): every hop counts and flood cost is // - Thin chains (degree <= 2): every hop counts and flood cost is
// minimal, so relay at full incoming depth // minimal, so relay at full incoming depth
// - Announces get a bit more headroom // - Announces (and urgent board posts) get a bit more headroom
let ttlLimit: UInt8 = { let ttlLimit: UInt8 = {
if degree >= highDegreeThreshold { if degree >= highDegreeThreshold {
return max(UInt8(2), min(ttlCap, UInt8(5))) return max(UInt8(2), min(ttlCap, UInt8(5)))
@@ -65,7 +73,7 @@ struct RelayController {
if degree <= 2 { if degree <= 2 {
return ttlCap return ttlCap
} }
let preferred = UInt8(isAnnounce ? 7 : 6) let preferred = UInt8((isAnnounce || isUrgentBoardPost) ? 7 : 6)
return max(UInt8(2), min(ttlCap, preferred)) return max(UInt8(2), min(ttlCap, preferred))
}() }()
let newTTL = ttlLimit &- 1 let newTTL = ttlLimit &- 1
+121
View File
@@ -11,12 +11,67 @@ struct TransportPeerSnapshot: Equatable, Hashable {
let isConnected: Bool let isConnected: Bool
let noisePublicKey: Data? let noisePublicKey: Data?
let lastSeen: Date let lastSeen: Date
/// Whether the peer's announce was signature-verified (courier tier gate).
let isVerified: Bool
init(
peerID: PeerID,
nickname: String,
isConnected: Bool,
noisePublicKey: Data?,
lastSeen: Date,
isVerified: Bool = false
) {
self.peerID = peerID
self.nickname = nickname
self.isConnected = isConnected
self.noisePublicKey = noisePublicKey
self.lastSeen = lastSeen
self.isVerified = isVerified
}
}
/// Outcome of a `/ping` probe over the mesh.
struct MeshPingResult: Equatable {
/// Round-trip time in milliseconds.
let rttMs: Int
/// Total hops to the peer (1 = directly connected), derived from the
/// pong's TTL decrements; nil when the reply carried inconsistent TTLs.
let hops: Int?
}
/// Undirected mesh link between two peers, normalized so `(a, b)` and
/// `(b, a)` collapse to one edge.
struct MeshTopologyEdge: Hashable {
let a: PeerID
let b: PeerID
init(_ first: PeerID, _ second: PeerID) {
if first < second {
a = first
b = second
} else {
a = second
b = first
}
}
}
/// Point-in-time view of the mesh graph learned from gossiped announces
/// (each announce carries up to 10 `directNeighbors`).
struct MeshTopologySnapshot: Equatable {
let localPeerID: PeerID
let nodes: [PeerID]
let edges: [MeshTopologyEdge]
} }
enum TransportEvent: @unchecked Sendable { enum TransportEvent: @unchecked Sendable {
case messageReceived(BitchatMessage) case messageReceived(BitchatMessage)
case publicMessageReceived(peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) case publicMessageReceived(peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
case noisePayloadReceived(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) case noisePayloadReceived(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
/// Encrypted group broadcast (MessageType 0x25). Opaque here the group
/// coordinator decrypts and authenticates against the roster.
case groupMessageReceived(payload: Data, timestamp: Date)
case peerConnected(PeerID) case peerConnected(PeerID)
case peerDisconnected(PeerID) case peerDisconnected(PeerID)
case peerListUpdated([PeerID]) case peerListUpdated([PeerID])
@@ -54,6 +109,11 @@ protocol Transport: AnyObject {
// Connectivity and peers // Connectivity and peers
func isPeerConnected(_ peerID: PeerID) -> Bool func isPeerConnected(_ peerID: PeerID) -> Bool
func isPeerReachable(_ peerID: PeerID) -> Bool func isPeerReachable(_ peerID: PeerID) -> Bool
/// Whether a send to this peer is likely to leave the device promptly.
/// Distinct from reachability: Nostr claims any favorite with a known
/// npub as reachable even with no relay connection, where a send only
/// joins a queue waiting for internet that may never come.
func canDeliverPromptly(to peerID: PeerID) -> Bool
func peerNickname(peerID: PeerID) -> String? func peerNickname(peerID: PeerID) -> String?
func getPeerNicknames() -> [PeerID: String] func getPeerNicknames() -> [PeerID: String]
@@ -95,16 +155,59 @@ protocol Transport: AnyObject {
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
func cancelTransfer(_ transferId: String) func cancelTransfer(_ transferId: String)
// Courier store-and-forward (mesh transports only): seal a message to the
// recipient's static key and hand it to connected couriers for physical
// delivery while the recipient is offline. Returns false when the
// transport cannot courier (no connected courier, or unsupported).
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool
// Private groups (mesh transports only): creator-signed state travels
// 1:1 over Noise sessions; group messages flood like public broadcasts.
func sendGroupInvite(_ statePayload: Data, to peerID: PeerID)
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID)
func broadcastGroupMessage(_ envelope: Data)
// Bulletin board (mesh transports only): broadcast a pre-signed board
// payload (post or tombstone) so it spreads over relay and gossip sync.
func sendBoardPayload(_ payload: Data)
// Mesh diagnostics (optional for transports). Defaults are inert so
// queue-backed transports (e.g. NostrTransport) stay untouched.
/// Sends a directed ping probe; the completion fires exactly once on the
/// main actor with the measured result, or nil on timeout/unsupported.
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void)
/// Estimated intermediate hops toward `peerID` from gossiped topology
/// ([] = direct link, nil = no known path).
func computeMeshPath(to peerID: PeerID) -> [PeerID]?
/// Current mesh graph for the topology map; nil when unsupported.
func currentMeshTopology() -> MeshTopologySnapshot?
// QR verification (optional for transports) // QR verification (optional for transports)
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
// Vouching / transitive verification (optional for transports)
/// Capabilities the peer advertised in its last verified announce;
/// empty for peers that predate the capabilities TLV.
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities
/// Sends an encoded vouch-attestation batch inside the Noise session.
func sendVouchAttestations(_ payload: Data, to peerID: PeerID)
/// Appends a peer-authenticated observer. Unlike
/// `installNoiseSessionCallbacks` this never touches the (single-slot)
/// handshake-required callback, so secondary features can observe
/// session establishment without disturbing the primary registration.
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void)
// Pending file management (BCH-01-002: files held in memory until user accepts) // Pending file management (BCH-01-002: files held in memory until user accepts)
func acceptPendingFile(id: String) -> URL? func acceptPendingFile(id: String) -> URL?
func declinePendingFile(id: String) func declinePendingFile(id: String)
} }
extension Transport { extension Transport {
// Reachability implies prompt delivery for transports that hand packets
// straight to the radio; queue-backed transports override this.
func canDeliverPromptly(to peerID: PeerID) -> Bool { isPeerReachable(peerID) }
// Noise identity hooks default to inert for transports that do not carry // Noise identity hooks default to inert for transports that do not carry
// Noise sessions (e.g. NostrTransport). // Noise sessions (e.g. NostrTransport).
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { nil } func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { nil }
@@ -120,6 +223,22 @@ extension Transport {
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) {}
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {}
func broadcastGroupMessage(_ envelope: Data) {}
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {}
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {}
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
func sendBoardPayload(_ payload: Data) {}
// Mesh diagnostics are mesh-transport-only; other transports report
// "no reply"/"no path" rather than pretending to measure anything.
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) {
Task { @MainActor in completion(nil) }
}
func computeMeshPath(to peerID: PeerID) -> [PeerID]? { nil }
func currentMeshTopology() -> MeshTopologySnapshot? { nil }
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {} func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {} func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
func cancelTransfer(_ transferId: String) {} func cancelTransfer(_ transferId: String) {}
@@ -152,6 +271,8 @@ extension BitchatDelegate {
) )
case let .noisePayloadReceived(peerID, type, payload, timestamp): case let .noisePayloadReceived(peerID, type, payload, timestamp):
didReceiveNoisePayload(from: peerID, type: type, payload: payload, timestamp: timestamp) didReceiveNoisePayload(from: peerID, type: type, payload: payload, timestamp: timestamp)
case let .groupMessageReceived(payload, timestamp):
didReceiveGroupMessage(payload: payload, timestamp: timestamp)
case .peerConnected(let peerID): case .peerConnected(let peerID):
didConnectToPeer(peerID) didConnectToPeer(peerID)
case .peerDisconnected(let peerID): case .peerDisconnected(let peerID):
+64
View File
@@ -16,6 +16,11 @@ enum TransportConfig {
static let bleFragmentRelayTtlCap: UInt8 = 7 static let bleFragmentRelayTtlCap: UInt8 = 7
static let bleFragmentRelayTtlCapDense: UInt8 = 5 // Contain fragment floods in dense graphs static let bleFragmentRelayTtlCapDense: UInt8 = 5 // Contain fragment floods in dense graphs
// Mesh diagnostics (/ping)
static let meshPingTimeoutSeconds: TimeInterval = 10 // Give up on a probe after this window
static let meshPingInboundMaxPerLink: Int = 5 // Inbound ping budget per ingress link (claimed sender is spoofable)...
static let meshPingInboundWindowSeconds: TimeInterval = 10 // ...per sliding window (anti-amplification)
// UI / Storage Caps // UI / Storage Caps
static let privateChatCap: Int = 1337 static let privateChatCap: Int = 1337
static let meshTimelineCap: Int = 1337 static let meshTimelineCap: Int = 1337
@@ -208,6 +213,25 @@ enum TransportConfig {
static let bleSubscriptionRateLimitWindowSeconds: TimeInterval = 60.0 // Window for tracking subscription attempts static let bleSubscriptionRateLimitWindowSeconds: TimeInterval = 60.0 // Window for tracking subscription attempts
static let bleSubscriptionRateLimitMaxAttempts: Int = 5 // Max attempts before extended cooldown static let bleSubscriptionRateLimitMaxAttempts: Int = 5 // Max attempts before extended cooldown
// Source routing (v2 directed packets)
// Longest path we will originate, in intermediate hops between us and the
// recipient. Keep small: every hop must be a fresh, confirmed, v2-capable
// node, and long stale paths fail more often than floods.
static let bleSourceRouteMaxIntermediateHops: Int = 4
// A routed send with no inbound traffic from the recipient within this
// window counts as a route failure.
static let bleSourceRouteConfirmationWindowSeconds: TimeInterval = 10.0
// After a route failure, directed sends to that recipient flood instead
// of routing until this lapses.
static let bleSourceRouteSuppressionSeconds: TimeInterval = 60.0
// Targeted fragment resync (REQUEST_SYNC fragmentIdFilter)
// A broadcast reassembly with no new fragment for this long is stalled
// and triggers a targeted REQUEST_SYNC naming its fragment stream.
static let bleFragmentResyncStallSeconds: TimeInterval = 5.0
// Minimum spacing between targeted resync requests for the same stream.
static let bleFragmentResyncRetrySeconds: TimeInterval = 10.0
// Store-and-forward for directed packets at relays. Spooled packets retry // Store-and-forward for directed packets at relays. Spooled packets retry
// on each maintenance flush until the window lapses; a longer window lets // on each maintenance flush until the window lapses; a longer window lets
// brief link gaps (walking between rooms, reconnect churn) heal themselves. // brief link gaps (walking between rooms, reconnect churn) heal themselves.
@@ -218,6 +242,16 @@ enum TransportConfig {
static let bleDisconnectNotifyDebounceSeconds: TimeInterval = 0.9 static let bleDisconnectNotifyDebounceSeconds: TimeInterval = 0.9
static let bleReconnectLogDebounceSeconds: TimeInterval = 2.0 static let bleReconnectLogDebounceSeconds: TimeInterval = 2.0
// Background wake-on-proximity (iOS). Pending connects issued on
// backgrounding never expire at the OS level: the Bluetooth controller
// completes them whenever the peer reappears in range and relaunches the
// app via state restoration. Entries older than the BLE address-rotation
// window no longer map to a reachable address, so the cache prunes them.
static let bleRecentPeripheralCacheCap: Int = 16
static let bleRecentPeripheralMaxAgeSeconds: TimeInterval = 15 * 60
// Central slots kept free for connects driven by live background discovery
static let bleBackgroundPendingConnectSlotReserve: Int = 2
// Weak-link cooldown after connection timeouts // Weak-link cooldown after connection timeouts
static let bleWeakLinkCooldownSeconds: TimeInterval = 30.0 static let bleWeakLinkCooldownSeconds: TimeInterval = 30.0
static let bleWeakLinkRSSICutoff: Int = -90 static let bleWeakLinkRSSICutoff: Int = -90
@@ -262,7 +296,14 @@ enum TransportConfig {
static let syncSeenCapacity: Int = 1000 static let syncSeenCapacity: Int = 1000
static let syncGCSMaxBytes: Int = 400 static let syncGCSMaxBytes: Int = 400
static let syncGCSTargetFpr: Double = 0.01 static let syncGCSTargetFpr: Double = 0.01
// Fragments and file transfers keep the short window; whole public
// messages get hours so a phone walking between partitions carries the
// room's recent history with it (see syncPublicMessageMaxAgeSeconds).
static let syncMaxMessageAgeSeconds: TimeInterval = 900 static let syncMaxMessageAgeSeconds: TimeInterval = 900
// How far back public broadcast messages stay sync-able. Must not exceed
// the receive-side acceptance window (BLEPublicMessagePolicy uses this
// same constant) or served packets would be dropped as stale.
static let syncPublicMessageMaxAgeSeconds: TimeInterval = 6 * 60 * 60
static let syncMaintenanceIntervalSeconds: TimeInterval = 30.0 static let syncMaintenanceIntervalSeconds: TimeInterval = 30.0
static let syncStalePeerCleanupIntervalSeconds: TimeInterval = 60.0 static let syncStalePeerCleanupIntervalSeconds: TimeInterval = 60.0
static let syncStalePeerTimeoutSeconds: TimeInterval = 60.0 static let syncStalePeerTimeoutSeconds: TimeInterval = 60.0
@@ -271,4 +312,27 @@ enum TransportConfig {
static let syncFragmentIntervalSeconds: TimeInterval = 30.0 static let syncFragmentIntervalSeconds: TimeInterval = 30.0
static let syncFileTransferIntervalSeconds: TimeInterval = 60.0 static let syncFileTransferIntervalSeconds: TimeInterval = 60.0
static let syncMessageIntervalSeconds: TimeInterval = 15.0 static let syncMessageIntervalSeconds: TimeInterval = 15.0
static let syncResponseRateLimitMaxResponses: Int = 8
static let syncResponseRateLimitWindowSeconds: TimeInterval = 30.0
// Courier store-and-forward
// Initial spray-and-wait budget per deposited envelope: each courier may
// hand half its remaining copies to another courier on encounter, so a
// message diffuses through a moving crowd instead of riding one person.
static let courierInitialCopies: UInt8 = 4
// Cooldown between speculative multi-hop handovers of the same envelope
// toward a recipient heard only via relayed announces.
static let courierRemoteHandoverCooldownSeconds: TimeInterval = 10 * 60
// One-time prekey bundles (forward-secret courier sealing)
// Own gossip-sync round for bundles: modest cadence, bounded peer count,
// and a long freshness window so bundles persist mesh-wide while their
// owners are away.
static let syncPrekeyBundleCapacity: Int = 200
static let syncPrekeyBundleIntervalSeconds: TimeInterval = 60.0
static let syncPrekeyBundleMaxAgeSeconds: TimeInterval = 24 * 60 * 60
// Unforced re-broadcasts of our own (unchanged) bundle, piggybacked on
// announces, keep it alive in peers' gossip stores; changed bundles are
// sent immediately.
static let prekeyBundleRebroadcastSeconds: TimeInterval = 60 * 60
} }
+38 -17
View File
@@ -86,45 +86,44 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
var enrichedPeers: [BitchatPeer] = [] var enrichedPeers: [BitchatPeer] = []
var connected: Set<PeerID> = [] var connected: Set<PeerID> = []
var addedPeerIDs: Set<PeerID> = [] var addedPeerIDs: Set<PeerID> = []
var meshNoiseKeys: Set<Data> = []
// Phase 1: Add all mesh peers (connected and reachable) // Phase 1: Add all mesh peers (connected and reachable)
for peerInfo in meshPeers { for peerInfo in meshPeers {
let peerID = peerInfo.peerID let peerID = peerInfo.peerID
guard peerID != meshService.myPeerID else { continue } // Never add self guard peerID != meshService.myPeerID else { continue } // Never add self
let peer = buildPeerFromMesh( let peer = buildPeerFromMesh(
peerInfo: peerInfo, peerInfo: peerInfo,
favorites: favorites, favorites: favorites,
meshAttached: hasAnyConnected meshAttached: hasAnyConnected
) )
enrichedPeers.append(peer) enrichedPeers.append(peer)
if peer.isConnected { connected.insert(peerID) } if peer.isConnected { connected.insert(peerID) }
addedPeerIDs.insert(peerID) addedPeerIDs.insert(peerID)
// Update fingerprint cache // Update fingerprint cache
if let publicKey = peerInfo.noisePublicKey { if let publicKey = peerInfo.noisePublicKey {
meshNoiseKeys.insert(publicKey)
fingerprintCache[peerID] = publicKey.sha256Fingerprint() fingerprintCache[peerID] = publicKey.sha256Fingerprint()
} }
} }
// Phase 2: Add offline favorites that we actively favorite // Phase 2: Add offline favorites that we actively favorite.
// Mesh rows use the short 16-hex peer ID while favorites are keyed by
// the full 32-byte noise key, so dedup must compare noise keys a
// PeerID comparison between the two forms can never match.
for (favoriteKey, favorite) in favorites where favorite.isFavorite { for (favoriteKey, favorite) in favorites where favorite.isFavorite {
if meshNoiseKeys.contains(favoriteKey) { continue }
let peerID = PeerID(hexData: favoriteKey) let peerID = PeerID(hexData: favoriteKey)
// Skip if already added (connected peer)
if addedPeerIDs.contains(peerID) { continue } if addedPeerIDs.contains(peerID) { continue }
// Skip if connected under different ID but same nickname
let isConnectedByNickname = enrichedPeers.contains {
$0.nickname == favorite.peerNickname && $0.isConnected
}
if isConnectedByNickname { continue }
let peer = buildPeerFromFavorite(favorite: favorite, peerID: peerID) let peer = buildPeerFromFavorite(favorite: favorite, peerID: peerID)
enrichedPeers.append(peer) enrichedPeers.append(peer)
addedPeerIDs.insert(peerID) addedPeerIDs.insert(peerID)
// Update fingerprint cache // Update fingerprint cache
fingerprintCache[peerID] = favoriteKey.sha256Fingerprint() fingerprintCache[peerID] = favoriteKey.sha256Fingerprint()
} }
@@ -257,7 +256,29 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
return false return false
} }
/// Block or unblock a mesh peer by its stable Noise identity.
///
/// The block is keyed by the peer's fingerprint, resolved from `peerID`
/// (cache / mesh session / known-peer Noise key). This works even when the
/// peer is offline including offline favorites so the exact tapped peer
/// is (un)blocked unambiguously instead of being re-resolved by a
/// display-name string that two peers could share.
/// - Returns: the resolved fingerprint, or `nil` if the identity is unknown.
@discardableResult
func setBlocked(_ peerID: PeerID, blocked: Bool) -> String? {
guard let fingerprint = getFingerprint(for: peerID) else {
SecureLogger.warning(
"⚠️ Cannot \(blocked ? "block" : "unblock") - unknown identity for peer: \(peerID)",
category: .session
)
return nil
}
identityManager.setBlocked(fingerprint, isBlocked: blocked)
updatePeers()
return fingerprint
}
/// Toggle favorite status /// Toggle favorite status
func toggleFavorite(_ peerID: PeerID) { func toggleFavorite(_ peerID: PeerID) {
guard let peer = getPeer(by: peerID) else { guard let peer = getPeer(by: peerID) else {
+32 -25
View File
@@ -10,7 +10,13 @@ import CryptoKit
// - Golomb-Rice with parameter P: q = (x - 1) >> P encoded as unary (q ones then a zero), then write P-bit remainder r = (x - 1) & ((1<<P)-1). // - Golomb-Rice with parameter P: q = (x - 1) >> P encoded as unary (q ones then a zero), then write P-bit remainder r = (x - 1) & ((1<<P)-1).
// - Bitstream is MSB-first within each byte. // - Bitstream is MSB-first within each byte.
enum GCSFilter { enum GCSFilter {
struct Params { let p: Int; let m: UInt32; let data: Data } // `includedCount` is how many of the input `ids` (in input order) the
// returned filter actually encodes. It can be below `ids.count` when the
// Golomb-Rice encoding overflows the byte budget and the tail is trimmed.
// Callers that derive a since-cursor need this: trimming drops from the
// input tail, so the first `includedCount` inputs are exactly what the
// filter covers.
struct Params { let p: Int; let m: UInt32; let data: Data; let includedCount: Int }
// Highest Golomb-Rice parameter we accept from the wire. P maps to an FPR // Highest Golomb-Rice parameter we accept from the wire. P maps to an FPR
// of ~1/2^P; beyond 32 the remainder width exceeds any practical filter // of ~1/2^P; beyond 32 the remainder width exceeds any practical filter
@@ -35,39 +41,40 @@ enum GCSFilter {
static func buildFilter(ids: [Data], maxBytes: Int, targetFpr: Double) -> Params { static func buildFilter(ids: [Data], maxBytes: Int, targetFpr: Double) -> Params {
let p = deriveP(targetFpr: targetFpr) let p = deriveP(targetFpr: targetFpr)
guard !ids.isEmpty else { guard !ids.isEmpty else {
return Params(p: p, m: 1, data: Data()) return Params(p: p, m: 1, data: Data(), includedCount: 0)
} }
let cap = estimateMaxElements(sizeBytes: maxBytes, p: p) let cap = estimateMaxElements(sizeBytes: maxBytes, p: p)
let selected = Array(ids.prefix(cap)) // Modulus is fixed to the initial candidate count so `m` stays stable
let range = max(1, hashRange(count: selected.count, p: p)) // as the tail is trimmed to fit the byte budget below.
let range = max(1, hashRange(count: min(ids.count, cap), p: p))
let modulo = UInt64(range) let modulo = UInt64(range)
var mapped = selected // Encode the first `count` inputs (input order). The caller passes IDs
.map { h64($0) } // newest-first, so trimming from the tail drops the oldest which is
.map { mapHash($0, modulo: modulo) } // what lets a since-cursor stay exact: the surviving set is always a
.sorted() // contiguous newest-prefix, never a hash-order-arbitrary subset.
mapped = normalizeMappedValues(mapped, modulo: modulo) func encodeFirst(_ count: Int) -> Data {
var mapped = ids.prefix(count)
if mapped.isEmpty { .map { h64($0) }
return Params(p: p, m: range, data: Data()) .map { mapHash($0, modulo: modulo) }
.sorted()
mapped = normalizeMappedValues(mapped, modulo: modulo)
return mapped.isEmpty ? Data() : encode(sorted: mapped, p: p)
} }
var encoded = encode(sorted: mapped, p: p) var count = min(ids.count, cap)
var trimmedCount = mapped.count var encoded = encodeFirst(count)
while encoded.count > maxBytes && count > 1 {
while encoded.count > maxBytes && trimmedCount > 0 { count = max(1, (count * 9) / 10)
if trimmedCount == 1 { encoded = encodeFirst(count)
mapped.removeAll() }
encoded = Data() // A single element that still overflows can't be represented.
break if encoded.count > maxBytes {
} return Params(p: p, m: range, data: Data(), includedCount: 0)
trimmedCount = max(1, (trimmedCount * 9) / 10)
mapped = Array(mapped.prefix(trimmedCount))
encoded = encode(sorted: mapped, p: p)
} }
return Params(p: p, m: range, data: encoded) return Params(p: p, m: range, data: encoded, includedCount: encoded.isEmpty ? 0 : count)
} }
static func decodeToSortedSet(p: Int, m: UInt32, data: Data) -> [UInt64] { static func decodeToSortedSet(p: Int, m: UInt32, data: Data) -> [UInt64] {
+80
View File
@@ -0,0 +1,80 @@
//
// GossipMessageArchive.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import Foundation
/// Disk persistence for the gossip-sync public message store, so the recent
/// public history a device carries survives app restarts. This is what lets
/// a phone act as a town crier: walk between two mesh partitions (or relaunch
/// hours later) and sync the room's backlog to whoever missed it.
///
/// Contents are signed public broadcasts already visible to anyone in radio
/// range so file protection (no additional sealing) is the right at-rest
/// posture. Wiped on panic.
final class GossipMessageArchive {
private let fileURL: URL?
init(fileURL: URL? = nil) {
self.fileURL = fileURL ?? Self.defaultFileURL()
}
/// Raw binary packets, decoded and freshness-filtered by the caller.
func load() -> [Data] {
guard let fileURL,
let data = try? Data(contentsOf: fileURL),
let packets = try? JSONDecoder().decode([Data].self, from: data) else {
return []
}
return packets
}
func save(_ packets: [Data]) {
guard let fileURL else { return }
guard !packets.isEmpty else {
try? FileManager.default.removeItem(at: fileURL)
return
}
do {
try FileManager.default.createDirectory(
at: fileURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
let data = try JSONEncoder().encode(packets)
var options: Data.WritingOptions = [.atomic]
#if os(iOS)
options.insert(.completeFileProtection)
#endif
try data.write(to: fileURL, options: options)
} catch {
SecureLogger.error("Failed to persist gossip archive: \(error)", category: .sync)
}
}
func wipe() {
guard let fileURL else { return }
try? FileManager.default.removeItem(at: fileURL)
}
/// Panic-wipe hook for callers that don't hold the live instance.
static func wipeDefault() {
GossipMessageArchive().wipe()
}
private static func defaultFileURL() -> URL? {
guard let base = try? FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
) else { return nil }
return base
.appendingPathComponent("sync", isDirectory: true)
.appendingPathComponent("public-messages.json")
}
}
+314 -32
View File
@@ -64,41 +64,82 @@ final class GossipSyncManager {
var seenCapacity: Int = 1000 // max packets per sync (cap across types) var seenCapacity: Int = 1000 // max packets per sync (cap across types)
var gcsMaxBytes: Int = 400 // filter size budget (128..1024) var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
var gcsTargetFpr: Double = 0.01 // 1% var gcsTargetFpr: Double = 0.01 // 1%
var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - discard older messages var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - fragments/files/announces
// Whole public messages stay sync-able much longer so devices carry
// the room's recent history between partitions and across restarts.
var publicMessageMaxAgeSeconds: TimeInterval = 900
var maintenanceIntervalSeconds: TimeInterval = 30.0 var maintenanceIntervalSeconds: TimeInterval = 30.0
var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0 var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0
var stalePeerTimeoutSeconds: TimeInterval = 60.0 var stalePeerTimeoutSeconds: TimeInterval = 60.0
var fragmentCapacity: Int = 600 var fragmentCapacity: Int = 600
var fileTransferCapacity: Int = 200 var fileTransferCapacity: Int = 200
var groupMessageCapacity: Int = 200
var fragmentSyncIntervalSeconds: TimeInterval = 30.0 var fragmentSyncIntervalSeconds: TimeInterval = 30.0
var fileTransferSyncIntervalSeconds: TimeInterval = 60.0 var fileTransferSyncIntervalSeconds: TimeInterval = 60.0
var messageSyncIntervalSeconds: TimeInterval = 15.0 var messageSyncIntervalSeconds: TimeInterval = 15.0
// Board posts are few but long-lived (days, until each post's own
// expiry), so they get a slow round with their own capacity instead
// of competing with the 15-minute message window.
var boardCapacity: Int = 200
var boardSyncIntervalSeconds: TimeInterval = 60.0
var responseRateLimitMaxResponses: Int = 8
var responseRateLimitWindowSeconds: TimeInterval = 30.0
// Prekey bundles: one per peer, own sync round, long freshness so
// bundles persist mesh-wide while their owners are offline.
var prekeyBundleCapacity: Int = 200
var prekeyBundleSyncIntervalSeconds: TimeInterval = 60.0
var prekeyBundleMaxAgeSeconds: TimeInterval = 24 * 60 * 60
} }
private let myPeerID: PeerID private let myPeerID: PeerID
private let config: Config private let config: Config
private let requestSyncManager: RequestSyncManager private let requestSyncManager: RequestSyncManager
private let archive: GossipMessageArchive?
weak var delegate: Delegate? weak var delegate: Delegate?
/// Source of raw signed board packets (posts + tombstones). The board
/// store is the single owner of board retention (expiry, tombstones,
/// caps, persistence), so sync rounds query it instead of keeping a
/// second copy here. Must be thread-safe; set before `start()`.
var boardPacketsProvider: (() -> [BitchatPacket])?
// Storage: broadcast packets by type, and latest announce per sender // Storage: broadcast packets by type, and latest announce per sender
private var messages = PacketStore() private var messages = PacketStore()
private var fragments = PacketStore() private var fragments = PacketStore()
private var fileTransfers = PacketStore() private var fileTransfers = PacketStore()
private var latestAnnouncementByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:] private var groupMessages = PacketStore()
private var latestAnnouncementByPeer: [PeerID: BitchatPacket] = [:]
// Latest verified prekey bundle per owner. Unlike announces, bundles are
// NOT dropped on leave/stale peer: their whole purpose is reaching a
// sender while the owner is away.
private var latestPrekeyBundleByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:]
private var archiveDirty = false
// Timer // Timer
private var periodicTimer: DispatchSourceTimer? private var periodicTimer: DispatchSourceTimer?
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility) private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
private var lastStalePeerCleanup: Date = .distantPast private var lastStalePeerCleanup: Date = .distantPast
private var syncSchedules: [SyncSchedule] = [] private var syncSchedules: [SyncSchedule] = []
private var responseRateLimiter: SyncResponseRateLimiter
init(myPeerID: PeerID, config: Config = Config(), requestSyncManager: RequestSyncManager) { init(myPeerID: PeerID, config: Config = Config(), requestSyncManager: RequestSyncManager, archive: GossipMessageArchive? = nil) {
self.myPeerID = myPeerID self.myPeerID = myPeerID
self.config = config self.config = config
self.requestSyncManager = requestSyncManager self.requestSyncManager = requestSyncManager
self.archive = archive
self.responseRateLimiter = SyncResponseRateLimiter(
maxResponses: config.responseRateLimitMaxResponses,
window: config.responseRateLimitWindowSeconds
)
var schedules: [SyncSchedule] = [] var schedules: [SyncSchedule] = []
if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 { if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 {
schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast)) // Group messages ride the public-message cadence; old clients
// ignore the extended bit and answer with announces/messages only.
var messageTypes: SyncTypeFlags = .publicMessages
if config.groupMessageCapacity > 0 {
messageTypes.formUnion(.groupMessage)
}
schedules.append(SyncSchedule(types: messageTypes, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast))
} }
if config.fragmentCapacity > 0 && config.fragmentSyncIntervalSeconds > 0 { if config.fragmentCapacity > 0 && config.fragmentSyncIntervalSeconds > 0 {
schedules.append(SyncSchedule(types: .fragment, interval: config.fragmentSyncIntervalSeconds, lastSent: .distantPast)) schedules.append(SyncSchedule(types: .fragment, interval: config.fragmentSyncIntervalSeconds, lastSent: .distantPast))
@@ -106,7 +147,19 @@ final class GossipSyncManager {
if config.fileTransferCapacity > 0 && config.fileTransferSyncIntervalSeconds > 0 { if config.fileTransferCapacity > 0 && config.fileTransferSyncIntervalSeconds > 0 {
schedules.append(SyncSchedule(types: .fileTransfer, interval: config.fileTransferSyncIntervalSeconds, lastSent: .distantPast)) schedules.append(SyncSchedule(types: .fileTransfer, interval: config.fileTransferSyncIntervalSeconds, lastSent: .distantPast))
} }
if config.prekeyBundleCapacity > 0 && config.prekeyBundleSyncIntervalSeconds > 0 {
schedules.append(SyncSchedule(types: .prekeyBundle, interval: config.prekeyBundleSyncIntervalSeconds, lastSent: .distantPast))
}
if config.boardCapacity > 0 && config.boardSyncIntervalSeconds > 0 {
schedules.append(SyncSchedule(types: .board, interval: config.boardSyncIntervalSeconds, lastSent: .distantPast))
}
syncSchedules = schedules syncSchedules = schedules
if archive != nil {
queue.async { [weak self] in
self?.restoreArchivedMessages()
}
}
} }
func start() { func start() {
@@ -130,12 +183,21 @@ final class GossipSyncManager {
guard let self = self else { return } guard let self = self else { return }
var types: SyncTypeFlags = .publicMessages var types: SyncTypeFlags = .publicMessages
if self.config.groupMessageCapacity > 0 {
types.formUnion(.groupMessage)
}
if self.config.fragmentCapacity > 0 && self.config.fragmentSyncIntervalSeconds > 0 { if self.config.fragmentCapacity > 0 && self.config.fragmentSyncIntervalSeconds > 0 {
types.formUnion(.fragment) types.formUnion(.fragment)
} }
if self.config.fileTransferCapacity > 0 && self.config.fileTransferSyncIntervalSeconds > 0 { if self.config.fileTransferCapacity > 0 && self.config.fileTransferSyncIntervalSeconds > 0 {
types.formUnion(.fileTransfer) types.formUnion(.fileTransfer)
} }
if self.config.prekeyBundleCapacity > 0 && self.config.prekeyBundleSyncIntervalSeconds > 0 {
types.formUnion(.prekeyBundle)
}
if self.config.boardCapacity > 0 && self.config.boardSyncIntervalSeconds > 0 && self.boardPacketsProvider != nil {
types.formUnion(.board)
}
self.sendRequestSync(to: peerID, types: types) self.sendRequestSync(to: peerID, types: types)
} }
} }
@@ -146,10 +208,23 @@ final class GossipSyncManager {
} }
} }
// Helper to check if a packet is within the age threshold // Helper to check if a packet is within the age threshold. Whole public
// messages get the long town-crier window; fragments, file transfers and
// announces keep the short one.
private func isPacketFresh(_ packet: BitchatPacket) -> Bool { private func isPacketFresh(_ packet: BitchatPacket) -> Bool {
// Group messages share the whole-message window: members off the mesh
// for a while should backfill their crew's history like public chat.
let maxAgeSeconds: TimeInterval
switch packet.type {
case MessageType.message.rawValue, MessageType.groupMessage.rawValue:
maxAgeSeconds = config.publicMessageMaxAgeSeconds
case MessageType.prekeyBundle.rawValue:
maxAgeSeconds = config.prekeyBundleMaxAgeSeconds
default:
maxAgeSeconds = config.maxMessageAgeSeconds
}
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
let ageThresholdMs = UInt64(config.maxMessageAgeSeconds * 1000) let ageThresholdMs = UInt64(maxAgeSeconds * 1000)
// If current time is less than threshold, accept all (handle clock issues gracefully) // If current time is less than threshold, accept all (handle clock issues gracefully)
guard nowMs >= ageThresholdMs else { return true } guard nowMs >= ageThresholdMs else { return true }
@@ -182,14 +257,14 @@ final class GossipSyncManager {
removeState(for: sender) removeState(for: sender)
return return
} }
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
let sender = PeerID(hexData: packet.senderID) let sender = PeerID(hexData: packet.senderID)
latestAnnouncementByPeer[sender] = (id: idHex, packet: packet) latestAnnouncementByPeer[sender] = packet
case .message: case .message:
guard isBroadcastRecipient else { return } guard isBroadcastRecipient else { return }
guard isPacketFresh(packet) else { return } guard isPacketFresh(packet) else { return }
let idHex = PacketIdUtil.computeId(packet).hexEncodedString() let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
messages.insert(idHex: idHex, packet: packet, capacity: max(1, config.seenCapacity)) messages.insert(idHex: idHex, packet: packet, capacity: max(1, config.seenCapacity))
archiveDirty = true
case .fragment: case .fragment:
guard isBroadcastRecipient else { return } guard isBroadcastRecipient else { return }
guard isPacketFresh(packet) else { return } guard isPacketFresh(packet) else { return }
@@ -200,6 +275,36 @@ final class GossipSyncManager {
guard isPacketFresh(packet) else { return } guard isPacketFresh(packet) else { return }
let idHex = PacketIdUtil.computeId(packet).hexEncodedString() let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
fileTransfers.insert(idHex: idHex, packet: packet, capacity: max(1, config.fileTransferCapacity)) fileTransfers.insert(idHex: idHex, packet: packet, capacity: max(1, config.fileTransferCapacity))
case .groupMessage:
// Opaque ciphertext to non-members; carried and served like any
// other broadcast so members get backfill from any relay.
guard isBroadcastRecipient else { return }
guard isPacketFresh(packet) else { return }
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
groupMessages.insert(idHex: idHex, packet: packet, capacity: max(1, config.groupMessageCapacity))
case .prekeyBundle:
// Callers only feed verified bundles here (own bundles at send
// time, peers' after signature verification), so gossip never
// spreads a bundle this node couldn't attribute.
guard isBroadcastRecipient else { return }
guard isPacketFresh(packet) else { return }
// Key by the bundle's authenticated identity (its noise static key),
// NOT the unauthenticated packet senderID. Otherwise one valid
// bundle re-broadcast under many fabricated sender IDs would create
// one cache entry each and exhaust the per-owner cap, starving
// legitimate bundles. One owner at most one entry.
guard let bundle = PrekeyBundle.decode(packet.payload) else { return }
let owner = PeerID(publicKey: bundle.noiseStaticPublicKey)
if let existing = latestPrekeyBundleByPeer[owner],
existing.packet.timestamp >= packet.timestamp {
return
}
// Bounded owner count; replacing a known owner's bundle is always
// allowed so the cap can't block refreshes.
guard latestPrekeyBundleByPeer[owner] != nil
|| latestPrekeyBundleByPeer.count < max(1, config.prekeyBundleCapacity) else { return }
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
latestPrekeyBundleByPeer[owner] = (id: idHex, packet: packet)
default: default:
break break
} }
@@ -208,7 +313,7 @@ final class GossipSyncManager {
private func sendPeriodicSync(for types: SyncTypeFlags) { private func sendPeriodicSync(for types: SyncTypeFlags) {
// Unicast sync to connected peers to allow RSR attribution // Unicast sync to connected peers to allow RSR attribution
if let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty { if let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty {
SecureLogger.debug("Sending periodic sync to \(connectedPeers.count) connected peers", category: .sync) SecureLogger.debug("Sending periodic sync (\(types.logDescription)) to \(connectedPeers.count) connected peers", category: .sync)
for peerID in connectedPeers { for peerID in connectedPeers {
sendRequestSync(to: peerID, types: types) sendRequestSync(to: peerID, types: types)
} }
@@ -233,11 +338,29 @@ final class GossipSyncManager {
delegate?.sendPacket(signed) delegate?.sendPacket(signed)
} }
private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags) { /// Targeted fragment recovery: ask connected peers for the specific
/// fragment streams whose reassembly has stalled, instead of waiting on
/// the next periodic GCS fragment round to cover them.
func requestMissingFragments(fragmentIDs: [Data]) {
queue.async { [weak self] in
self?._requestMissingFragments(fragmentIDs)
}
}
private func _requestMissingFragments(_ fragmentIDs: [Data]) {
guard let filter = RequestSyncPacket.encodeFragmentIdFilter(fragmentIDs) else { return }
guard let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty else { return }
SecureLogger.debug("Requesting \(fragmentIDs.count) stalled fragment stream(s) from \(connectedPeers.count) peer(s)", category: .sync)
for peerID in connectedPeers {
sendRequestSync(to: peerID, types: .fragment, fragmentIdFilter: filter)
}
}
private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags, fragmentIdFilter: String? = nil) {
// Register the request for RSR validation // Register the request for RSR validation
requestSyncManager.registerRequest(to: peerID) requestSyncManager.registerRequest(to: peerID)
let payload = buildGcsPayload(for: types) let payload = buildGcsPayload(for: types, fragmentIdFilter: fragmentIdFilter)
var recipient = Data() var recipient = Data()
var temp = peerID.id var temp = peerID.id
while temp.count >= 2 && recipient.count < 8 { while temp.count >= 2 && recipient.count < 8 {
@@ -265,7 +388,17 @@ final class GossipSyncManager {
} }
private func _handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) { private func _handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
// A response can replay the whole store, so bound how often one peer
// can trigger a diff pass regardless of how fast it asks.
guard responseRateLimiter.shouldRespond(to: peerID, now: Date()) else {
SecureLogger.warning("Rate-limited REQUEST_SYNC from \(peerID.id.prefix(8))", category: .sync)
return
}
let requestedTypes = (request.types ?? .publicMessages) let requestedTypes = (request.types ?? .publicMessages)
// The requester's filter only covers packets at or after this cursor;
// older packets are outside the filter but not missing, and without
// the cursor they would be re-sent every round.
let since = request.sinceTimestamp
// Decode GCS into sorted set and prepare membership checker // Decode GCS into sorted set and prepare membership checker
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data) let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
func mightContain(_ id: Data) -> Bool { func mightContain(_ id: Data) -> Bool {
@@ -273,11 +406,13 @@ final class GossipSyncManager {
return GCSFilter.contains(sortedValues: sorted, candidate: bucket) return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
} }
// Announces are exempt from the since-cursor: they carry the signing
// keys needed to verify everything else, and there is at most one per
// peer, so the resend cost is negligible.
if requestedTypes.contains(.announce) { if requestedTypes.contains(.announce) {
for (_, pair) in latestAnnouncementByPeer { for (_, pkt) in latestAnnouncementByPeer {
let (idHex, pkt) = pair
guard isPacketFresh(pkt) else { continue } guard isPacketFresh(pkt) else { continue }
let idBytes = Data(hexString: idHex) ?? Data() let idBytes = PacketIdUtil.computeId(pkt)
if !mightContain(idBytes) { if !mightContain(idBytes) {
var toSend = pkt var toSend = pkt
toSend.ttl = 0 toSend.ttl = 0
@@ -290,6 +425,7 @@ final class GossipSyncManager {
if requestedTypes.contains(.message) { if requestedTypes.contains(.message) {
let toSendMsgs = messages.allPackets(isFresh: isPacketFresh) let toSendMsgs = messages.allPackets(isFresh: isPacketFresh)
for pkt in toSendMsgs { for pkt in toSendMsgs {
if let since, pkt.timestamp < since { continue }
let idBytes = PacketIdUtil.computeId(pkt) let idBytes = PacketIdUtil.computeId(pkt)
if !mightContain(idBytes) { if !mightContain(idBytes) {
var toSend = pkt var toSend = pkt
@@ -301,8 +437,19 @@ final class GossipSyncManager {
} }
if requestedTypes.contains(.fragment) { if requestedTypes.contains(.fragment) {
// A fragment-ID filter narrows the diff to exactly the named
// fragment streams (targeted resync for stalled reassemblies)
// and bypasses the since-cursor for them; the GCS filter still
// excludes the pieces the requester already holds. Fragment
// payloads start with the 8-byte stream ID.
let fragmentIdFilter = RequestSyncPacket.decodeFragmentIdFilter(request.fragmentIdFilter)
let frags = fragments.allPackets(isFresh: isPacketFresh) let frags = fragments.allPackets(isFresh: isPacketFresh)
for pkt in frags { for pkt in frags {
if let fragmentIdFilter {
guard fragmentIdFilter.contains(Data(pkt.payload.prefix(8))) else { continue }
} else if let since, pkt.timestamp < since {
continue
}
let idBytes = PacketIdUtil.computeId(pkt) let idBytes = PacketIdUtil.computeId(pkt)
if !mightContain(idBytes) { if !mightContain(idBytes) {
var toSend = pkt var toSend = pkt
@@ -316,6 +463,53 @@ final class GossipSyncManager {
if requestedTypes.contains(.fileTransfer) { if requestedTypes.contains(.fileTransfer) {
let files = fileTransfers.allPackets(isFresh: isPacketFresh) let files = fileTransfers.allPackets(isFresh: isPacketFresh)
for pkt in files { for pkt in files {
if let since, pkt.timestamp < since { continue }
let idBytes = PacketIdUtil.computeId(pkt)
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend)
}
}
}
if requestedTypes.contains(.groupMessage) {
let groupPkts = groupMessages.allPackets(isFresh: isPacketFresh)
for pkt in groupPkts {
if let since, pkt.timestamp < since { continue }
let idBytes = PacketIdUtil.computeId(pkt)
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend)
}
}
}
// Like announces, prekey bundles are exempt from the since-cursor:
// there is at most one per owner (newer replaces older), so the
// resend cost is bounded and a joining peer must be able to learn
// bundles generated long before it arrived.
if requestedTypes.contains(.prekeyBundle) {
for (_, pair) in latestPrekeyBundleByPeer {
let (idHex, pkt) = pair
guard isPacketFresh(pkt) else { continue }
let idBytes = Data(hexString: idHex) ?? Data()
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend)
}
}
}
if requestedTypes.contains(.boardPost) {
// The board store already filters to live posts and tombstones;
// no freshness window applies (posts sync until their own expiry).
let boardPackets = boardPacketsProvider?() ?? []
for pkt in boardPackets {
if let since, pkt.timestamp < since { continue }
let idBytes = PacketIdUtil.computeId(pkt) let idBytes = PacketIdUtil.computeId(pkt)
if !mightContain(idBytes) { if !mightContain(idBytes) {
var toSend = pkt var toSend = pkt
@@ -328,11 +522,11 @@ final class GossipSyncManager {
} }
// Build REQUEST_SYNC payload using current candidates and GCS params // Build REQUEST_SYNC payload using current candidates and GCS params
private func buildGcsPayload(for types: SyncTypeFlags) -> Data { private func buildGcsPayload(for types: SyncTypeFlags, fragmentIdFilter: String? = nil) -> Data {
var candidates: [BitchatPacket] = [] var candidates: [BitchatPacket] = []
if types.contains(.announce) { if types.contains(.announce) {
for (_, pair) in latestAnnouncementByPeer where isPacketFresh(pair.packet) { for (_, pkt) in latestAnnouncementByPeer where isPacketFresh(pkt) {
candidates.append(pair.packet) candidates.append(pkt)
} }
} }
if types.contains(.message) { if types.contains(.message) {
@@ -344,9 +538,20 @@ final class GossipSyncManager {
if types.contains(.fileTransfer) { if types.contains(.fileTransfer) {
candidates.append(contentsOf: fileTransfers.allPackets(isFresh: isPacketFresh)) candidates.append(contentsOf: fileTransfers.allPackets(isFresh: isPacketFresh))
} }
if types.contains(.groupMessage) {
candidates.append(contentsOf: groupMessages.allPackets(isFresh: isPacketFresh))
}
if types.contains(.prekeyBundle) {
for (_, pair) in latestPrekeyBundleByPeer where isPacketFresh(pair.packet) {
candidates.append(pair.packet)
}
}
if types.contains(.boardPost) {
candidates.append(contentsOf: boardPacketsProvider?() ?? [])
}
if candidates.isEmpty { if candidates.isEmpty {
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr) let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types) let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types, fragmentIdFilter: fragmentIdFilter)
return req.encode() return req.encode()
} }
@@ -360,49 +565,113 @@ final class GossipSyncManager {
cap = max(1, config.fragmentCapacity) cap = max(1, config.fragmentCapacity)
} else if types == .fileTransfer { } else if types == .fileTransfer {
cap = max(1, config.fileTransferCapacity) cap = max(1, config.fileTransferCapacity)
} else if types == .prekeyBundle {
cap = max(1, config.prekeyBundleCapacity)
} else if types == .board {
cap = max(1, config.boardCapacity)
} else { } else {
cap = max(1, config.seenCapacity) cap = max(1, config.seenCapacity)
} }
let takeN = min(candidates.count, min(nMax, cap)) let takeN = min(candidates.count, min(nMax, cap))
if takeN <= 0 { if takeN <= 0 {
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types) let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types, fragmentIdFilter: fragmentIdFilter)
return req.encode() return req.encode()
} }
let ids: [Data] = candidates.prefix(takeN).map { PacketIdUtil.computeId($0) } let included = Array(candidates.prefix(takeN))
let ids: [Data] = included.map { PacketIdUtil.computeId($0) }
let params = GCSFilter.buildFilter(ids: ids, maxBytes: config.gcsMaxBytes, targetFpr: config.gcsTargetFpr) let params = GCSFilter.buildFilter(ids: ids, maxBytes: config.gcsMaxBytes, targetFpr: config.gcsTargetFpr)
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types) // When the filter can't cover every candidate either the store
// exceeds `takeN` or the encoder trimmed the tail to fit the byte
// budget tell the responder how far back the filter actually
// reaches. `includedCount` counts inputs in newest-first order, so the
// covered set is a contiguous newest-prefix and the oldest included
// timestamp is an exact cursor. Packets older than it are outside the
// filter but not missing; without the cursor the responder would
// re-send that entire tail every round.
let covered = params.includedCount
let sinceTimestamp: UInt64? = (covered < candidates.count && covered > 0)
? included[covered - 1].timestamp
: nil
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types, sinceTimestamp: sinceTimestamp, fragmentIdFilter: fragmentIdFilter)
return req.encode() return req.encode()
} }
// Periodic cleanup of expired messages and announcements // Periodic cleanup of expired messages and announcements
private func cleanupExpiredMessages() { private func cleanupExpiredMessages() {
// Remove expired announcements // Remove expired announcements
latestAnnouncementByPeer = latestAnnouncementByPeer.filter { _, pair in latestAnnouncementByPeer = latestAnnouncementByPeer.filter { _, pkt in
isPacketFresh(pair.packet) isPacketFresh(pkt)
} }
let messageCountBefore = messages.packets.count
messages.removeExpired(isFresh: isPacketFresh) messages.removeExpired(isFresh: isPacketFresh)
if messages.packets.count != messageCountBefore {
archiveDirty = true
}
fragments.removeExpired(isFresh: isPacketFresh) fragments.removeExpired(isFresh: isPacketFresh)
fileTransfers.removeExpired(isFresh: isPacketFresh) fileTransfers.removeExpired(isFresh: isPacketFresh)
groupMessages.removeExpired(isFresh: isPacketFresh)
latestPrekeyBundleByPeer = latestPrekeyBundleByPeer.filter { _, pair in
isPacketFresh(pair.packet)
}
}
// MARK: - Archive (public message persistence)
/// Rebuild the public message store from disk on launch, dropping
/// anything that aged out while the app was dead.
private func restoreArchivedMessages() {
guard let archive else { return }
var restored = 0
for data in archive.load() {
guard let packet = BitchatPacket.from(data),
packet.type == MessageType.message.rawValue,
isPacketFresh(packet) else { continue }
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
messages.insert(idHex: idHex, packet: packet, capacity: max(1, config.seenCapacity))
restored += 1
}
if restored > 0 {
SecureLogger.debug("Restored \(restored) archived public message(s) for gossip sync", category: .sync)
archiveDirty = true
}
}
private func persistArchiveIfDirty() {
guard archiveDirty, let archive else { return }
archiveDirty = false
let packets = messages.allPackets(isFresh: isPacketFresh)
.compactMap { $0.toBinaryData(padding: false) }
archive.save(packets)
}
/// Flush the archive outside the maintenance cadence (app backgrounding).
func persistNow() {
queue.async { [weak self] in
self?.persistArchiveIfDirty()
}
} }
private func performPeriodicMaintenance(now: Date = Date()) { private func performPeriodicMaintenance(now: Date = Date()) {
cleanupExpiredMessages() cleanupExpiredMessages()
cleanupStaleAnnouncementsIfNeeded(now: now) cleanupStaleAnnouncementsIfNeeded(now: now)
persistArchiveIfDirty()
requestSyncManager.cleanup() // Cleanup expired sync requests requestSyncManager.cleanup() // Cleanup expired sync requests
responseRateLimiter.prune(now: now)
var dueTypes: SyncTypeFlags = [] // One request per due schedule rather than a union filter: each type
// group gets the full GCS capacity and its own since-cursor, so heavy
// fragment traffic can't crowd messages out of the filter.
for index in syncSchedules.indices { for index in syncSchedules.indices {
guard syncSchedules[index].interval > 0 else { continue } guard syncSchedules[index].interval > 0 else { continue }
// No board source wired up means nothing to offer or store;
// skip the round entirely.
if syncSchedules[index].types == .board && boardPacketsProvider == nil { continue }
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval { if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
syncSchedules[index].lastSent = now syncSchedules[index].lastSent = now
dueTypes.formUnion(syncSchedules[index].types) sendPeriodicSync(for: syncSchedules[index].types)
} }
} }
if !dueTypes.isEmpty {
sendPeriodicSync(for: dueTypes)
}
} }
private func cleanupStaleAnnouncementsIfNeeded(now: Date) { private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
@@ -418,8 +687,8 @@ final class GossipSyncManager {
let nowMs = UInt64(now.timeIntervalSince1970 * 1000) let nowMs = UInt64(now.timeIntervalSince1970 * 1000)
guard nowMs >= timeoutMs else { return } guard nowMs >= timeoutMs else { return }
let cutoff = nowMs - timeoutMs let cutoff = nowMs - timeoutMs
let stalePeerIDs = latestAnnouncementByPeer.compactMap { peerID, pair in let stalePeerIDs = latestAnnouncementByPeer.compactMap { peerID, pkt in
pair.packet.timestamp < cutoff ? peerID : nil pkt.timestamp < cutoff ? peerID : nil
} }
guard !stalePeerIDs.isEmpty else { return } guard !stalePeerIDs.isEmpty else { return }
for peerKey in stalePeerIDs { for peerKey in stalePeerIDs {
@@ -435,10 +704,17 @@ final class GossipSyncManager {
} }
private func removeState(for peerID: PeerID) { private func removeState(for peerID: PeerID) {
// Deliberately keeps the peer's prekey bundle: bundles exist to reach
// owners who left the mesh, and they age out on their own schedule.
_ = latestAnnouncementByPeer.removeValue(forKey: peerID) _ = latestAnnouncementByPeer.removeValue(forKey: peerID)
let messageCountBefore = messages.packets.count
messages.remove { PeerID(hexData: $0.senderID) == peerID } messages.remove { PeerID(hexData: $0.senderID) == peerID }
if messages.packets.count != messageCountBefore {
archiveDirty = true
}
fragments.remove { PeerID(hexData: $0.senderID) == peerID } fragments.remove { PeerID(hexData: $0.senderID) == peerID }
fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID } fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID }
groupMessages.remove { PeerID(hexData: $0.senderID) == peerID }
} }
} }
@@ -456,6 +732,12 @@ extension GossipSyncManager {
} }
} }
func _hasPrekeyBundle(for peerID: PeerID) -> Bool {
queue.sync {
latestPrekeyBundleByPeer[peerID] != nil
}
}
func _messageCount(for peerID: PeerID) -> Int { func _messageCount(for peerID: PeerID) -> Int {
queue.sync { queue.sync {
messages.allPackets { _ in true }.filter { PeerID(hexData: $0.senderID) == peerID }.count messages.allPackets { _ in true }.filter { PeerID(hexData: $0.senderID) == peerID }.count
@@ -0,0 +1,42 @@
import BitFoundation
import Foundation
/// Sliding-window limiter for REQUEST_SYNC responses.
///
/// A single sync response can replay the entire gossip store, so a peer that
/// requests in a tight loop must not be able to drain the airtime and battery
/// of everyone in radio range. Legitimate peers send at most a few requests
/// per maintenance tick (one per type schedule, plus the initial sync).
struct SyncResponseRateLimiter {
private let maxResponses: Int
private let window: TimeInterval
private var history: [PeerID: [Date]] = [:]
init(maxResponses: Int, window: TimeInterval) {
self.maxResponses = max(1, maxResponses)
self.window = max(0, window)
}
/// Returns true (and records the response) if the peer is under its
/// response budget for the current window.
mutating func shouldRespond(to peerID: PeerID, now: Date) -> Bool {
let cutoff = now.addingTimeInterval(-window)
var recent = (history[peerID] ?? []).filter { $0 >= cutoff }
guard recent.count < maxResponses else {
history[peerID] = recent
return false
}
recent.append(now)
history[peerID] = recent
return true
}
/// Drops history outside the window so departed peers don't accumulate.
mutating func prune(now: Date) {
let cutoff = now.addingTimeInterval(-window)
history = history.compactMapValues { dates in
let recent = dates.filter { $0 >= cutoff }
return recent.isEmpty ? nil : recent
}
}
}
+58 -1
View File
@@ -7,9 +7,25 @@ struct SyncTypeFlags: OptionSet {
let rawValue: UInt64 let rawValue: UInt64
init(rawValue: UInt64) { init(rawValue: UInt64) {
self.rawValue = rawValue & 0x00FF_FFFF_FFFF_FFFF // Trim to max 8 bytes // Drop any bit that doesn't map to a known message type. Wire data can
// carry up to 8 bytes of flags; without this mask, bits with no type
// (a truncated/garbled field, or a type a newer peer added) would live
// in the set as phantom membership that no `contains` check matches and
// `toData` re-serializes a meaningless "accepted but does nothing"
// state. Masking here keeps every instance normalized at the source.
self.rawValue = rawValue & SyncTypeFlags.knownTypeMask
} }
/// Union of every bit that maps to a message type. Derived from the
/// bittype table so it tracks automatically when a type is added.
private static let knownTypeMask: UInt64 = {
var mask: UInt64 = 0
for bit in 0..<64 where SyncTypeFlags.type(forBit: bit) != nil {
mask |= (1 << UInt64(bit))
}
return mask
}()
private static func bitIndex(for type: MessageType) -> Int? { private static func bitIndex(for type: MessageType) -> Int? {
switch type { switch type {
case .announce: return 0 case .announce: return 0
@@ -20,6 +36,29 @@ struct SyncTypeFlags: OptionSet {
case .fragment: return 5 case .fragment: return 5
case .requestSync: return 6 case .requestSync: return 6
case .fileTransfer: return 7 case .fileTransfer: return 7
case .boardPost: return 8
// Extended bits are compat-safe by construction: `toData()` encodes
// the bitfield little-endian with trailing zero bytes trimmed (bit 10
// widens the wire form from 1 to 2 bytes inside the length-prefixed
// REQUEST_SYNC TLV 0x04), and `decode(_:)` accepts 1...8 bytes while
// `type(forBit:)` maps unknown bits to nil so old clients simply
// ignore the group bit and answer with the types they know.
case .groupMessage: return 10
// Courier envelopes are directed deposits between trusted peers and
// must never spread via gossip sync.
case .courierEnvelope: return nil
// Ping/pong are ephemeral directed probes; replaying them via gossip
// sync would only produce stale, unanswerable echoes.
case .ping, .pong: return nil
// Gateway carriers are ephemeral live traffic (uplinks are directed,
// downlinks are rate-budgeted rebroadcasts); replaying them via sync
// would waste airtime and extend their lifetime.
case .nostrCarrier: return nil
// Prekey bundles gossip like board posts. The bitfield is a
// wire-tolerant little-endian UInt64 (1-8 bytes, unknown high bits
// ignored by `type(forBit:)`), so bits 8+ need no format change: old
// clients decode the wider flags and simply never match the new bits.
case .prekeyBundle: return 9
} }
} }
@@ -33,6 +72,12 @@ struct SyncTypeFlags: OptionSet {
case 5: return .fragment case 5: return .fragment
case 6: return .requestSync case 6: return .requestSync
case 7: return .fileTransfer case 7: return .fileTransfer
// Bit 8 spills the encoded bitfield into a second byte. Decoders since
// type-aware sync (#853) accept 1-8 bytes and map unknown bits to no
// known type, so old clients ignore board rounds instead of choking.
case 8: return .boardPost
case 9: return .prekeyBundle
case 10: return .groupMessage
default: default:
return nil return nil
} }
@@ -42,6 +87,9 @@ struct SyncTypeFlags: OptionSet {
static let message = SyncTypeFlags(messageTypes: [.message]) static let message = SyncTypeFlags(messageTypes: [.message])
static let fragment = SyncTypeFlags(messageTypes: [.fragment]) static let fragment = SyncTypeFlags(messageTypes: [.fragment])
static let fileTransfer = SyncTypeFlags(messageTypes: [.fileTransfer]) static let fileTransfer = SyncTypeFlags(messageTypes: [.fileTransfer])
static let board = SyncTypeFlags(messageTypes: [.boardPost])
static let prekeyBundle = SyncTypeFlags(messageTypes: [.prekeyBundle])
static let groupMessage = SyncTypeFlags(messageTypes: [.groupMessage])
static let publicMessages = SyncTypeFlags(messageTypes: [.announce, .message]) static let publicMessages = SyncTypeFlags(messageTypes: [.announce, .message])
@@ -67,6 +115,15 @@ struct SyncTypeFlags: OptionSet {
SyncTypeFlags(rawValue: rawValue & other.rawValue) SyncTypeFlags(rawValue: rawValue & other.rawValue)
} }
/// Compact form for logs, e.g. "message+fragment". Without this, the
/// per-schedule periodic sync rounds log identical lines and read as
/// duplicated sends (misdiagnosed twice during July 2026 device testing).
var logDescription: String {
let types = toMessageTypes()
guard !types.isEmpty else { return "none" }
return types.map { String(describing: $0) }.joined(separator: "+")
}
func toMessageTypes() -> [MessageType] { func toMessageTypes() -> [MessageType] {
guard rawValue != 0 else { return [] } guard rawValue != 0 else { return [] }
var types: [MessageType] = [] var types: [MessageType] = []
@@ -27,4 +27,3 @@ struct PeerDisplayNameResolver {
return result return result
} }
} }
@@ -0,0 +1,602 @@
import BitFoundation
import BitLogger
import Foundation
/// The narrow surface `ChatGroupCoordinator` needs from its owner.
///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
/// minimal context it actually uses instead of holding an `unowned` back-ref
/// to the whole `ChatViewModel`. Group chats are keyed like direct chats
/// (virtual "group_" peer IDs), so the conversation intents below reuse the
/// private-chat store operations.
@MainActor
protocol ChatGroupContext: AnyObject {
// MARK: Identity & state
var nickname: String { get }
var myPeerID: PeerID { get }
var selectedPrivateChatPeer: PeerID? { get }
var groupStore: GroupStore { get }
/// Fingerprint of our own Noise static identity key.
func myNoiseFingerprint() -> String
/// Our Ed25519 signing public key.
func mySigningPublicKey() -> Data
/// Signs `data` with our Noise signing key.
func signWithNoiseKey(_ data: Data) -> Data?
// MARK: Peers
func getPeerIDForNickname(_ nickname: String) -> PeerID?
func isPeerConnected(_ peerID: PeerID) -> Bool
func peerNickname(for peerID: PeerID) -> String?
/// The peer's Noise fingerprint from the live session/registry.
func meshFingerprint(for peerID: PeerID) -> String?
/// The peer's persisted crypto identity (fingerprint + signing key), if
/// the identity store has a signature-verified announce for them.
func cryptoIdentity(for peerID: PeerID) -> (fingerprint: String, signingKey: Data)?
/// The connected short peer ID whose fingerprint matches, if any.
func connectedPeerID(forFingerprint fingerprint: String) -> PeerID?
/// Whether the user has blocked the identity with this Noise fingerprint.
func isFingerprintBlocked(_ fingerprint: String) -> Bool
// MARK: Transport
func sendGroupInvitePayload(_ payload: Data, to peerID: PeerID)
func sendGroupKeyUpdatePayload(_ payload: Data, to peerID: PeerID)
func broadcastGroupMessagePayload(_ payload: Data)
// MARK: Conversation intents (group chats are direct-keyed)
@discardableResult
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool
func markPrivateChatUnread(_ peerID: PeerID)
func removePrivateChat(_ peerID: PeerID)
func startPrivateChat(with peerID: PeerID)
func endPrivateChat()
func addSystemMessage(_ content: String)
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID)
func notifyUIChanged()
func notifyPrivateMessage(from senderName: String, message: String, peerID: PeerID)
}
extension ChatViewModel: ChatGroupContext {
// `nickname`, `myPeerID`, `selectedPrivateChatPeer`, `groupStore`,
// `getPeerIDForNickname(_:)`, `isPeerConnected(_:)`, `peerNickname(for:)`,
// `appendPrivateMessage(_:to:)`, `markPrivateChatUnread(_:)`,
// `removePrivateChat(_:)`, `startPrivateChat(with:)`,
// `addSystemMessage(_:)`, `addLocalPrivateSystemMessage(_:to:)`,
// `notifyUIChanged()`, and `notifyPrivateMessage(from:message:peerID:)`
// are shared requirements with the other contexts or satisfied by
// existing `ChatViewModel` members. The members below flatten nested
// service accesses into intent-named calls.
func myNoiseFingerprint() -> String {
meshService.noiseIdentityFingerprint()
}
func mySigningPublicKey() -> Data {
meshService.noiseSigningPublicKeyData()
}
func signWithNoiseKey(_ data: Data) -> Data? {
meshService.noiseSignData(data)
}
func meshFingerprint(for peerID: PeerID) -> String? {
meshService.getFingerprint(for: peerID)
}
/// The persisted, signature-verified identity behind a short mesh peer
/// ID. Cross-checked against the live session fingerprint so a roster
/// entry can never be pinned to a signing key from a different identity.
func cryptoIdentity(for peerID: PeerID) -> (fingerprint: String, signingKey: Data)? {
guard let fingerprint = meshService.getFingerprint(for: peerID) else { return nil }
let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID)
guard let identity = candidates.first(where: { $0.fingerprint == fingerprint }),
let signingKey = identity.signingPublicKey else { return nil }
return (fingerprint, signingKey)
}
/// Short mesh peer IDs are the fingerprint's first 16 hex chars, so the
/// connected peer for a roster fingerprint is a direct derivation.
func connectedPeerID(forFingerprint fingerprint: String) -> PeerID? {
let shortID = PeerID(str: String(fingerprint.prefix(16)))
return meshService.isPeerConnected(shortID) ? shortID : nil
}
func isFingerprintBlocked(_ fingerprint: String) -> Bool {
identityManager.isBlocked(fingerprint: fingerprint)
}
func sendGroupInvitePayload(_ payload: Data, to peerID: PeerID) {
meshService.sendGroupInvite(payload, to: peerID)
}
func sendGroupKeyUpdatePayload(_ payload: Data, to peerID: PeerID) {
meshService.sendGroupKeyUpdate(payload, to: peerID)
}
func broadcastGroupMessagePayload(_ payload: Data) {
meshService.broadcastGroupMessage(payload)
}
// MARK: CommandContextProvider group commands (parsed by CommandProcessor)
func groupCreate(named name: String) -> CommandResult {
groupCoordinator.createGroup(named: name)
}
func groupInvite(nickname: String) -> CommandResult {
groupCoordinator.inviteMember(nickname: nickname)
}
func groupRemove(nickname: String) -> CommandResult {
groupCoordinator.removeMember(nickname: nickname)
}
func groupLeave() -> CommandResult {
groupCoordinator.leaveGroup()
}
func groupList() -> CommandResult {
groupCoordinator.listGroups()
}
}
/// Owns the private-groups feature: creating groups, creator-managed invites
/// and key rotation over Noise, and sealing/opening group message broadcasts.
/// Delivery is fire-and-flood like public chat no per-member acks in v1
/// with gossip-sync backfill as the only offline catch-up.
@MainActor
final class ChatGroupCoordinator {
private unowned let context: any ChatGroupContext
private static let maxGroupNameLength = 40
init(context: any ChatGroupContext) {
self.context = context
}
// MARK: - Commands
func createGroup(named rawName: String) -> CommandResult {
let name = rawName.trimmed
guard !name.isEmpty else {
return .error(message: String(localized: "system.group.usage_create", comment: "Usage hint for /group create"))
}
guard name.count <= Self.maxGroupNameLength else {
return .error(message: String(localized: "system.group.name_too_long", comment: "Error when a group name exceeds the length cap"))
}
let myFingerprint = context.myNoiseFingerprint()
let mySigningKey = context.mySigningPublicKey()
guard !myFingerprint.isEmpty, mySigningKey.count == 32 else {
return .error(message: String(localized: "system.group.identity_unavailable", comment: "Error when the local identity is not ready for group operations"))
}
let creator = GroupMember(fingerprint: myFingerprint, signingKey: mySigningKey, nickname: context.nickname)
guard let group = context.groupStore.createGroup(named: name, creator: creator) else {
return .error(message: String(localized: "system.group.create_failed", comment: "Error when group creation fails"))
}
context.startPrivateChat(with: group.peerID)
return .success(message: String(
format: String(localized: "system.group.created", comment: "System message after creating a group; placeholder is the group name"),
locale: .current,
name
))
}
func inviteMember(nickname rawNickname: String) -> CommandResult {
let nickname = normalizedNickname(rawNickname)
guard !nickname.isEmpty else {
return .error(message: String(localized: "system.group.usage_invite", comment: "Usage hint for /group invite"))
}
guard let group = selectedGroup() else {
return .error(message: String(localized: "system.group.not_in_group", comment: "Error when a group command requires an open group chat"))
}
guard group.creatorFingerprint == context.myNoiseFingerprint() else {
return .error(message: String(localized: "system.group.creator_only", comment: "Error when a non-creator attempts a creator-only group action"))
}
guard let peerID = context.getPeerIDForNickname(nickname) else {
return .error(message: String(
format: String(localized: "system.group.peer_not_found", comment: "Error when the invitee nickname is unknown; placeholder is the nickname"),
locale: .current,
nickname
))
}
guard context.isPeerConnected(peerID) else {
return .error(message: String(
format: String(localized: "system.group.peer_not_connected", comment: "Error when the invitee is not connected over mesh; placeholder is the nickname"),
locale: .current,
nickname
))
}
guard let identity = context.cryptoIdentity(for: peerID) else {
return .error(message: String(
format: String(localized: "system.group.peer_identity_unknown", comment: "Error when the invitee's verified identity is unavailable; placeholder is the nickname"),
locale: .current,
nickname
))
}
guard !group.isMember(fingerprint: identity.fingerprint) else {
return .error(message: String(
format: String(localized: "system.group.already_member", comment: "Error when the invitee is already a member; placeholder is the nickname"),
locale: .current,
nickname
))
}
guard group.members.count < BitchatGroup.maxMembers else {
return .error(message: String(
format: String(localized: "system.group.full", comment: "Error when the group is at the member cap; placeholder is the cap"),
locale: .current,
"\(BitchatGroup.maxMembers)"
))
}
let newMember = GroupMember(
fingerprint: identity.fingerprint,
signingKey: identity.signingKey,
nickname: context.peerNickname(for: peerID) ?? nickname
)
// Rotate the key (epoch + 1) on every roster change, not just removals.
// A monotonically increasing epoch per roster gives the receiver a
// strict ordering: two out-of-order invite states can no longer share
// an epoch and last-writer-wins a just-added member back out.
let members = group.members + [newMember]
guard let (updated, key) = context.groupStore.rotateKey(groupID: group.groupID, members: members),
let payload = signedStatePayload(for: updated, key: key) else {
return .error(message: String(localized: "system.group.invite_failed", comment: "Error when building or signing a group invite fails"))
}
context.sendGroupInvitePayload(payload, to: peerID)
distributeState(payload, group: updated, excluding: [identity.fingerprint], type: .keyUpdate)
return .success(message: String(
format: String(localized: "system.group.invited", comment: "System message after inviting someone; placeholders are the nickname and the group name"),
locale: .current,
nickname,
updated.name
))
}
/// Creator-side removal: rotates the group key (epoch + 1) and sends the
/// new state to every remaining member so the removed member's key stops
/// decrypting future traffic.
func removeMember(nickname rawNickname: String) -> CommandResult {
let nickname = normalizedNickname(rawNickname)
guard !nickname.isEmpty else {
return .error(message: String(localized: "system.group.usage_remove", comment: "Usage hint for /group remove"))
}
guard let group = selectedGroup() else {
return .error(message: String(localized: "system.group.not_in_group", comment: "Error when a group command requires an open group chat"))
}
guard group.creatorFingerprint == context.myNoiseFingerprint() else {
return .error(message: String(localized: "system.group.creator_only", comment: "Error when a non-creator attempts a creator-only group action"))
}
guard let member = group.members.first(where: { $0.nickname.caseInsensitiveCompare(nickname) == .orderedSame }) else {
return .error(message: String(
format: String(localized: "system.group.member_not_found", comment: "Error when the member to remove is not in the roster; placeholder is the nickname"),
locale: .current,
nickname
))
}
guard member.fingerprint != group.creatorFingerprint else {
return .error(message: String(localized: "system.group.cannot_remove_creator", comment: "Error when the creator tries to remove themselves"))
}
let remaining = group.members.filter { $0.fingerprint != member.fingerprint }
guard let (rotated, newKey) = context.groupStore.rotateKey(groupID: group.groupID, members: remaining),
let payload = signedStatePayload(for: rotated, key: newKey) else {
return .error(message: String(localized: "system.group.rotate_failed", comment: "Error when rotating the group key fails"))
}
distributeState(payload, group: rotated, excluding: [], type: .keyUpdate)
notifyRemovedMember(member, rotated: rotated)
return .success(message: String(
format: String(localized: "system.group.removed_member", comment: "System message after removing a member and rotating the key; placeholder is the nickname"),
locale: .current,
member.nickname
))
}
func leaveGroup() -> CommandResult {
guard let group = selectedGroup() else {
return .error(message: String(localized: "system.group.not_in_group", comment: "Error when a group command requires an open group chat"))
}
// Close the chat window first so the confirmation message doesn't
// resurrect the conversation we're about to remove.
context.endPrivateChat()
context.removePrivateChat(group.peerID)
context.groupStore.removeGroup(withID: group.groupID)
context.notifyUIChanged()
return .success(message: String(
format: String(localized: "system.group.left", comment: "System message after leaving a group; placeholder is the group name"),
locale: .current,
group.name
))
}
func listGroups() -> CommandResult {
let groups = context.groupStore.groups
guard !groups.isEmpty else {
return .success(message: String(localized: "system.group.none", comment: "System message when the user is in no groups"))
}
let myFingerprint = context.myNoiseFingerprint()
let lines = groups.map { group -> String in
let role = group.creatorFingerprint == myFingerprint ? " (creator)" : ""
return "#\(group.name)\(role)\(group.members.count)/\(BitchatGroup.maxMembers)"
}
return .success(message: String(localized: "system.group.list_header", comment: "Header line for the /group list output") + "\n" + lines.joined(separator: "\n"))
}
// MARK: - Sending
/// Fire-and-flood send: local echo goes straight to `.sent` because group
/// messages have no per-member acknowledgments in v1.
func sendGroupMessage(_ content: String, to groupPeerID: PeerID) {
guard !content.isEmpty, content.count <= InputValidator.Limits.maxMessageLength else { return }
guard let group = context.groupStore.group(for: groupPeerID),
let key = context.groupStore.key(forGroupID: group.groupID) else {
context.addSystemMessage(String(localized: "system.group.unknown", comment: "System message when sending into an unknown group"))
return
}
let messageID = UUID().uuidString
let timestamp = Date()
let payload: Data
do {
payload = try GroupCrypto.sealMessage(
content: content,
messageID: messageID,
senderNickname: context.nickname,
senderSigningKey: context.mySigningPublicKey(),
timestampMs: UInt64(timestamp.timeIntervalSince1970 * 1000),
groupID: group.groupID,
epoch: group.epoch,
key: key,
sign: { [weak context] data in context?.signWithNoiseKey(data) }
)
} catch {
SecureLogger.error("Failed to seal group message: \(error)", category: .encryption)
context.addLocalPrivateSystemMessage(
String(localized: "system.group.send_failed", comment: "System message when sealing a group message fails"),
to: groupPeerID
)
return
}
let message = BitchatMessage(
id: messageID,
sender: context.nickname,
content: content,
timestamp: timestamp,
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: group.name,
senderPeerID: context.myPeerID,
mentions: nil,
deliveryStatus: .sent
)
context.appendPrivateMessage(message, to: groupPeerID)
context.broadcastGroupMessagePayload(payload)
context.notifyUIChanged()
}
// MARK: - Receiving
/// Decrypt-verify path for an incoming 0x25 broadcast. Drops silently for
/// unknown groups (non-members relay but never read), wrong epochs, bad
/// sender signatures, and senders missing from the pinned roster.
func handleGroupMessagePayload(_ payload: Data, timestamp: Date) {
guard let envelope = GroupMessageEnvelope.decode(payload) else { return }
guard let group = context.groupStore.group(withID: envelope.groupID) else { return }
guard envelope.epoch == group.epoch else {
SecureLogger.debug("Dropping group message with epoch \(envelope.epoch) (current \(group.epoch))", category: .encryption)
return
}
guard let key = context.groupStore.key(forGroupID: group.groupID) else { return }
let plaintext: GroupMessagePlaintext
do {
plaintext = try GroupCrypto.openMessage(envelope, key: key)
} catch {
SecureLogger.debug("Failed to open group message: \(error)", category: .encryption)
return
}
// Sender must be pinned in the creator-signed roster; key possession
// alone is not authorship.
guard let member = group.member(withSigningKey: plaintext.senderSigningKey) else {
SecureLogger.warning("Dropping group message from non-roster sender", category: .security)
return
}
// Our own broadcast echoed back via relay or sync replay.
guard plaintext.senderSigningKey != context.mySigningPublicKey() else { return }
// Honor /block inside groups too: drop display + notification for a
// blocked member, consistent with every other inbound path.
guard !context.isFingerprintBlocked(member.fingerprint) else {
SecureLogger.debug("Dropping group message from blocked member", category: .security)
return
}
let groupPeerID = group.peerID
// Trust the authenticated inner timestamp (clamped so a future-dated
// message cannot pin itself to the bottom of the timeline).
let messageDate = min(Date(timeIntervalSince1970: TimeInterval(plaintext.timestampMs) / 1000), Date())
let senderName = member.nickname.isEmpty ? plaintext.senderNickname : member.nickname
let senderPeerID = PeerID(str: String(member.fingerprint.prefix(16)))
let message = BitchatMessage(
id: plaintext.messageID,
sender: senderName,
content: plaintext.content,
timestamp: messageDate,
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: group.name,
senderPeerID: senderPeerID,
mentions: nil
)
guard context.appendPrivateMessage(message, to: groupPeerID) else { return }
let isViewing = context.selectedPrivateChatPeer == groupPeerID
if !isViewing {
context.markPrivateChatUnread(groupPeerID)
let isRecent = Date().timeIntervalSince(messageDate) < 30
if isRecent {
context.notifyPrivateMessage(
from: "\(senderName) @ \(group.name)",
message: plaintext.content,
peerID: groupPeerID
)
}
}
context.notifyUIChanged()
}
/// Accepts creator-signed group state arriving as an invite. The Noise
/// session peer must BE the creator, the signature must verify against
/// the creator key pinned in the roster, and we must be in the roster.
func handleGroupInvitePayload(from peerID: PeerID, payload: Data) {
applyGroupState(from: peerID, payload: payload, isInvite: true)
}
/// Accepts creator-signed state updates (rotation/roster). A state whose
/// roster no longer includes us means we were removed: drop the group.
func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) {
applyGroupState(from: peerID, payload: payload, isInvite: false)
}
}
private extension ChatGroupCoordinator {
enum StateSendType {
case invite
case keyUpdate
}
func normalizedNickname(_ raw: String) -> String {
let trimmed = raw.trimmed
return trimmed.hasPrefix("@") ? String(trimmed.dropFirst()) : trimmed
}
func selectedGroup() -> BitchatGroup? {
guard let selected = context.selectedPrivateChatPeer, selected.isGroup else { return nil }
return context.groupStore.group(for: selected)
}
func signedStatePayload(for group: BitchatGroup, key: Data) -> Data? {
GroupStatePayload.makeSigned(group: group, key: key) { [weak context] data in
context?.signWithNoiseKey(data)
}?.encode()
}
/// Sends the state payload to every connected roster member except us and
/// the excluded fingerprints. Offline members catch up the next time the
/// creator sends them state (v1 limitation, documented in the PR).
func distributeState(_ payload: Data, group: BitchatGroup, excluding excludedFingerprints: Set<String>, type: StateSendType) {
let myFingerprint = context.myNoiseFingerprint()
for member in group.members {
guard member.fingerprint != myFingerprint,
!excludedFingerprints.contains(member.fingerprint),
let peerID = context.connectedPeerID(forFingerprint: member.fingerprint) else { continue }
switch type {
case .invite:
context.sendGroupInvitePayload(payload, to: peerID)
case .keyUpdate:
context.sendGroupKeyUpdatePayload(payload, to: peerID)
}
}
}
/// Tells a just-removed member they're out so their client can deactivate
/// the group instead of silently going dark (dropping every message under
/// the epoch it no longer has the key for). The notice is a creator-signed
/// state whose roster excludes the removee their `applyGroupState`
/// removal branch fires on the missing-self roster and surfaces the
/// "removed from group" system message.
///
/// It carries a throwaway all-zero key, never the rotated key, so the
/// removee cannot decrypt post-removal traffic. State is sent 1:1 over
/// authenticated Noise, so no remaining member ever receives this blob
/// (and even if one did, its own missing-self check would not match).
/// If the removee is offline the notice can't be delivered same v1
/// limitation as any other missed key update, documented in the PR.
func notifyRemovedMember(_ removed: GroupMember, rotated: BitchatGroup) {
guard let peerID = context.connectedPeerID(forFingerprint: removed.fingerprint) else { return }
let throwawayKey = Data(count: BitchatGroup.keyLength)
guard let payload = signedStatePayload(for: rotated, key: throwawayKey) else { return }
context.sendGroupKeyUpdatePayload(payload, to: peerID)
}
func applyGroupState(from peerID: PeerID, payload: Data, isInvite: Bool) {
guard let state = GroupStatePayload.decode(payload) else {
SecureLogger.warning("Malformed group state payload from \(peerID.id.prefix(8))", category: .security)
return
}
// The Noise session already authenticated `peerID`; require that the
// authenticated peer IS the creator whose key signed the state, so a
// member can't re-invite or rotate on the creator's behalf.
guard let senderFingerprint = context.meshFingerprint(for: peerID),
senderFingerprint == state.creatorFingerprint else {
SecureLogger.warning("Dropping group state from non-creator \(peerID.id.prefix(8))", category: .security)
return
}
guard state.verifyCreatorSignature() else {
SecureLogger.warning("Dropping group state with invalid creator signature", category: .security)
return
}
let myFingerprint = context.myNoiseFingerprint()
let existing = context.groupStore.group(withID: state.groupID)
// A creator-signed roster that no longer includes us is a removal.
guard state.members.contains(where: { $0.fingerprint == myFingerprint }) else {
if let existing {
if context.selectedPrivateChatPeer == existing.peerID {
context.endPrivateChat()
}
context.removePrivateChat(existing.peerID)
context.groupStore.removeGroup(withID: existing.groupID)
context.addSystemMessage(String(
format: String(localized: "system.group.removed_from", comment: "System message when removed from a group; placeholder is the group name"),
locale: .current,
existing.name
))
context.notifyUIChanged()
}
return
}
// Never regress the epoch: state travels over live Noise sessions,
// so an older epoch here is a stale (or misbehaving) creator device.
if let existing, state.epoch < existing.epoch {
SecureLogger.warning("Dropping stale group state (epoch \(state.epoch) < \(existing.epoch))", category: .security)
return
}
let isNewMembership = existing == nil
guard context.groupStore.upsert(state.asGroup, key: state.key) else {
SecureLogger.error("Failed to store group state for \(state.name)", category: .session)
return
}
if isNewMembership {
let inviter = state.members.first { $0.fingerprint == state.creatorFingerprint }?.nickname
?? context.peerNickname(for: peerID)
?? "?"
let notice = String(
format: String(localized: "system.group.joined", comment: "System message when added to a group; placeholders are the group name and the inviter"),
locale: .current,
state.name,
inviter
)
context.addSystemMessage(notice)
context.markPrivateChatUnread(state.asGroup.peerID)
context.notifyPrivateMessage(from: inviter, message: notice, peerID: state.asGroup.peerID)
} else if isInvite == false, let existing, state.epoch > existing.epoch {
SecureLogger.info("Group '\(state.name)' rotated to epoch \(state.epoch)", category: .session)
}
context.notifyUIChanged()
}
}
@@ -185,6 +185,13 @@ final class ChatLifecycleCoordinator {
func markPrivateMessagesAsRead(from peerID: PeerID) { func markPrivateMessagesAsRead(from peerID: PeerID) {
context.markChatAsRead(from: peerID) context.markChatAsRead(from: peerID)
// Group chats are keyed under a virtual group_ peerID; no member IS the
// conversation peer, so the receipt loops below (which gate on
// senderPeerID == peerID) must never emit a read/delivered receipt for
// one. This guard makes that explicit so a future refactor of the
// receipt matching can't silently start leaking receipts into groups.
guard !peerID.isGroup else { return }
if peerID.isGeoDM, if peerID.isGeoDM,
let recipientHex = context.nostrKeyMapping[peerID], let recipientHex = context.nostrKeyMapping[peerID],
case .location(let channel) = context.activeChannel, case .location(let channel) = context.activeChannel,
@@ -324,7 +331,7 @@ private extension ChatLifecycleCoordinator {
do { do {
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash) let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
let event = try NostrProtocol.createEphemeralGeohashEvent( let event = try await NostrProtocol.createMinedEphemeralGeohashEvent(
content: message, content: message,
geohash: channel.geohash, geohash: channel.geohash,
senderIdentity: identity, senderIdentity: identity,
@@ -355,9 +362,10 @@ private extension ChatLifecycleCoordinator {
case .failed: return 1 case .failed: return 1
case .sending: return 2 case .sending: return 2
case .sent: return 3 case .sent: return 3
case .partiallyDelivered: return 4 case .carried: return 4
case .delivered: return 5 case .partiallyDelivered: return 5
case .read: return 6 case .delivered: return 6
case .read: return 7
} }
} }
} }
@@ -117,13 +117,13 @@ final class ChatMediaTransferCoordinator {
try? FileManager.default.removeItem(at: url) try? FileManager.default.removeItem(at: url)
await MainActor.run { [weak self] in await MainActor.run { [weak self] in
guard let self else { return } guard let self else { return }
self.handleMediaSendFailure(messageID: messageID, reason: "Voice note too large") self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", comment: "Failure reason shown when a voice note exceeds the size limit"))
} }
} catch { } catch {
SecureLogger.error("Voice note send failed: \(error)", category: .session) SecureLogger.error("Voice note send failed: \(error)", category: .session)
await MainActor.run { [weak self] in await MainActor.run { [weak self] in
guard let self else { return } guard let self else { return }
self.handleMediaSendFailure(messageID: messageID, reason: "Failed to send voice note") self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", comment: "Failure reason shown when a voice note could not be sent"))
} }
} }
} }
+3 -58
View File
@@ -21,13 +21,9 @@ protocol ChatNostrContext: GeohashSubscriptionContext, NostrInboundPipelineConte
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
// MARK: Favorites & notifications (shared with the other contexts) // MARK: Favorites (shared with the other contexts)
/// The persisted favorite relationship for the peer's Noise static key, if any. /// The persisted favorite relationship for the peer's Noise static key, if any.
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship?
/// Adds (or updates) a favorite in the favorites store.
func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String)
/// Posts a generic local user notification.
func postLocalNotification(title: String, body: String, identifier: String)
} }
extension ChatViewModel: ChatNostrContext { extension ChatViewModel: ChatNostrContext {
@@ -71,7 +67,7 @@ final class ChatNostrCoordinator {
key: Data? key: Data?
) { ) {
guard let context else { return } guard let context else { return }
if let _ = key { if key != nil {
if let identity = context.currentNostrIdentity() { if let identity = context.currentNostrIdentity() {
context.sendGeohashDeliveryAck(for: message.id, toRecipientHex: senderPubkey, from: identity) context.sendGeohashDeliveryAck(for: message.id, toRecipientHex: senderPubkey, from: identity)
} }
@@ -84,7 +80,7 @@ final class ChatNostrCoordinator {
} }
if !wasReadBefore && context.selectedPrivateChatPeer == message.senderPeerID { if !wasReadBefore && context.selectedPrivateChatPeer == message.senderPeerID {
if let _ = key { if key != nil {
if let identity = context.currentNostrIdentity() { if let identity = context.currentNostrIdentity() {
context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity) context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity)
} }
@@ -98,57 +94,6 @@ final class ChatNostrCoordinator {
} }
} }
@MainActor
func handleFavoriteNotification(content: String, from nostrPubkey: String) {
guard let context else { return }
guard let senderNoiseKey = inbound.findNoiseKey(for: nostrPubkey) else { return }
let isFavorite = content.contains("FAVORITE:TRUE")
let senderNickname = content.components(separatedBy: "|").last ?? "Unknown"
if isFavorite {
context.addFavorite(
noiseKey: senderNoiseKey,
nostrPublicKey: nostrPubkey,
nickname: senderNickname
)
}
var extractedNostrPubkey: String?
if let range = content.range(of: "NPUB:") {
let suffix = content[range.upperBound...]
let parts = suffix.components(separatedBy: "|")
if let key = parts.first {
extractedNostrPubkey = String(key)
}
} else if content.contains(":") {
let parts = content.components(separatedBy: ":")
if parts.count >= 3 {
extractedNostrPubkey = String(parts[2])
}
}
SecureLogger.info("📝 Received favorite notification from \(senderNickname): \(isFavorite)", category: .session)
if isFavorite && extractedNostrPubkey != nil {
SecureLogger.info(
"💾 Storing Nostr key association for \(senderNickname): \(extractedNostrPubkey!.prefix(16))...",
category: .session
)
context.addFavorite(
noiseKey: senderNoiseKey,
nostrPublicKey: extractedNostrPubkey,
nickname: senderNickname
)
}
context.postLocalNotification(
title: isFavorite ? "New Favorite" : "Favorite Removed",
body: "\(senderNickname) \(isFavorite ? "favorited" : "unfavorited") you",
identifier: "fav-\(UUID().uuidString)"
)
}
@MainActor @MainActor
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) { func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
guard let context else { return } guard let context else { return }
+103 -94
View File
@@ -68,10 +68,22 @@ extension ChatViewModel: ChatOutgoingContext {
final class ChatOutgoingCoordinator { final class ChatOutgoingCoordinator {
private unowned let context: any ChatOutgoingContext private unowned let context: any ChatOutgoingContext
/// In-flight NIP-13 mining for the most recent geohash send. A newer send
/// (or leaving the channel) cancels it, which only expedites the mining
/// the message still goes out at the difficulty already reached.
/// (Read access is internal so tests can await the send's completion.)
private(set) var geohashMiningTask: Task<Void, Never>?
init(context: any ChatOutgoingContext) { init(context: any ChatOutgoingContext) {
self.context = context self.context = context
} }
/// Finish any in-flight geohash PoW mining early (the pending message
/// still sends, at whatever committed difficulty it reached).
func expeditePendingGeohashMining() {
geohashMiningTask?.cancel()
}
func sendMessage(_ content: String) { func sendMessage(_ content: String) {
guard let trimmed = content.trimmedOrNilIfEmpty else { return } guard let trimmed = content.trimmedOrNilIfEmpty else { return }
@@ -92,120 +104,117 @@ final class ChatOutgoingCoordinator {
} }
let mentions = context.parseMentions(from: content) let mentions = context.parseMentions(from: content)
let preparedMessage = preparePublicMessage(content: content, trimmed: trimmed, mentions: mentions)
guard let preparedMessage else { return }
appendLocalEcho(preparedMessage.message) switch context.activeChannel {
routePublicMessage( case .mesh:
originalContent: content, sendMeshPublicMessage(originalContent: content, trimmed: trimmed, mentions: mentions)
mentions: mentions, case .location(let channel):
geoContext: preparedMessage.geoContext, sendGeohashPublicMessage(trimmed, mentions: mentions, channel: channel)
messageID: preparedMessage.message.id, }
timestamp: preparedMessage.message.timestamp
)
} }
} }
private extension ChatOutgoingCoordinator { private extension ChatOutgoingCoordinator {
func preparePublicMessage( func sendMeshPublicMessage(originalContent: String, trimmed: String, mentions: [String]) {
content: String, let message = BitchatMessage(
trimmed: String, sender: context.nickname,
mentions: [String] content: trimmed,
) -> (message: BitchatMessage, geoContext: ChatViewModel.GeoOutgoingContext?)? { timestamp: Date(),
var geoContext: ChatViewModel.GeoOutgoingContext? isRelay: false,
var displaySender = context.nickname senderPeerID: context.myPeerID,
var localSenderPeerID = context.myPeerID mentions: mentions.isEmpty ? nil : mentions
var messageID: String? )
var messageTimestamp = Date()
switch context.activeChannel { appendLocalEcho(message, to: .mesh)
case .mesh: context.recordPublicActivity(forChannelKey: "mesh")
break context.sendMeshMessage(
originalContent,
mentions: mentions,
messageID: message.id,
timestamp: message.timestamp
)
}
case .location(let channel): /// Geohash sends mine a NIP-13 nonce tag first (off the main actor, see
/// `NostrPoW`), so the whole echo-and-send runs in a task once the signed
/// event whose ID is also the local message ID exists. Typical mining
/// at the default target is well under 100 ms and hard-capped at
/// `NostrPoW.miningTimeCap`, so sending is never meaningfully delayed.
func sendGeohashPublicMessage(_ trimmed: String, mentions: [String], channel: GeohashChannel) {
let identity: NostrIdentity
do {
identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
} catch {
SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session)
context.addSystemMessage(
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
)
return
}
let displaySender = context.nickname + "#" + String(identity.publicKeyHex.suffix(4))
let senderPeerID = PeerID(nostr: identity.publicKeyHex)
let teleported = context.isTeleported
let nickname = context.nickname
// Serialize geohash sends: each send awaits the previous send's task
// before it appends + relays, so user-visible order always matches
// send order even when an earlier message mines longer than a later
// one. Cancelling the previous task only *expedites* its mining (the
// NIP-13 target is polled, not aborted), so it still finishes and
// sends and it finishes fast, so awaiting it never stacks mining
// delays or blocks a send beyond `NostrPoW.miningTimeCap`.
let previousSend = geohashMiningTask
previousSend?.cancel()
geohashMiningTask = Task { @MainActor [weak context = self.context] in
await previousSend?.value
let event: NostrEvent
do { do {
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash) event = try await NostrProtocol.createMinedEphemeralGeohashEvent(
let suffix = String(identity.publicKeyHex.suffix(4))
displaySender = context.nickname + "#" + suffix
localSenderPeerID = PeerID(nostr: identity.publicKeyHex)
let teleported = context.isTeleported
let event = try NostrProtocol.createEphemeralGeohashEvent(
content: trimmed, content: trimmed,
geohash: channel.geohash, geohash: channel.geohash,
senderIdentity: identity, senderIdentity: identity,
nickname: context.nickname, nickname: nickname,
teleported: teleported
)
messageID = event.id
messageTimestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at))
geoContext = (
channel: channel,
event: event,
identity: identity,
teleported: teleported teleported: teleported
) )
} catch { } catch {
SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session) SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session)
context.addSystemMessage( context?.addSystemMessage(
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
)
return nil
}
}
let message = BitchatMessage(
id: messageID,
sender: displaySender,
content: trimmed,
timestamp: messageTimestamp,
isRelay: false,
senderPeerID: localSenderPeerID,
mentions: mentions.isEmpty ? nil : mentions
)
return (message, geoContext)
}
func appendLocalEcho(_ message: BitchatMessage) {
context.appendPublicMessage(message, to: ConversationID(channelID: context.activeChannel))
let contentKey = context.normalizedContentKey(message.content)
context.recordContentKey(contentKey, timestamp: message.timestamp)
}
func routePublicMessage(
originalContent: String,
mentions: [String],
geoContext: ChatViewModel.GeoOutgoingContext?,
messageID: String,
timestamp: Date
) {
switch context.activeChannel {
case .mesh:
context.recordPublicActivity(forChannelKey: "mesh")
context.sendMeshMessage(
originalContent,
mentions: mentions,
messageID: messageID,
timestamp: timestamp
)
case .location(let channel):
context.recordPublicActivity(forChannelKey: "geo:\(channel.geohash)")
guard let geoContext, geoContext.channel.geohash == channel.geohash else {
SecureLogger.error("Geo: missing send context for \(channel.geohash)", category: .session)
context.addSystemMessage(
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails") String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
) )
return return
} }
guard let context else { return }
Task { @MainActor [weak context = self.context] in let message = BitchatMessage(
context?.sendGeohash(context: geoContext) id: event.id,
} sender: displaySender,
content: trimmed,
timestamp: Date(timeIntervalSince1970: TimeInterval(event.created_at)),
isRelay: false,
senderPeerID: senderPeerID,
mentions: mentions.isEmpty ? nil : mentions
)
context.appendPublicMessage(message, to: ConversationID(channelID: .location(channel)))
let contentKey = context.normalizedContentKey(message.content)
context.recordContentKey(contentKey, timestamp: message.timestamp)
context.recordPublicActivity(forChannelKey: "geo:\(channel.geohash)")
context.sendGeohash(context: (
channel: channel,
event: event,
identity: identity,
teleported: teleported
))
} }
} }
func appendLocalEcho(_ message: BitchatMessage, to conversationID: ConversationID) {
context.appendPublicMessage(message, to: conversationID)
let contentKey = context.normalizedContentKey(message.content)
context.recordContentKey(contentKey, timestamp: message.timestamp)
}
} }
@@ -323,6 +323,15 @@ final class ChatPeerIdentityCoordinator {
func startPrivateChat(with peerID: PeerID) { func startPrivateChat(with peerID: PeerID) {
guard peerID != context.myPeerID else { return } guard peerID != context.myPeerID else { return }
// Group chats are virtual conversations: no peer identity, favorites,
// handshake, or message consolidation applies just select the chat.
if peerID.isGroup {
context.selectedPrivateChatFingerprint = nil
context.beginPrivateChatSession(with: peerID)
context.markPrivateChatRead(peerID)
return
}
let peerNickname = context.peerNickname(for: peerID) ?? "unknown" let peerNickname = context.peerNickname(for: peerID) ?? "unknown"
if context.unifiedIsBlocked(peerID) { if context.unifiedIsBlocked(peerID) {
@@ -92,7 +92,6 @@ protocol ChatPrivateConversationContext: AnyObject {
func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String)
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
func sendDeliveryAckViaNostrEmbedded(_ message: BitchatMessage, wasReadBefore: Bool, senderPubkey: String, key: Data?)
// MARK: System messages // MARK: System messages
func addSystemMessage(_ content: String) func addSystemMessage(_ content: String)
@@ -101,6 +100,9 @@ protocol ChatPrivateConversationContext: AnyObject {
// MARK: Favorites & notifications // MARK: Favorites & notifications
/// The persisted favorite relationship for the peer's Noise static key, if any. /// The persisted favorite relationship for the peer's Noise static key, if any.
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship?
/// The persisted favorite relationship resolved from a short 16-hex mesh
/// peer ID (matched against the IDs derived from stored noise keys).
func favoriteRelationship(forPeerID peerID: PeerID) -> FavoritesPersistenceService.FavoriteRelationship?
/// Persists that the peer favorited/unfavorited us (favorites store write). /// Persists that the peer favorited/unfavorited us (favorites store write).
func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?)
/// Posts the incoming-private-message local notification. /// Posts the incoming-private-message local notification.
@@ -197,6 +199,10 @@ extension ChatViewModel: ChatPrivateConversationContext {
FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
} }
// `favoriteRelationship(forPeerID:)` is shared with
// `ChatPeerIdentityContext`; its witness lives in
// `ChatPeerIdentityCoordinator.swift`.
func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) { func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) {
FavoritesPersistenceService.shared.updatePeerFavoritedUs( FavoritesPersistenceService.shared.updatePeerFavoritedUs(
peerNoisePublicKey: noiseKey, peerNoisePublicKey: noiseKey,
@@ -221,10 +227,29 @@ extension ChatViewModel: ChatPrivateConversationContext {
final class ChatPrivateConversationCoordinator { final class ChatPrivateConversationCoordinator {
private unowned let context: any ChatPrivateConversationContext private unowned let context: any ChatPrivateConversationContext
// Outbox retries re-wrap the same message in fresh gift-wrap events, so
// relay-level event-ID dedup can't catch them; track inbound GeoDM
// message IDs so each copy past the first costs one (already-deduped)
// ack check and nothing else.
private var seenInboundGeoDMIDs: Set<String> = []
private var seenInboundGeoDMOrder: [String] = []
private static let seenInboundGeoDMCap = 512
init(context: any ChatPrivateConversationContext) { init(context: any ChatPrivateConversationContext) {
self.context = context self.context = context
} }
/// Returns `false` if this GeoDM message ID was already handled.
private func markInboundGeoDMSeen(_ messageId: String) -> Bool {
guard !seenInboundGeoDMIDs.contains(messageId) else { return false }
seenInboundGeoDMIDs.insert(messageId)
seenInboundGeoDMOrder.append(messageId)
if seenInboundGeoDMOrder.count > Self.seenInboundGeoDMCap {
seenInboundGeoDMIDs.remove(seenInboundGeoDMOrder.removeFirst())
}
return true
}
func sendPrivateMessage(_ content: String, to peerID: PeerID) { func sendPrivateMessage(_ content: String, to peerID: PeerID) {
guard !content.isEmpty else { return } guard !content.isEmpty else { return }
@@ -245,10 +270,15 @@ final class ChatPrivateConversationCoordinator {
return return
} }
guard let noiseKey = Data(hexString: peerID.id) else { return } // Resolve the favorite behind this conversation. It may be keyed by
// the full 64-hex noise-key ID (offline favorite row) or the short
// 16-hex mesh ID the raw hex bytes of a short ID are a routing ID,
// never a noise key, so they must not be used as a favorites key.
let noiseKey = peerID.noiseKey ?? context.noisePublicKey(for: peerID)
let isConnected = context.isPeerConnected(peerID) let isConnected = context.isPeerConnected(peerID)
let isReachable = context.isPeerReachable(peerID) let isReachable = context.isPeerReachable(peerID)
let favoriteStatus = context.favoriteRelationship(forNoiseKey: noiseKey) let favoriteStatus = noiseKey.flatMap { context.favoriteRelationship(forNoiseKey: $0) }
?? context.favoriteRelationship(forPeerID: peerID)
let isMutualFavorite = favoriteStatus?.isMutual ?? false let isMutualFavorite = favoriteStatus?.isMutual ?? false
let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil
@@ -397,17 +427,45 @@ final class ChatPrivateConversationCoordinator {
guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return } guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return }
let messageId = pm.messageID let messageId = pm.messageID
SecureLogger.info("GeoDM: recv PM <- sender=\(senderPubkey.prefix(8))… mid=\(messageId.prefix(8))", category: .session) // Ack before the dedup guard: a re-sent copy means the sender may not
// have our DELIVERED yet, and markGeoDeliveryAckSent dedups the
// actual sends.
sendDeliveryAckIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id) sendDeliveryAckIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id)
guard markInboundGeoDMSeen(messageId) else { return }
SecureLogger.info("GeoDM: recv PM <- sender=\(senderPubkey.prefix(8))… mid=\(messageId.prefix(8))", category: .session)
if context.isNostrBlocked(pubkeyHexLowercased: senderPubkey) { if context.isNostrBlocked(pubkeyHexLowercased: senderPubkey) {
return return
} }
// Prefer the favorite's stored nickname when the sender resolved to a
// known noise key; the Nostr display name is a geohash-scoped
// fallback (e.g. "anon#678e") that would mislabel favorite-transport
// DMs. Geohash conversations (nostr_ keys) keep the geo name.
let senderName: String = {
if let noiseKey = convKey.noiseKey,
let favoriteNickname = context.favoriteRelationship(forNoiseKey: noiseKey)?.peerNickname,
!favoriteNickname.isEmpty {
return favoriteNickname
}
return context.displayNameForNostrPubkey(senderPubkey)
}()
// Favorite notifications ride the PM channel over Nostr too; intercept
// them so they update the relationship instead of rendering as text.
if pm.content.hasPrefix("[FAVORITED]") || pm.content.hasPrefix("[UNFAVORITED]") {
handleFavoriteNotification(
pm.content,
from: convKey,
senderNickname: senderName
)
return
}
if context.privateChatsContainMessage(withID: messageId) { return } if context.privateChatsContainMessage(withID: messageId) { return }
let senderName = context.displayNameForNostrPubkey(senderPubkey)
let message = BitchatMessage( let message = BitchatMessage(
id: messageId, id: messageId,
sender: senderName, sender: senderName,
@@ -456,7 +514,10 @@ final class ChatPrivateConversationCoordinator {
category: .session category: .session
) )
} else { } else {
SecureLogger.warning("GeoDM: delivered ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session) // A stale ack for a message this device no longer tracks (dropped
// outbox entry, cleared chat, or a peer re-acking after losing our
// receipt) expected occasionally, not actionable.
SecureLogger.debug("GeoDM: delivered ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session)
} }
} }
@@ -486,93 +547,6 @@ final class ChatPrivateConversationCoordinator {
context.sendGeohashReadReceipt(messageId, toRecipientHex: senderPubKey, from: id) context.sendGeohashReadReceipt(messageId, toRecipientHex: senderPubKey, from: id)
} }
func handlePrivateMessage(
_ payload: NoisePayload,
actualSenderNoiseKey: Data?,
senderNickname: String,
targetPeerID: PeerID,
messageTimestamp: Date,
senderPubkey: String
) {
guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return }
let messageId = pm.messageID
let messageContent = pm.content
if messageContent.hasPrefix("[FAVORITED]") || messageContent.hasPrefix("[UNFAVORITED]") {
if let key = actualSenderNoiseKey {
handleFavoriteNotificationFromMesh(
messageContent,
from: PeerID(hexData: key),
senderNickname: senderNickname
)
}
return
}
if isDuplicateMessage(messageId, targetPeerID: targetPeerID) {
return
}
let wasReadBefore = context.sentReadReceipts.contains(messageId)
var isViewingThisChat = false
if context.selectedPrivateChatPeer == targetPeerID {
isViewingThisChat = true
} else if let selectedPeer = context.selectedPrivateChatPeer,
let selectedPeerNoiseKey = context.noisePublicKey(for: selectedPeer),
let key = actualSenderNoiseKey,
selectedPeerNoiseKey == key {
isViewingThisChat = true
}
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
let shouldMarkAsUnread = !wasReadBefore && !isViewingThisChat && isRecentMessage
let message = BitchatMessage(
id: messageId,
sender: senderNickname,
content: messageContent,
timestamp: messageTimestamp,
isRelay: false,
isPrivate: true,
recipientNickname: context.nickname,
senderPeerID: targetPeerID,
deliveryStatus: .delivered(to: context.nickname, at: Date())
)
addMessageToPrivateChatsIfNeeded(message, targetPeerID: targetPeerID)
mirrorToEphemeralIfNeeded(message, targetPeerID: targetPeerID, key: actualSenderNoiseKey)
context.sendDeliveryAckViaNostrEmbedded(
message,
wasReadBefore: wasReadBefore,
senderPubkey: senderPubkey,
key: actualSenderNoiseKey
)
if wasReadBefore {
// No-op.
} else if isViewingThisChat {
handleViewingThisChat(
message,
targetPeerID: targetPeerID,
key: actualSenderNoiseKey,
senderPubkey: senderPubkey
)
} else {
markAsUnreadIfNeeded(
shouldMarkAsUnread: shouldMarkAsUnread,
targetPeerID: targetPeerID,
key: actualSenderNoiseKey,
isRecentMessage: isRecentMessage,
senderNickname: senderNickname,
messageContent: messageContent
)
}
context.notifyUIChanged()
}
func handlePrivateMessage(_ message: BitchatMessage) { func handlePrivateMessage(_ message: BitchatMessage) {
SecureLogger.debug("📥 handlePrivateMessage called for message from \(message.sender)", category: .session) SecureLogger.debug("📥 handlePrivateMessage called for message from \(message.sender)", category: .session)
let senderPeerID = message.senderPeerID ?? context.getPeerIDForNickname(message.sender) let senderPeerID = message.senderPeerID ?? context.getPeerIDForNickname(message.sender)
@@ -583,7 +557,7 @@ final class ChatPrivateConversationCoordinator {
} }
if message.content.hasPrefix("[FAVORITED]") || message.content.hasPrefix("[UNFAVORITED]") { if message.content.hasPrefix("[FAVORITED]") || message.content.hasPrefix("[UNFAVORITED]") {
handleFavoriteNotificationFromMesh(message.content, from: peerID, senderNickname: message.sender) handleFavoriteNotification(message.content, from: peerID, senderNickname: message.sender)
return return
} }
@@ -706,7 +680,10 @@ final class ChatPrivateConversationCoordinator {
} }
} }
func handleFavoriteNotificationFromMesh(_ content: String, from peerID: PeerID, senderNickname: String) { /// Applies an inbound `[FAVORITED]`/`[UNFAVORITED]` marker from either
/// transport. `peerID` must resolve to a noise key a full 64-hex ID or
/// one the unified peer list knows; otherwise the notification is dropped.
func handleFavoriteNotification(_ content: String, from peerID: PeerID, senderNickname: String) {
let isFavorite = content.hasPrefix("[FAVORITED]") let isFavorite = content.hasPrefix("[FAVORITED]")
let parts = content.split(separator: ":") let parts = content.split(separator: ":")
@@ -82,7 +82,9 @@ protocol ChatPublicConversationContext: AnyObject {
// MARK: Inbound public message processing // MARK: Inbound public message processing
func processActionMessage(_ message: BitchatMessage) -> BitchatMessage func processActionMessage(_ message: BitchatMessage) -> BitchatMessage
func isMessageBlocked(_ message: BitchatMessage) -> Bool func isMessageBlocked(_ message: BitchatMessage) -> Bool
func allowPublicMessage(senderKey: String, contentKey: String) -> Bool /// `powBits` is the validated NIP-13 difficulty of the source Nostr event
/// (0 for mesh messages); sufficient PoW relaxes the per-sender bucket.
func allowPublicMessage(senderKey: String, contentKey: String, powBits: Int) -> Bool
/// Buffers a visible-channel message for the batched (~80 ms) pipeline /// Buffers a visible-channel message for the batched (~80 ms) pipeline
/// flush, which commits it to `conversationID` in the store. /// flush, which commits it to `conversationID` in the store.
func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID)
@@ -137,8 +139,8 @@ extension ChatViewModel: ChatPublicConversationContext {
meshService.sendMessage(content, mentions: mentions, messageID: messageID, timestamp: timestamp) meshService.sendMessage(content, mentions: mentions, messageID: messageID, timestamp: timestamp)
} }
func allowPublicMessage(senderKey: String, contentKey: String) -> Bool { func allowPublicMessage(senderKey: String, contentKey: String, powBits: Int) -> Bool {
publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey) publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey, powBits: powBits)
} }
func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) { func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) {
@@ -290,7 +292,17 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
func clearCurrentPublicTimeline() { func clearCurrentPublicTimeline() {
context.clearPublicConversation(ConversationID(channelID: context.activeChannel)) context.clearPublicConversation(ConversationID(channelID: context.activeChannel))
// The SPM test process shares the real Application Support tree, so this
// detached deletion can land mid-test under parallel scheduling and flake
// a file-dependent test. Tests never need the on-disk media cleared.
guard !TestEnvironment.isRunningTests else { return }
Task.detached(priority: .utility) { Task.detached(priority: .utility) {
// Skipped under tests: the test process shares the user's real
// ~/Library/Application Support/files tree, and this detached
// wipe fires at a nondeterministic time racing tests that
// write media there (see the same guard in panicClearAllData).
guard !TestEnvironment.isRunningTests else { return }
do { do {
let base = try FileManager.default.url( let base = try FileManager.default.url(
for: .applicationSupportDirectory, for: .applicationSupportDirectory,
@@ -367,7 +379,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
guard let context else { return } guard let context else { return }
do { do {
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash) let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
let event = try NostrProtocol.createEphemeralGeohashEvent( let event = try await NostrProtocol.createMinedEphemeralGeohashEvent(
content: content, content: content,
geohash: channel.geohash, geohash: channel.geohash,
senderIdentity: identity, senderIdentity: identity,
@@ -395,7 +407,11 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
) )
} }
func handlePublicMessage(_ message: BitchatMessage) { /// - Parameter powBits: validated NIP-13 difficulty of the source Nostr
/// event (0 for mesh messages). Sufficient PoW relaxes the per-sender
/// rate limit; low/no-PoW events keep the strict limits so old clients
/// still get through at normal rates.
func handlePublicMessage(_ message: BitchatMessage, powBits: Int = 0) {
let finalMessage = context.processActionMessage(message) let finalMessage = context.processActionMessage(message)
if context.isMessageBlocked(finalMessage) { return } if context.isMessageBlocked(finalMessage) { return }
@@ -405,7 +421,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
if shouldRateLimit { if shouldRateLimit {
let senderKey = normalizedSenderKey(for: finalMessage) let senderKey = normalizedSenderKey(for: finalMessage)
let contentKey = context.normalizedContentKey(finalMessage.content) let contentKey = context.normalizedContentKey(finalMessage.content)
if !context.allowPublicMessage(senderKey: senderKey, contentKey: contentKey) { if !context.allowPublicMessage(senderKey: senderKey, contentKey: contentKey, powBits: powBits) {
return return
} }
} }
@@ -57,6 +57,8 @@ protocol ChatTransportEventContext: AnyObject {
// MARK: Routing & acknowledgements // MARK: Routing & acknowledgements
func flushRouterOutbox(for peerID: PeerID) func flushRouterOutbox(for peerID: PeerID)
/// Offer queued mail for *other* peers to this newly connected courier.
func retryCourierDeposits(via peerID: PeerID)
func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID)
// MARK: Delivery status // MARK: Delivery status
@@ -69,6 +71,11 @@ protocol ChatTransportEventContext: AnyObject {
// MARK: Verification payloads // MARK: Verification payloads
func handleVerifyChallengePayload(from peerID: PeerID, payload: Data) func handleVerifyChallengePayload(from peerID: PeerID, payload: Data)
func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) func handleVerifyResponsePayload(from peerID: PeerID, payload: Data)
// MARK: Group payloads (creator-signed state over Noise)
func handleGroupInvitePayload(from peerID: PeerID, payload: Data)
func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data)
func handleVouchPayload(from peerID: PeerID, payload: Data)
} }
extension ChatViewModel: ChatTransportEventContext { extension ChatViewModel: ChatTransportEventContext {
@@ -103,6 +110,10 @@ extension ChatViewModel: ChatTransportEventContext {
messageRouter.flushOutbox(for: peerID) messageRouter.flushOutbox(for: peerID)
} }
func retryCourierDeposits(via peerID: PeerID) {
messageRouter.courierBecameAvailable(peerID)
}
func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) { func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) {
meshService.sendDeliveryAck(for: messageID, to: peerID) meshService.sendDeliveryAck(for: messageID, to: peerID)
} }
@@ -123,6 +134,18 @@ extension ChatViewModel: ChatTransportEventContext {
func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) { func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) {
verificationCoordinator.handleVerifyResponsePayload(from: peerID, payload: payload) verificationCoordinator.handleVerifyResponsePayload(from: peerID, payload: payload)
} }
func handleGroupInvitePayload(from peerID: PeerID, payload: Data) {
groupCoordinator.handleGroupInvitePayload(from: peerID, payload: payload)
}
func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) {
groupCoordinator.handleGroupKeyUpdatePayload(from: peerID, payload: payload)
}
func handleVouchPayload(from peerID: PeerID, payload: Data) {
vouchCoordinator.handleVouchPayload(from: peerID, payload: payload)
}
} }
final class ChatTransportEventCoordinator { final class ChatTransportEventCoordinator {
@@ -208,6 +231,7 @@ final class ChatTransportEventCoordinator {
} }
context.flushRouterOutbox(for: peerID) context.flushRouterOutbox(for: peerID)
context.retryCourierDeposits(via: peerID)
} }
} }
@@ -364,6 +388,15 @@ private extension ChatTransportEventCoordinator {
case .verifyResponse: case .verifyResponse:
context.handleVerifyResponsePayload(from: peerID, payload: payload) context.handleVerifyResponsePayload(from: peerID, payload: payload)
case .groupInvite:
context.handleGroupInvitePayload(from: peerID, payload: payload)
case .groupKeyUpdate:
context.handleGroupKeyUpdatePayload(from: peerID, payload: payload)
case .vouch:
context.handleVouchPayload(from: peerID, payload: payload)
} }
} }
@@ -24,6 +24,10 @@ protocol ChatVerificationContext: AnyObject {
func setStoredVerified(_ fingerprint: String, verified: Bool) func setStoredVerified(_ fingerprint: String, verified: Bool)
func isVerifiedFingerprint(_ fingerprint: String) -> Bool func isVerifiedFingerprint(_ fingerprint: String) -> Bool
func saveIdentityState() func saveIdentityState()
/// After a fingerprint becomes verified, run a transitive-vouch pass over
/// currently connected peers (so verifying a peer you're already connected
/// to sends vouches immediately, and the new identity propagates onward).
func vouchToConnectedVerifiedPeers()
// MARK: Encryption status // MARK: Encryption status
func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID)
@@ -86,6 +90,10 @@ extension ChatViewModel: ChatVerificationContext {
peerIdentityStore.setVerified(fingerprint, verified: verified) peerIdentityStore.setVerified(fingerprint, verified: verified)
} }
func vouchToConnectedVerifiedPeers() {
vouchCoordinator.vouchToConnectedVerifiedPeers()
}
var unifiedPeers: [BitchatPeer] { var unifiedPeers: [BitchatPeer] {
unifiedPeerService.peers unifiedPeerService.peers
} }
@@ -148,6 +156,9 @@ final class ChatVerificationCoordinator {
context.saveIdentityState() context.saveIdentityState()
context.setStoredVerified(fingerprint, verified: true) context.setStoredVerified(fingerprint, verified: true)
context.updateEncryptionStatus(for: peerID) context.updateEncryptionStatus(for: peerID)
// Verifying a peer is a vouch trigger: push attestations to my other
// connected verified peers (and to this one if already connected).
context.vouchToConnectedVerifiedPeers()
} }
func unverifyFingerprint(for peerID: PeerID) { func unverifyFingerprint(for peerID: PeerID) {
@@ -340,6 +351,8 @@ final class ChatVerificationCoordinator {
} }
context.updateEncryptionStatus(for: peerID) context.updateEncryptionStatus(for: peerID)
// QR verification just completed same vouch trigger as manual verify.
context.vouchToConnectedVerifiedPeers()
} }
} }
+181 -8
View File
@@ -102,7 +102,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
@MainActor @MainActor
var canSendMediaInCurrentContext: Bool { var canSendMediaInCurrentContext: Bool {
if let peer = selectedPrivateChatPeer { if let peer = selectedPrivateChatPeer {
return !(peer.isGeoDM || peer.isGeoChat) // Media transfer is not wired for groups in v1 (sendFilePrivate
// rejects the virtual group_ recipient), so keep the affordance off.
return !(peer.isGeoDM || peer.isGeoChat || peer.isGroup)
} }
switch activeChannel { switch activeChannel {
case .mesh: return true case .mesh: return true
@@ -177,6 +179,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
lazy var nostrCoordinator = ChatNostrCoordinator(context: self) lazy var nostrCoordinator = ChatNostrCoordinator(context: self)
lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self) lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self)
lazy var verificationCoordinator = ChatVerificationCoordinator(context: self) lazy var verificationCoordinator = ChatVerificationCoordinator(context: self)
lazy var groupCoordinator = ChatGroupCoordinator(context: self)
lazy var vouchCoordinator = ChatVouchCoordinator(context: self)
// Computed properties for compatibility // Computed properties for compatibility
@MainActor @MainActor
@@ -305,12 +309,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
var nostrRelayManager: NostrRelayManager? var nostrRelayManager: NostrRelayManager?
private let userDefaults = UserDefaults.standard private let userDefaults = UserDefaults.standard
let keychain: KeychainManagerProtocol let keychain: KeychainManagerProtocol
/// Private group membership: keys in the keychain, metadata on disk.
let groupStore: GroupStore
private let nicknameKey = "bitchat.nickname" private let nicknameKey = "bitchat.nickname"
// Location channel state (macOS supports manual geohash selection) // Location channel state (macOS supports manual geohash selection)
var activeChannel: ChannelID { var activeChannel: ChannelID {
get { conversations.activeChannel } get { conversations.activeChannel }
set { set {
guard conversations.activeChannel != newValue else { return } guard conversations.activeChannel != newValue else { return }
// Leaving a channel expedites any in-flight NIP-13 mining: the
// pending message still sends, at the difficulty already reached.
outgoingCoordinator.expeditePendingGeohashMining()
conversations.setActiveChannel(newValue) conversations.setActiveChannel(newValue)
visibleMessagesCache = nil visibleMessagesCache = nil
objectWillChange.send() objectWillChange.send()
@@ -764,15 +773,19 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
locationPresenceStore: LocationPresenceStore? = nil, locationPresenceStore: LocationPresenceStore? = nil,
locationManager: LocationChannelManager = .shared locationManager: LocationChannelManager = .shared
) { ) {
let meshService = BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager)
meshService.sfMetrics = .shared
self.init( self.init(
keychain: keychain, keychain: keychain,
idBridge: idBridge, idBridge: idBridge,
identityManager: identityManager, identityManager: identityManager,
transport: BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager), transport: meshService,
conversations: conversations, conversations: conversations,
peerIdentityStore: peerIdentityStore ?? PeerIdentityStore(), peerIdentityStore: peerIdentityStore ?? PeerIdentityStore(),
locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(), locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(),
locationManager: locationManager locationManager: locationManager,
outboxStore: MessageOutboxStore(keychain: keychain),
sfMetrics: .shared
) )
} }
@@ -788,7 +801,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
peerIdentityStore: PeerIdentityStore? = nil, peerIdentityStore: PeerIdentityStore? = nil,
locationPresenceStore: LocationPresenceStore? = nil, locationPresenceStore: LocationPresenceStore? = nil,
locationManager: LocationChannelManager = .shared, locationManager: LocationChannelManager = .shared,
readReceiptsDefaults: UserDefaults? = nil readReceiptsDefaults: UserDefaults? = nil,
outboxStore: MessageOutboxStore? = nil,
sfMetrics: StoreAndForwardMetrics? = nil
) { ) {
let conversations = conversations ?? ConversationStore() let conversations = conversations ?? ConversationStore()
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore() let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
@@ -797,10 +812,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
keychain: keychain, keychain: keychain,
idBridge: idBridge, idBridge: idBridge,
identityManager: identityManager, identityManager: identityManager,
meshService: transport meshService: transport,
outboxStore: outboxStore,
sfMetrics: sfMetrics
) )
self.keychain = keychain self.keychain = keychain
self.groupStore = GroupStore(keychain: keychain)
self.idBridge = idBridge self.idBridge = idBridge
self.identityManager = identityManager self.identityManager = identityManager
self.conversations = conversations self.conversations = conversations
@@ -988,6 +1006,48 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
) )
} }
// Mesh (Noise identity) block helpers. Unlike the `/block <nickname>`
// command, these resolve and persist the block by the peer's stable
// fingerprint (derived from `peerID`), so the exact tapped peer is
// (un)blocked unambiguous across nickname collisions and functional for
// offline peers that can no longer be resolved through the mesh service.
@MainActor
func blockMeshPeer(peerID: PeerID, displayName: String) {
setMeshPeerBlocked(peerID, blocked: true, displayName: displayName)
}
@MainActor
func unblockMeshPeer(peerID: PeerID, displayName: String) {
setMeshPeerBlocked(peerID, blocked: false, displayName: displayName)
}
@MainActor
private func setMeshPeerBlocked(_ peerID: PeerID, blocked: Bool, displayName: String) {
guard unifiedPeerService.setBlocked(peerID, blocked: blocked) != nil else {
addCommandOutput(
String(
format: String(
localized: blocked ? "system.mesh.block_failed" : "system.mesh.unblock_failed",
comment: "System message shown when a mesh peer cannot be blocked or unblocked"
),
locale: .current,
displayName
)
)
return
}
addCommandOutput(
String(
format: String(
localized: blocked ? "system.mesh.blocked" : "system.mesh.unblocked",
comment: "System message shown when a mesh peer is blocked or unblocked"
),
locale: .current,
displayName
)
)
}
func displayNameForNostrPubkey(_ pubkeyHex: String) -> String { func displayNameForNostrPubkey(_ pubkeyHex: String) -> String {
publicConversationCoordinator.displayNameForNostrPubkey(pubkeyHex) publicConversationCoordinator.displayNameForNostrPubkey(pubkeyHex)
} }
@@ -1147,6 +1207,24 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// Clear persistent favorites from keychain // Clear persistent favorites from keychain
FavoritesPersistenceService.shared.clearAllFavorites() FavoritesPersistenceService.shared.clearAllFavorites()
// Drop courier mail carried for third parties (memory and disk),
// our own queued outbox, the carried public history, and the
// counters describing all of it
CourierStore.shared.wipe()
messageRouter.wipeOutbox()
GossipMessageArchive.wipeDefault()
StoreAndForwardMetrics.shared.reset()
// Drop private group keys and rosters (keychain + disk)
groupStore.wipe()
// Drop cached peers' prekey bundles (who we could write to is
// metadata too). Our own prekey privates are keychain-backed and go
// with deleteAllKeychainData above plus the identity reset below.
PrekeyBundleStore.shared.wipe()
// Drop bulletin-board posts and tombstones (memory and disk); board
// posts are signed with our identity key and persist for days.
BoardStore.shared.wipe()
// Identity manager has cleared persisted identity data above // Identity manager has cleared persisted identity data above
// Clear autocomplete state // Clear autocomplete state
@@ -1215,6 +1293,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// Delete ALL media files (incoming and outgoing) in background // Delete ALL media files (incoming and outgoing) in background
Task.detached(priority: .utility) { Task.detached(priority: .utility) {
// Skipped under tests: the test process shares the user's real
// ~/Library/Application Support/files tree, and this detached
// utility-priority wipe fires at a nondeterministic time
// deleting media that concurrently running tests (e.g. the
// sendImage flow) just wrote there, and the developer's real
// app data with it.
guard !TestEnvironment.isRunningTests else { return }
do { do {
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let filesDir = base.appendingPathComponent("files", isDirectory: true) let filesDir = base.appendingPathComponent("files", isDirectory: true)
@@ -1427,6 +1512,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
func setupNoiseCallbacks() { func setupNoiseCallbacks() {
verificationCoordinator.setupNoiseCallbacks() verificationCoordinator.setupNoiseCallbacks()
vouchCoordinator.setupNoiseCallbacks()
}
/// Whether the fingerprint currently counts as vouched (1 valid vouch
/// from a voucher I verified, and no explicit verification of mine).
@MainActor
func isVouchedFingerprint(_ fingerprint: String) -> Bool {
identityManager.isVouched(fingerprint: fingerprint)
} }
// MARK: - BitchatDelegate Methods // MARK: - BitchatDelegate Methods
@@ -1435,7 +1528,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
/// Processes IRC-style commands starting with '/'. /// Processes IRC-style commands starting with '/'.
/// - Parameter command: The full command string including the leading slash /// - Parameter command: The full command string including the leading slash
/// - Note: Supports commands like /nick, /msg, /who, /slap, /clear, /help /// - Note: Supports commands like /msg, /who, /slap, /clear, /help
@MainActor @MainActor
func handleCommand(_ command: String) { func handleCommand(_ command: String) {
let result = commandProcessor.process(command) let result = commandProcessor.process(command)
@@ -1443,16 +1536,56 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
switch result { switch result {
case .success(let message): case .success(let message):
if let msg = message { if let msg = message {
addSystemMessage(msg) addCommandOutput(msg)
} }
case .error(let message): case .error(let message):
addSystemMessage(message) addCommandOutput(message)
case .handled: case .handled:
// Command was handled, no message needed // Command was handled, no message needed
break break
} }
} }
/// Command output belongs in the conversation where the user typed the
/// command; the public timeline is invisible while a DM is open. The DM
/// selection is read *after* processing so commands that switch chats
/// (`/msg`) print into the conversation they just opened.
@MainActor
private func addCommandOutput(_ content: String) {
if let peerID = selectedPrivateChatPeer {
addLocalPrivateSystemMessage(content, to: peerID)
} else {
addSystemMessage(content)
}
}
/// Origin conversation for deferred command output, captured when the
/// command is issued (before any async work starts).
@MainActor
func currentCommandDestination() -> CommandOutputDestination {
if let peerID = selectedPrivateChatPeer {
return .privateChat(peerID)
}
// Deferring commands (/ping) are rejected in geohash channels, so a
// non-DM origin is always the #mesh timeline.
return .meshTimeline
}
/// Routes deferred command output (async /ping results) into the
/// conversation captured at issue time, immune to chat switches in the
/// meantime. A DM result lands in the origin chat's history even if that
/// chat is no longer selected (or was cleared it then reappears as the
/// first message when the chat is reopened).
@MainActor
func addCommandOutput(_ content: String, to destination: CommandOutputDestination) {
switch destination {
case .privateChat(let peerID):
addLocalPrivateSystemMessage(content, to: peerID)
case .meshTimeline:
publicConversationCoordinator.addMeshOnlySystemMessage(content)
}
}
// MARK: - Message Reception // MARK: - Message Reception
@MainActor @MainActor
@@ -1484,6 +1617,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
) )
} }
func didReceiveGroupMessage(payload: Data, timestamp: Date) {
Task { @MainActor [weak self] in
self?.groupCoordinator.handleGroupMessagePayload(payload, timestamp: timestamp)
}
}
// MARK: - QR Verification API // MARK: - QR Verification API
@MainActor @MainActor
func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool { func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {
@@ -1511,6 +1650,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
func didUpdatePeerList(_ peers: [PeerID]) { func didUpdatePeerList(_ peers: [PeerID]) {
peerListCoordinator.didUpdatePeerList(peers) peerListCoordinator.didUpdatePeerList(peers)
// A peer-list update follows every verified announce, which is where a
// peer's `.vouch` capability actually arrives retry vouching now that
// capabilities may finally be known (closes the auth-time capability race).
Task { @MainActor [weak self] in
self?.vouchCoordinator.peersUpdated(peers)
}
} }
@MainActor @MainActor
@@ -1599,6 +1744,19 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
func addGeohashOnlySystemMessage(_ content: String) { func addGeohashOnlySystemMessage(_ content: String) {
publicConversationCoordinator.addGeohashOnlySystemMessage(content) publicConversationCoordinator.addGeohashOnlySystemMessage(content)
} }
/// Add a local system message to one specific geohash timeline, active or
/// not. Used by the board's new-pin alerts to scope-match the pin's channel.
@MainActor
func addGeohashSystemMessage(_ content: String, geohash: String) {
let systemMessage = BitchatMessage(
sender: "system",
content: content,
timestamp: Date(),
isRelay: false
)
appendGeohashMessageIfAbsent(systemMessage, toGeohash: geohash)
}
// Send a public message without adding a local user echo. // Send a public message without adding a local user echo.
// Used for emotes where we want a local system-style confirmation instead. // Used for emotes where we want a local system-style confirmation instead.
@MainActor @MainActor
@@ -1606,12 +1764,27 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
publicConversationCoordinator.sendPublicRaw(content) publicConversationCoordinator.sendPublicRaw(content)
} }
// Send a normal public message (with local echo) to the active channel.
// CommandContextProvider hook for commands that post real messages
// (`/pay`); only called when no private chat is selected.
@MainActor
func sendPublicMessage(_ content: String) {
sendMessage(content)
}
/// Handle incoming public message /// Handle incoming public message
@MainActor @MainActor
func handlePublicMessage(_ message: BitchatMessage) { func handlePublicMessage(_ message: BitchatMessage) {
publicConversationCoordinator.handlePublicMessage(message) publicConversationCoordinator.handlePublicMessage(message)
} }
/// Handle an incoming public Nostr message with its validated NIP-13
/// difficulty; sufficient PoW relaxes the per-sender rate limit.
@MainActor
func handlePublicMessage(_ message: BitchatMessage, powBits: Int) {
publicConversationCoordinator.handlePublicMessage(message, powBits: powBits)
}
/// Check for mentions and send notifications /// Check for mentions and send notifications
func checkForMentions(_ message: BitchatMessage) { func checkForMentions(_ message: BitchatMessage) {
publicConversationCoordinator.checkForMentions(message) publicConversationCoordinator.checkForMentions(message)
@@ -17,7 +17,9 @@ struct ChatViewModelServiceBundle {
keychain: KeychainManagerProtocol, keychain: KeychainManagerProtocol,
idBridge: NostrIdentityBridge, idBridge: NostrIdentityBridge,
identityManager: SecureIdentityStateManagerProtocol, identityManager: SecureIdentityStateManagerProtocol,
meshService: Transport meshService: Transport,
outboxStore: MessageOutboxStore? = nil,
sfMetrics: StoreAndForwardMetrics? = nil
) { ) {
let commandProcessor = CommandProcessor(identityManager: identityManager) let commandProcessor = CommandProcessor(identityManager: identityManager)
let privateChatManager = PrivateChatManager(meshService: meshService) let privateChatManager = PrivateChatManager(meshService: meshService)
@@ -28,14 +30,22 @@ struct ChatViewModelServiceBundle {
) )
let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge) let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge)
nostrTransport.senderPeerID = meshService.myPeerID nostrTransport.senderPeerID = meshService.myPeerID
let messageRouter = MessageRouter(transports: [meshService, nostrTransport]) let messageRouter = MessageRouter(
transports: [meshService, nostrTransport],
outboxStore: outboxStore,
metrics: sfMetrics
)
self.commandProcessor = commandProcessor self.commandProcessor = commandProcessor
self.messageRouter = messageRouter self.messageRouter = messageRouter
self.privateChatManager = privateChatManager self.privateChatManager = privateChatManager
self.unifiedPeerService = unifiedPeerService self.unifiedPeerService = unifiedPeerService
self.autocompleteService = AutocompleteService() self.autocompleteService = AutocompleteService()
self.deduplicationService = MessageDeduplicationService() // Persist processed gift-wrap event IDs: NIP-59 randomizes their
// timestamps, so the 24h-lookback DM subscriptions redeliver the same
// events on every launch and only a cross-launch record stops the
// reprocessing (re-sent DELIVERED bursts, phantom-ack noise).
self.deduplicationService = MessageDeduplicationService(nostrEventStore: NostrProcessedEventStore())
self.publicMessagePipeline = PublicMessagePipeline() self.publicMessagePipeline = PublicMessagePipeline()
} }
} }
@@ -66,6 +76,7 @@ final class ChatViewModelBootstrapper {
configureNoiseCallbacks() configureNoiseCallbacks()
bindTransferProgress() bindTransferProgress()
configureGeoChannels() configureGeoChannels()
configureGateway()
bindTeleportState() bindTeleportState()
requestNotifications() requestNotifications()
registerObservers() registerObservers()
@@ -100,11 +111,28 @@ private extension ChatViewModelBootstrapper {
category: .session category: .session
) )
viewModel.conversations.setDeliveryStatus( viewModel.conversations.setDeliveryStatus(
.failed(reason: "Not delivered"), .failed(reason: String(localized: "content.delivery.reason.not_delivered", comment: "Failure reason shown when the router gave up delivering a message")),
forMessageID: messageID forMessageID: messageID
) )
} }
} }
// A message with no reachable transport that was handed to a courier
// shows a distinct "carried" state instead of sitting in "sending"
// forever. Never downgrade a confirmed receipt: the courier copy can
// race direct delivery when the peer reappears.
viewModel.messageRouter.onMessageCarried = { [weak viewModel] messageID, peerID in
guard let viewModel else { return }
switch viewModel.conversations.deliveryStatus(forMessageID: messageID) {
case .delivered, .read:
break
default:
SecureLogger.debug(
"📦 Message \(messageID.prefix(8))… for \(peerID.id.prefix(8))… handed to courier → marked carried",
category: .session
)
viewModel.conversations.setDeliveryStatus(.carried, forMessageID: messageID)
}
}
viewModel.commandProcessor.contextProvider = viewModel viewModel.commandProcessor.contextProvider = viewModel
viewModel.commandProcessor.meshService = viewModel.meshService viewModel.commandProcessor.meshService = viewModel.meshService
viewModel.participantTracker.configure(context: viewModel) viewModel.participantTracker.configure(context: viewModel)
@@ -221,6 +249,72 @@ private extension ChatViewModelBootstrapper {
) )
} }
/// Wires the gateway-mode policy layer (`GatewayService`) to the mesh
/// transport, the relay manager, and the inbound Nostr pipeline. All
/// dependencies are closures so the service stays unit-testable with
/// fakes.
func configureGateway() {
// Gateway mode bridges BLE mesh <-> Nostr; a mock transport (tests)
// has no carrier packets to bridge.
guard let bleService = viewModel.meshService as? BLEService else { return }
let gateway = GatewayService.shared
gateway.publishToRelays = { event, geohash in
let relays = GeoRelayDirectory.shared.closestRelays(
toGeohash: geohash,
count: TransportConfig.nostrGeoRelayCount
)
// Symmetric with the local send path (GeohashSubscriptionManager
// .sendGeohash): with no known geo relay, refuse rather than
// publish to default relays no geo subscriber reads that would
// be silent dead traffic, not delivery.
guard !relays.isEmpty else {
SecureLogger.warning("🌐 Gateway: no geo relays for #\(geohash); not publishing carried event", category: .session)
return
}
NostrRelayManager.shared.sendEvent(event, to: relays)
}
gateway.broadcastToMesh = { [weak bleService] payload in
bleService?.broadcastNostrCarrier(payload)
}
gateway.sendToGatewayPeer = { [weak bleService] payload, peer in
bleService?.sendNostrCarrier(payload, to: peer) ?? false
}
gateway.availableGatewayPeers = { [weak bleService] in
bleService?.reachableGatewayPeers() ?? []
}
gateway.relaysConnected = { NostrRelayManager.shared.isConnected }
gateway.currentGeohash = { [weak viewModel] in viewModel?.currentGeohash }
// Carried events enter the same pipeline as relay-received events so
// blocking, rate limits, dedup, and rendering behave identically.
gateway.injectInbound = { [weak viewModel] event in
viewModel?.handleNostrEvent(event)
}
// The capability bit is advertised ONLY while the toggle is on; a
// change forces a re-announce so peers learn promptly.
gateway.onEnabledChanged = { [weak bleService] enabled in
bleService?.setLocalCapability(.gateway, enabled: enabled)
}
bleService.onNostrCarrierPacket = { payload, from, directedToUs in
GatewayService.shared.handleMeshCarrier(payload, from: from, directedToUs: directedToUs)
}
// Uplinks deposited while relays were unreachable flush on reconnect.
NostrRelayManager.shared.$isConnected
.receive(on: DispatchQueue.main)
.sink { connected in
if connected {
GatewayService.shared.flushQueuedUplinks()
}
}
.store(in: &viewModel.cancellables)
// Apply the persisted toggle at launch.
if gateway.isEnabled {
bleService.setLocalCapability(.gateway, enabled: true)
}
}
func bindTeleportState() { func bindTeleportState() {
viewModel.locationManager.$teleported viewModel.locationManager.$teleported
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
@@ -0,0 +1,272 @@
import BitFoundation
import BitLogger
import Foundation
/// The narrow surface `ChatVouchCoordinator` needs from its owner.
///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
/// minimal context it actually uses instead of holding an `unowned` back-ref
/// to the whole `ChatViewModel`. This keeps the coordinator independently
/// testable (see `ChatVouchCoordinatorContextTests`) and makes its true
/// dependencies explicit.
@MainActor
protocol ChatVouchContext: AnyObject {
// MARK: Identity & trust state
func getFingerprint(for peerID: PeerID) -> String?
func isVerifiedFingerprint(_ fingerprint: String) -> Bool
/// The peer's announce-bound Ed25519 signing key, if known this session.
func signingKey(forFingerprint fingerprint: String) -> Data?
/// Verified fingerprints ordered most recently verified first.
func recentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String]
/// Stores an accepted vouch (identity manager enforces the storage gates).
@discardableResult
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool
func lastVouchBatchSent(to fingerprint: String) -> Date?
func markVouchBatchSent(to fingerprint: String, at date: Date)
// MARK: Transport
func peerCapabilities(for peerID: PeerID) -> PeerCapabilities
/// PeerIDs with a currently established mesh session (used to run a vouch
/// pass over peers we are already connected to when we verify someone).
func connectedPeerIDs() -> [PeerID]
/// Appends a session-established observer (additive; never displaces the
/// verification coordinator's callbacks).
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void)
/// Signs `data` with our Noise (Ed25519) signing key.
func noiseSignData(_ data: Data) -> Data?
func sendVouchAttestations(_ payload: Data, to peerID: PeerID)
// MARK: UI refresh
/// Signals that derived trust state changed so peer list / fingerprint
/// views recompute badges.
func notifyPeerTrustChanged()
}
extension ChatViewModel: ChatVouchContext {
// `getFingerprint(for:)` and `isVerifiedFingerprint(_:)` are shared
// requirements with the verification context and satisfied by existing
// `ChatViewModel` members. The members below flatten nested service
// accesses into intent-named calls.
func signingKey(forFingerprint fingerprint: String) -> Data? {
identityManager.signingPublicKey(forFingerprint: fingerprint)
}
func recentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] {
identityManager.mostRecentlyVerifiedFingerprints(limit: limit, excluding: fingerprint)
}
@discardableResult
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool {
identityManager.recordVouch(
voucheeFingerprint: voucheeFingerprint,
voucherFingerprint: voucherFingerprint,
timestamp: timestamp
)
}
func lastVouchBatchSent(to fingerprint: String) -> Date? {
identityManager.lastVouchBatchSent(to: fingerprint)
}
func markVouchBatchSent(to fingerprint: String, at date: Date) {
identityManager.markVouchBatchSent(to: fingerprint, at: date)
}
func peerCapabilities(for peerID: PeerID) -> PeerCapabilities {
meshService.peerCapabilities(peerID)
}
func connectedPeerIDs() -> [PeerID] {
Array(connectedPeers)
}
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {
meshService.addPeerAuthenticatedObserver(handler)
}
func noiseSignData(_ data: Data) -> Data? {
meshService.noiseSignData(data)
}
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {
meshService.sendVouchAttestations(payload, to: peerID)
}
func notifyPeerTrustChanged() {
// PeerListModel refreshes on this notification; the view-model change
// covers FingerprintView / VerificationModel consumers.
NotificationCenter.default.post(name: Notification.Name("peerStatusUpdated"), object: nil)
notifyUIChanged()
}
}
/// Transitive verification ("vouching"): when a Noise session comes up with a
/// peer I verified, I attest over that authenticated, encrypted session
/// to the other identities I have verified. Receivers accept such vouches
/// only from peers *they* verified, giving a serverless
/// verified-by-people-you-verified tier (`TrustLevel.vouched`).
@MainActor
final class ChatVouchCoordinator {
/// Minimum spacing between vouch batches to the same peer (persisted).
static let batchInterval: TimeInterval = 24 * 60 * 60
private unowned let context: any ChatVouchContext
init(context: any ChatVouchContext) {
self.context = context
}
/// Registers the session-established hook. Additive alongside the
/// verification coordinator's callbacks; call once at bootstrap.
func setupNoiseCallbacks() {
context.addPeerAuthenticatedObserver { [weak self] peerID, fingerprint in
DispatchQueue.main.async { [weak self] in
self?.peerAuthenticated(peerID, fingerprint: fingerprint)
}
}
}
/// Trigger session established: on a Noise session coming up with a peer
/// I verified, attempt to send a vouch batch. Kept as the historical entry
/// point; the real work lives in `attemptVouch`.
func peerAuthenticated(_ peerID: PeerID, fingerprint: String, now: Date = Date()) {
attemptVouch(to: peerID, fingerprint: fingerprint, now: now)
}
/// Trigger verified announce processed: a peer's `.vouch` capability
/// arrives on its *announce*, which is handled independently of the Noise
/// handshake. This is invoked on every peer-list update (fired after each
/// verified announce), so it closes the capability race the batch that
/// `peerAuthenticated` couldn't send (capabilities not yet known) goes out
/// once the capability-bearing announce lands. Throttled per peer.
func peersUpdated(_ peerIDs: [PeerID], now: Date = Date()) {
for peerID in peerIDs {
guard let fingerprint = context.getFingerprint(for: peerID) else { continue }
attemptVouch(to: peerID, fingerprint: fingerprint, now: now)
}
}
/// Trigger local verification completed: the user just verified a peer.
/// Run a vouch pass over every currently connected peer I verified. This
/// makes vouching fire when verifying someone already connected (whose
/// session is authenticated, so `peerAuthenticated` never re-fires), and it
/// propagates the newly-verified identity to my other verified peers.
/// Throttled per peer by `batchInterval`, so it can't spam.
func vouchToConnectedVerifiedPeers(now: Date = Date()) {
var sentCount = 0
for peerID in context.connectedPeerIDs() {
guard let fingerprint = context.getFingerprint(for: peerID) else { continue }
if attemptVouch(to: peerID, fingerprint: fingerprint, now: now) {
sentCount += 1
}
}
if sentCount > 0 {
SecureLogger.info(
"🪪 verify-triggered vouch pass sent to \(sentCount) connected peer(s)",
category: .security
)
}
}
/// Exchange policy shared by every trigger: to a peer I verified, send
/// attestations for up to `VouchAttestation.maxBatchCount` *other* verified
/// fingerprints (most recently verified first), at most once per peer per
/// `batchInterval`. Returns whether a batch was actually sent.
@discardableResult
func attemptVouch(to peerID: PeerID, fingerprint: String, now: Date = Date()) -> Bool {
guard context.isVerifiedFingerprint(fingerprint) else { return false }
// Capability gate, race-tolerant: a peer's `.vouch` bit is carried on
// its announce, processed independently of the Noise handshake, so at
// authentication time the capability set is frequently still empty.
// Treat an empty/unknown set as eligible the payload is a Noise
// `0x12` (`NoisePayloadType.vouch`) that non-supporting peers harmlessly
// ignore, so sending on an unknown set is safe and avoids the race
// dropping the batch. Only skip when the peer advertised a non-empty
// capability set that explicitly lacks `.vouch`.
let capabilities = context.peerCapabilities(for: peerID)
if !capabilities.isEmpty, !capabilities.contains(.vouch) { return false }
if let lastSent = context.lastVouchBatchSent(to: fingerprint),
now.timeIntervalSince(lastSent) < Self.batchInterval {
return false
}
let candidates = context.recentlyVerifiedFingerprints(
limit: VouchAttestation.maxBatchCount,
excluding: fingerprint
)
var attestations: [VouchAttestation] = []
for candidate in candidates {
// Only fingerprints whose announce-bound signing key we know can
// be anchored to a concrete identity; skip the rest.
guard let fingerprintData = Data(hexString: candidate),
fingerprintData.count == VouchAttestation.fingerprintSize,
let signingKey = context.signingKey(forFingerprint: candidate),
signingKey.count == VouchAttestation.signingKeySize,
let attestation = VouchAttestation.build(
voucheeFingerprint: fingerprintData,
voucheeSigningKey: signingKey,
timestampMs: UInt64(now.timeIntervalSince1970 * 1000),
sign: context.noiseSignData
) else {
continue
}
attestations.append(attestation)
}
guard !attestations.isEmpty,
let payload = VouchAttestation.encodeList(attestations) else { return false }
context.sendVouchAttestations(payload, to: peerID)
context.markVouchBatchSent(to: fingerprint, at: now)
SecureLogger.debug(
"🪪 Sent \(attestations.count) vouch attestation(s) to \(peerID.id.prefix(8))",
category: .security
)
return true
}
/// Accept policy: process inbound vouches only from a sender I verified,
/// only with a valid Ed25519 signature under the sender's announce-bound
/// signing key, and only within the validity window. Self-vouches and
/// vouches for already-verified peers are dropped by the identity
/// manager's storage gates.
func handleVouchPayload(from peerID: PeerID, payload: Data, now: Date = Date()) {
guard let senderFingerprint = context.getFingerprint(for: peerID),
context.isVerifiedFingerprint(senderFingerprint) else {
SecureLogger.debug(
"🪪 Ignoring vouch payload from unverified peer \(peerID.id.prefix(8))",
category: .security
)
return
}
guard let senderSigningKey = context.signingKey(forFingerprint: senderFingerprint) else {
SecureLogger.debug(
"🪪 No signing key for vouching peer \(peerID.id.prefix(8))…; dropping batch",
category: .security
)
return
}
var acceptedCount = 0
for attestation in VouchAttestation.decodeList(from: payload) {
guard attestation.verifySignature(voucherSigningKey: senderSigningKey),
!attestation.isExpired(now: now) else { continue }
let stored = context.recordVouch(
voucheeFingerprint: attestation.voucheeFingerprintHex,
voucherFingerprint: senderFingerprint,
timestamp: attestation.timestamp
)
if stored { acceptedCount += 1 }
}
if acceptedCount > 0 {
SecureLogger.info(
"🪪 Accepted \(acceptedCount) vouch(es) from \(senderFingerprint.prefix(8))",
category: .security
)
context.notifyPeerTrustChanged()
}
}
}
@@ -109,11 +109,6 @@ extension ChatViewModel {
) )
} }
@MainActor
func handleFavoriteNotification(content: String, from nostrPubkey: String) {
nostrCoordinator.handleFavoriteNotification(content: content, from: nostrPubkey)
}
@MainActor @MainActor
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) { func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
nostrCoordinator.sendFavoriteNotificationViaNostr(noisePublicKey: noisePublicKey, isFavorite: isFavorite) nostrCoordinator.sendFavoriteNotificationViaNostr(noisePublicKey: noisePublicKey, isFavorite: isFavorite)
@@ -13,6 +13,12 @@ extension ChatViewModel {
@MainActor @MainActor
func sendPrivateMessage(_ content: String, to peerID: PeerID) { func sendPrivateMessage(_ content: String, to peerID: PeerID) {
// Group chats reuse the private-chat surface but broadcast a sealed
// envelope instead of routing to a single peer.
if peerID.isGroup {
groupCoordinator.sendGroupMessage(content, to: peerID)
return
}
privateConversationCoordinator.sendPrivateMessage(content, to: peerID) privateConversationCoordinator.sendPrivateMessage(content, to: peerID)
} }
@@ -121,25 +127,6 @@ extension ChatViewModel {
mediaTransferCoordinator.deleteMediaMessage(messageID: messageID) mediaTransferCoordinator.deleteMediaMessage(messageID: messageID)
} }
@MainActor
func handlePrivateMessage(
_ payload: NoisePayload,
actualSenderNoiseKey: Data?,
senderNickname: String,
targetPeerID: PeerID,
messageTimestamp: Date,
senderPubkey: String
) {
privateConversationCoordinator.handlePrivateMessage(
payload,
actualSenderNoiseKey: actualSenderNoiseKey,
senderNickname: senderNickname,
targetPeerID: targetPeerID,
messageTimestamp: messageTimestamp,
senderPubkey: senderPubkey
)
}
@MainActor @MainActor
func handlePrivateMessage(_ message: BitchatMessage) { func handlePrivateMessage(_ message: BitchatMessage) {
privateConversationCoordinator.handlePrivateMessage(message) privateConversationCoordinator.handlePrivateMessage(message)
@@ -190,8 +177,8 @@ extension ChatViewModel {
} }
@MainActor @MainActor
func handleFavoriteNotificationFromMesh(_ content: String, from peerID: PeerID, senderNickname: String) { func handleFavoriteNotification(_ content: String, from peerID: PeerID, senderNickname: String) {
privateConversationCoordinator.handleFavoriteNotificationFromMesh( privateConversationCoordinator.handleFavoriteNotification(
content, content,
from: peerID, from: peerID,
senderNickname: senderNickname senderNickname: senderNickname
@@ -115,6 +115,9 @@ final class GeohashSubscriptionManager {
private weak var context: (any GeohashSubscriptionContext)? private weak var context: (any GeohashSubscriptionContext)?
private let inbound: NostrInboundPipeline private let inbound: NostrInboundPipeline
private let presence: GeoPresenceTracker private let presence: GeoPresenceTracker
/// Geohashes already told "sent via mesh gateway" this session, so the
/// notice appears once per channel instead of once per message.
private var gatewayNoticeGeohashes = Set<String>()
init(context: any GeohashSubscriptionContext, inbound: NostrInboundPipeline, presence: GeoPresenceTracker) { init(context: any GeohashSubscriptionContext, inbound: NostrInboundPipeline, presence: GeoPresenceTracker) {
self.context = context self.context = context
@@ -145,6 +148,9 @@ final class GeohashSubscriptionManager {
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
self?.inbound.subscribeNostrEvent(event) self?.inbound.subscribeNostrEvent(event)
// Gateway downlink: rebroadcast relay events for the viewed
// channel onto the mesh (no-op unless gateway mode is on).
GatewayService.shared.rebroadcastRelayEvent(event, geohash: channel.geohash)
} }
} }
@@ -235,6 +241,9 @@ final class GeohashSubscriptionManager {
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
self?.inbound.handleNostrEvent(event) self?.inbound.handleNostrEvent(event)
// Gateway downlink: rebroadcast relay events for the viewed
// channel onto the mesh (no-op unless gateway mode is on).
GatewayService.shared.rebroadcastRelayEvent(event, geohash: channel.geohash)
} }
} }
@@ -280,6 +289,23 @@ final class GeohashSubscriptionManager {
NostrRelayManager.shared.sendEvent(event, to: targetRelays) NostrRelayManager.shared.sendEvent(event, to: targetRelays)
} }
// Mesh gateway uplink: with no working relay connection, hand the
// locally signed event to a mesh peer advertising the gateway
// capability (keys never leave this device only the finished,
// signed event travels). Uplink is only ever attempted here, for a
// freshly composed event, never for received carrier events (loop
// rule 3 in GatewayService).
if GatewayService.shared.uplinkViaMesh(event: event, geohash: channel.geohash),
gatewayNoticeGeohashes.insert(channel.geohash).inserted {
context.addPublicSystemMessage(
String(
localized: "system.gateway.sent_via_mesh",
defaultValue: "sent via mesh gateway",
comment: "System message when a geohash message was handed to a mesh internet gateway because no relay is reachable"
)
)
}
context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex) context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
context.registerNostrKeyMapping(identity.publicKeyHex, for: PeerID(nostr: identity.publicKeyHex)) context.registerNostrKeyMapping(identity.publicKeyHex, for: PeerID(nostr: identity.publicKeyHex))
SecureLogger.debug( SecureLogger.debug(
+19 -9
View File
@@ -48,15 +48,25 @@ struct MessageRateLimiter {
self.contentRefill = contentRefillPerSec self.contentRefill = contentRefillPerSec
} }
mutating func allow(senderKey: String, contentKey: String, now: Date = Date()) -> Bool { /// - Parameter powBits: validated NIP-13 difficulty of the event
var senderBucket = senderBuckets[senderKey] ?? TokenBucket( /// (`NostrPoW.validatedDifficulty`; 0 for mesh or no-PoW events).
capacity: senderCapacity, /// At or above `NostrPoW.rateLimitBypassBits` the per-sender bucket is
tokens: senderCapacity, /// skipped entirely each such message paid for itself with work but
refillPerSec: senderRefill, /// the per-content flood bucket still applies.
lastRefill: now mutating func allow(senderKey: String, contentKey: String, powBits: Int = 0, now: Date = Date()) -> Bool {
) let senderAllowed: Bool
let senderAllowed = senderBucket.allow(now: now) if powBits >= NostrPoW.rateLimitBypassBits {
senderBuckets[senderKey] = senderBucket senderAllowed = true
} else {
var senderBucket = senderBuckets[senderKey] ?? TokenBucket(
capacity: senderCapacity,
tokens: senderCapacity,
refillPerSec: senderRefill,
lastRefill: now
)
senderAllowed = senderBucket.allow(now: now)
senderBuckets[senderKey] = senderBucket
}
var contentBucket = contentBuckets[contentKey] ?? TokenBucket( var contentBucket = contentBuckets[contentKey] ?? TokenBucket(
capacity: contentCapacity, capacity: contentCapacity,

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