9 Commits
Author SHA1 Message Date
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
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
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
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
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
IslamandGitHub 4cfcefcda6 BitFoundation module to centralize shared components (#1089)
* Run local packages’ tests as well on CI

* BitFoundation module to centralize shared components
2026-04-14 14:10:03 -05:00
7d83310bc2 Expand Nostr and identity coverage (#1059)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2026-03-12 19:41:05 -10:00