After force-quit -> relaunch the durable outbox is restored while the
in-memory conversation store is not. updateAcknowledgedMessageDeliveryStatus
gated the router-side clear on the conversation lookup, so a delivery ack
for a retried message was discarded and the already-delivered message
re-sent on every flush/auth event until the attempt cap marked it failed.
Always run the peer-scoped router clear (markDelivered(_:from:) only
terminalizes the acking peer's own queues); gate only the UI status
transition and the media-retry release on the conversation holding the
message, mirroring the Nostr path in
ChatPrivateConversationCoordinator.handleDelivered.
Also drop the dead allowedPeerIDs parameter on
MessageRouter.clearRetainedMessage (only ever called with nil; its filter
branch paired a scoped in-memory removal with an unscoped store tombstone)
and the redundant manual ==/hash on PeerMessageKey.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Voice recording happens inside the people/DM sheet, but its error alert
was still presented unconditionally from the root view. A mic-permission
failure therefore force-dismissed the sheet and ended the conversation
through exactly the reconciliation path this branch fixes for the
Bluetooth alert.
Treat it the same way: the sheet-dismiss guard now also ignores
reconciliation while the voice alert is up, the root copy of the alert
defers to any other root modal (people sheet included), and the sheet
presents its own copy gated on the sheet's modal state. The voice alert
keeps priority over the Bluetooth alert on both levels via the existing
isVoiceAlertPresented flag, now mirrored into the people-sheet state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A refused delete/clear (journal write failure, reservation conflict,
path collision, capacity) only logged SecureLogger.error, so a wedged
/clear looked like success. Refusals now post a localized system
message (content.system.media_delete_refused, 30 locales) into the
affected private chat via the existing system-message mechanism, wired
at both the per-bubble delete and /clear refusal sites.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Explicit deletion of LEGACY (non-stable-ID) incoming media left the
decrypted payload on disk indefinitely, waiting for 100MB oldest-first
quota cleanup. Restore real deletion safely with the machinery this PR
built: BLEIncomingFileStore.removeLegacyIncomingFile unlinks under the
payload-coordination lock only when the path is not pending delivery,
not held by a deletion reservation, and not owned by a stable receipt
or journal entry; otherwise the file keeps the leave-for-quota
fallback. The view model routes per-bubble deletes and /clear through
the gated unlink once no bubble in any conversation references the
basename.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Clearing conversation A removed outgoing media IDs from every direct
conversation and unlinked the payload unconditionally, so a mirrored
outgoing bubble in conversation B (identity-alias handoff) lost both
its row and its file. Outgoing removal and file cleanup now mirror the
incoming alias protection: only IDs with no surviving copy in another
conversation are removed everywhere and unlinked.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The structurally invalid record is now quarantined aside on first
lookup instead of staying in place globally fail-closed, so the test
clears the .corrupt file before exercising journal validation alone.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A private-media policy resolution whose completion is dropped by
transport teardown (BLEService messageQueue deallocation guard) left
its peersResolvingReconnectRetry entry in place, parking every future
reconnect-retry resolution for that peer until panic reset. Disconnect
now removes the entry; retained records survive and the next reconnect
starts a fresh resolution.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
content.delivery.reason.private_media_capability_unresolved (used on
this branch) and content.delivery.reason.private_media_delivery_
unconfirmed (used by the receipt-timeout branch stacked on it), each
covering all 30 locales.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The panic media wipe runs on the file store PanicRecoveryOperations
.live() constructs, but receipt lookups are served by BLEService's own
incomingFileStore, whose in-memory index survived the wipe. Drop that
instance's cache in completePanicReset — after media deletion, before
radio admission reopens — so the next lookup rebuilds from the wiped
directory. The panic test now drives the production suspend/wipe/
complete sequence against the instance BLEService actually uses.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A single unreadable or undecodable receipt record used to abort the
whole index install, leaving every lookup .unavailable and silently
dropping ALL inbound private media with no recovery path. Move the
damaged file aside to a .corrupt name (skipped by future scans, bytes
preserved), fail only that ID closed, and let the rest of the ledger
load. Directory-level create/enumerate failures stay globally
fail-closed and retryable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The deferral-buffer rework routes quarantine restores through a
same-generation ready transition that no longer rebuilds capability
state — but it drained both outbound queues unconditionally there,
bypassing the pending-convergence gate. Honor the restore reason in
that branch too: retrying deferred inbound ciphertext stays (it is
receive-side and safe under restored keys), while queue drains wait for
the convergence retry's establishment exactly as on the new-generation
path. Terminal restores keep draining immediately.
Also pre-drain the establishment's serialized forced announce in the
terminal-restore test so the tap only observes restore-driven traffic.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A responder timeout restores the quarantined old session generation and
requests one mandatory convergence retry, but both were dispatched
unordered onto the transport queue. When the restore handler won the
race it re-entered handleNoisePeerAuthenticated and drained the pending
private-message and typed-payload queues under the restored keys — keys
a legitimately restarted counterpart may have already discarded after
completing the replacement handshake whose message 3 was lost. Every
drained DM, read receipt, and invite then failed authentication
remotely and disappeared silently.
Restores now carry a reason. Terminal restores (claimed-identity
mismatch, or failures owning no convergence retry) drain immediately as
before: the counterpart never finished replacement keys, so the
restored generation is still valid on both ends. Pending-convergence
restores rebuild the generation-bound protocol state for receive
purposes but leave both outbound queues parked; the convergence retry
(or any later reconnect the revalidation policy initiates) re-enters
the authenticated transition with a fresh generation and drains them
under keys both sides hold. If the retry is cancelled the queues keep
waiting for the next establishment — the same contract every other
queued-before-handshake message already relies on — rather than
gambling them on keys only this side can prove.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes the last cleartext private-content path over BLE: private DM images/voice were sent as plaintext signed fileTransfer packets, TTL-relayed across the mesh, so every relay saw the full bytes. Now the complete BitchatFilePacket is encrypted as a single Noise AEAD message (inner type 0x20, matching Android) and the opaque ciphertext is fragmented. Adds an authenticated in-session capability proof (0x21 TLV: capabilities + Ed25519 key), TOFU-style downgrade pinning, a per-send consent dialog for the signed-cleartext fallback to legacy peers, and a cancellation/admission registry so cancel/delete cannot race a deferred cleartext send.
Android wire constants (0x20 / 0x21 / capability bit 8) confirmed shipping. The 256-fragment preflight cap applies only to the directed fileTransfer migration fallback; encrypted media to capable peers uses the full receiver ceiling.
Rebased over #1428/#1349: identity reads go through BLELocalIdentityStateStore; the session-bound authenticated signing-key check and the announce-path TOFU pin are kept as complementary checks. Full local suite green (1744+197 tests).
Raises the file-local waitUntil default from 1s to 10s (returns early on success, so zero cost on healthy runs) and fixes a latent race in the observers test: it waited on the fetch-probe request count, which increments before handleFetchSuccess settles isFetching/lastFetchAt on the MainActor, so the next trigger notification posted into that gap was silently swallowed by the guard. Now waits on the .geoRelayDirectoryDidRefresh notification posted at the end of the synchronous success handler. Negative checks assert against captured baselines. No assertion weakened; 15/15 x 9 local runs including under 2x-ncpu CPU load.
Replaces the share-extension handoff that auto-sent shared text/URLs into the current channel with a validated, single-slot 24h envelope that shows the destination and a bounded preview and requires an explicit tap to copy into the composer — it never sends. Rejects malformed/oversized/control-character/non-HTTP(S)/expired payloads (including bidi-override U+202E), and clears on consume/cancel/expiry/panic.
Rebased onto main with Persian strings added for all new keys (30-locale coverage verified), plus a panic-recovery clear so an envelope staged during an interrupted wipe cannot survive relaunch.
TOFU-pins the Ed25519 signing key per noise key so a self-consistent announce that reuses a victim's peerID+noise key with the attacker's signing key/nickname is rejected instead of overwriting the registry and persisted identity. Complements #1432: that PR's signed-LEAVE verification reads the stored signing key this PR protects from announce-path poisoning.
Rebased onto main as a single commit; swift build --build-tests green, 40/40 targeted tests incl. the spoofing e2e cases, and #1432's suites 50/50 unaffected.
Converts BLEAnnounceThrottle to a lock-backed class and moves myPeerID/myPeerIDData/myNickname into a lock-backed BLELocalIdentityStateStore snapshot, so announces can never observe a split peer-ID/wire-ID during panic rotation. Serializes sendAnnounce onto the messageQueue barrier and moves the panic-reset pendingNoiseSessionQueues clear onto collectionsQueue (the queue every other mutation site uses).
Rebased onto main after #1431/#1432; integrates with #1431's panic-suspend structure (guard retained in both the outer sendAnnounce and the deferred worker).
Moves inbound Nostr Schnorr verification and NIP-17 gift-wrap decryption off the main actor into per-relay bounded AsyncStream pipelines (parallel across relays, ordered per relay), dedups before crypto, and removes the redundant second re-verify in NostrInboundPipeline/GeoPresenceTracker. Adds a panic-wipe generation counter so in-flight decrypts are discarded after a wipe.
Rebased onto main; #1451's 256 KiB pre-parse byte cap and tag limits are preserved and still enforced before any crypto/allocation.
Replaces the scheduled workflow that pushed upstream georelay CSV directly to main unreviewed with a validator-backed automation that resolves an immutable upstream commit, validates against the reviewed baseline, and opens a non-auto-merged PR (or tracking issue) instead of writing to main. Adds a mirrored strict client-side validator in GeoRelayDirectory (all-or-nothing, size/row/host/coord caps, streamed 512KiB cap, >=50% baseline overlap) that fails safe to last-known-good, and retargets runtime refresh to bitchat's reviewed copy.
Replaces ConversationStore's message-ID->physical-index map with logical indexes plus a head indexOffset, so cap-eviction advances the offset instead of rebuilding the whole dictionary on every steady-state append (~23.7x measured ingest throughput at cap). Adds a steady-state benchmark floor and a 1,200-op differential stress test against an O(n) reference model.
Rewrites just clean/nuke to remove only ignored build artifacts (.DerivedData/.build), never git-checkout or rm tracked project files (previously could destroy uncommitted work). Adds a CI guard (check-just-clean-safety.sh) against reintroducing source-mutating cleanup, plus README/Local.xcconfig modernization.
Adds an ios-tests CI job that runs the UIKit/CoreBluetooth-conditional suite on the first available iPhone simulator (serial), and fixes the coverage-summary step ordering so the profile isn't invalidated by the serial benchmark rebuild.
Adds remote-static-key->peerID binding at Noise handshake completion (closes a mesh impersonation/MITM hole where a peer could complete a handshake under another peer's ID). Also hardens LEAVE handling to require a verified signature and suppresses relay of unverifiable leaves.
* Make panic wipe deterministic and device-bound
* Scope install markers to iOS
* Harden panic recovery and service shutdown
* Invalidate queued BLE ingress during panic
* Harden panic keychain and media cleanup
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: jack <jack@deck.local>
* Add Persian (fa) localization and an in-app language picker
Persian was the one notable gap in the 29-language catalog. Translate all
381 strings (plus the share extension) with proper plural substitutions,
and register fa in knownRegions.
Settings gains a LANGUAGE section: a picker over every language the bundle
ships (native names via Locale), backed by an AppleLanguages override so
users can run bitchat in a language different from the device's. Localization
resolves at process start, so the picker surfaces a restart note.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Remove unused AppLanguageSettings.currentOverride (Periphery)
AppInfoView reads the override via @AppStorage, so the accessor was dead
code and failed the Periphery CI scan.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
* Bound public rate-limit buckets against attacker-keyed growth.
Keep the NIP-13 PoW sender bypass, skip content-bucket minting on
sender reject, and evict idle/oldest entries at a hard cap.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Stop Cashu-looking text from skipping long-message guards.
Oversized public content always collapses and takes the plain
formatting path so remote tokens cannot force layout/regex DoS.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Cap teleported geohash participant markers.
Bound the set with FIFO eviction, clear it on channel switch, and
prune markers that leave the visible participant list.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Bound untrusted Nostr relay frames and event tags.
Reject oversized inbound messages before JSON parse, cap tag
arrays/values at decode, and stop logging raw tag contents.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fail soft when Noise handshake state is unexpectedly missing.
Replace the initiator startHandshake force unwrap with a guard
that throws invalidState instead of crashing.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Cap geohash nickname cache from remote Nostr events.
FIFO-evict at capacity, clear on channel switch, and prune
nicknames that leave the visible participant list.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Avoid overlapping exclusive access in the rate limiter.
Make bucket helpers static so inout dictionary updates do not
conflict with a mutating call on self.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix rate-limiter tests for mutating allow under #expect.
Call allow outside the macro so Swift Testing does not capture an
immutable copy of the struct.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop unused WebSocket data helper; reset rate limiter on panic wipe.
dataWithinInboundLimit replaced the unbounded path, and panic clear
should not leave public intake buckets behind.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: use country-level resolution for low-precision geohashes (#887)
* fix: skip admin fallback when country exists but is duplicate
Prevents mixed labels like "United Kingdom and Scotland" for
single-country geohashes. Admin fallback now only triggers when
no country is available from the placemark.
* fix: migrate stale low-precision bookmark names so country-first logic applies
Users who bookmarked a <=2-char geohash before the country-first resolver
kept the old administrativeArea cache (e.g. "England" for `gc`) because
resolveBookmarkNameIfNeeded bails when bookmarkNames[gh] is non-nil. Add a
one-shot, versioned migration that drops cached names for <=2-char geohashes
on load; the next LocationChannelsSheet .onAppear re-resolves them via the
fixed logic. Higher-precision entries are untouched.
* Fix low-precision geohash country names
---------
Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Centralize PTT and voice audio-session ownership, harden courier/bridge/outbox delivery and recovery, correct location and delivery-state races, add privacy/release metadata, and ship reproducible universal Arti slices with Release CI coverage.
Validated by the full iOS suite, repeated audio/fragment/performance regressions, BitFoundation tests, strict lint and dead-code analysis, universal iOS Release builds, and iOS/macOS archives.
* Consolidate duplicate BLE links after restore; suppress duplicate fragment streams; onChange coalescing
Field evidence (two-phone test after BLE state-restoration relaunches):
both phones held 2-3 simultaneous same-role connections to each other —
one side received every packet from three distinct centrals all bound to
the same peer — so every PTT voice frame arrived 3x, and a 41KB voice
file went out as TWO complete independent 89-fragment streams (different
assembly ids), both fully reassembled and the duplicate only dropped at
the very end by messageID dedup. 2-3x airtime/battery on all traffic
plus doubled reassembly memory.
Root causes and fixes:
- Duplicate links stayed unbound forever: announces (the only packet
that binds a link to a peer) went through the per-peer duplicate-link
collapse, so a peer's second/third link never received the announce it
needed to become bound — and unbound links pass the collapse untouched,
so every broadcast sprayed down all of them. Direct announces now
bypass the collapse and reach every live link (relayed announces keep
it); once bound, the existing collapse dedups all traffic.
- Same-role link retirement: a verified direct announce now consolidates
our central-role connections to that peer — keep the link the announce
arrived on (or the most recently bound one), cancel other connected
links bound to the same peer. One connection per role per peer is the
normal dual-role topology; only same-role duplicates are touched, only
links already announce-bound are retired (never pre-announce links),
at most one retirement per peer per rebind-cooldown window, and the
peer keeps a live link either way. Directness stays forgeable (TTL is
unsigned), so a replay could nominate the survivor — bounded by the
cooldown and by DM routing's existing canDeliverSecurely gate. A
rotation rebind now also cancels other stale links still bound to the
rotated-away ID, so the ghost identity retires promptly.
- Deterministic preferred-link collapse: when several bound links to one
peer are candidates, collapse now keeps the peer's most recently bound
link (the reverse-mapped one) instead of dictionary order; links
without a discovered characteristic are excluded from fanout (they
cannot be written to, and could silently eat a peer's collapsed copy).
links(to:) now reports all bound peripheral links, and removing one
duplicate no longer clobbers the reverse map of the survivor.
- Duplicate fragment streams: a transferId-less resend (gossip-sync
replay, spool) of file content already being fragmented out to a
covering audience is dropped at the outbound scheduler (broadcast
covers everyone; directed covers its recipient). App-initiated sends
carry an explicit transferId the progress UI tracks and always run.
A peer that asks after the stream completes still gets a resend.
- didUnsubscribeFrom no longer flaps a peer that is still live on other
links (the far side retiring its duplicate arrives as an unsubscribe);
didDisconnectPeripheral bookkeeping is skipped for self-retired links
by removing the store entry before cancelling.
Also: guard the DeliveryStatus onChange state write in TextMessageView/
MediaMessageView (unconditional per-row writes under a message storm
tripped SwiftUI's "tried to update multiple times per frame" warning).
The restore-path bgRemaining=∞ log was already fixed on main (#1425
review follow-up 07b5ac31: init-time seed + sampler-routed restore
captures); verified, no change needed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Review fixes: multi-link disconnect guard, characteristic-aware retirement
Adversarial review of the duplicate-link PR (merge-with-nits):
- didDisconnectPeripheral gets the same multi-link guard as the
unsubscribe path: when a duplicate link drops naturally while the peer
stays live on another (dual-role central link, or a second bound link
during the post-restore consolidation window), peer-disconnect
bookkeeping (markDisconnected + disconnect notify) no longer runs — a
UI blip until the next announce re-marked the peer connected. The
reverse map was just repaired onto a connected survivor, so
directLinkState is the accurate probe. Scan restart and connect-slot
refill stay unguarded: they respond to the physical drop regardless of
remaining logical links. (No unit test: driving the CBCentralManager
delegate requires CBPeripheral instances, which cannot be constructed
in tests; the policy pieces backing the guard are covered.)
- Retirement is now characteristic-aware: the policy snapshot carries
characteristic presence, and keptPeripheralUUID selects anchors only
among writable links while any exist — consolidation must not keep a
link mid-service-rediscovery (didModifyServices cleared its
characteristic) and cancel the writable duplicate, stranding outbound
traffic on the central link until rediscovery finishes. When neither
anchor is writable but a writable duplicate exists, consolidation
defers to a later announce instead of guessing. The reverse-map
survivor repair in removePeripheral prefers writable links for the
same reason. Three new policy tests cover charless-vs-writable.
Deferred with code comments per review: central-side collapse keeps the
oldest subscription (no recency signal; remote consolidates within its
cooldown), and the extra preferred-bindings bleQueue hop in
sendOnAllLinks (fold into a combined snapshot if profiling flags it).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Fix restore-path main↔bleQueue deadlock and courier drop amplification
A device froze permanently in a two-phone test. Debugger stacks showed an
ABBA deadlock: the main actor was in bleQueue.sync (delivery-ack send →
broadcastPacket → readLinkState) while bleQueue was in main.sync
(captureBluetoothStatus reading backgroundTimeRemaining). The load that
lined the two edges up came from a courier-drop amplification storm:
drop dedup was in-memory only while the outbox driving 120s re-deposits
is persisted, so every relaunch republished the same undelivered DM as a
fresh 24h relay drop and every gateway relaunch re-fetched the whole
backlog — ~20 copies of one DM delivered in 40ms, each triggering
decrypt + delivery + ack + handshake work.
Fixes, in rank order:
- Edge B (P0): captureBluetoothStatus no longer main.syncs from bleQueue;
backgroundTimeRemaining is sampled on main and cached behind a lock.
Invariant documented: bleQueue must NEVER sync-dispatch to main.
- Edge A (P0, defense in depth): sendDeliveryAck / sendReadReceipt /
sendPrivateMessage / sendNoisePayload / triggerHandshake hop to
messageQueue like sendMessage, so no main-actor call path reaches
readLinkState's bleQueue.sync.
- Drop dedup (P1): publishedDropKeys and seenDropEventIDs persist across
relaunches (new BridgeDropDedupStore, entries expire with the 24h
NIP-40 drop window; wiped on panic) — one drop per message ID per 24h
regardless of relaunch count.
- Receiver dedup (P1): openCourierEnvelope dedups on the inner private
message ID before delivery, so a duplicate copy costs one decrypt and
never re-delivers, re-acks, or re-triggers a handshake.
- Handshake gating (P2): queued acks initiate a Noise handshake only for
reachable peers; mail from absent/rotated identities no longer turns
each copy into a mesh-wide handshake flood (the ack stays queued and
flushes when a session eventually establishes).
- Outbox (P3): re-enqueueing a queued message ID carries over its
depositedCourierKeys so resends stop re-burning the same courier slots.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Review fixes: offline-drop durability, gateway handoff retry, restore-log freshness, coalesced persist
Adversarial review of the storm/deadlock PR surfaced four issues:
- Offline blackhole (must-fix): a deposit made while relays were down
persisted its dedup key even though the drop only sat in the in-memory
pending queue — app killed before reconnect meant the relaunch lost the
drop but the persisted key blocked every re-deposit for 24h. The
persisted snapshot now excludes keys still pending; they become durable
only when flushPendingDrops actually publishes them.
- Gateway handoff: seen-event IDs were consumed before the deliverToPeer
handoff; a failed handoff (peer walked away) permanently dropped the
event for a single-gateway island. deliverToPeer now reports whether
the handoff was attempted, and a failure releases the seen slot so a
relaunch or backlog redelivery retries.
- Restore-path logs: central/peripheral-restore captures logged the init
sentinel bgRemaining=∞. The cache is now seeded in init's main-thread
branch and restore captures route through the sampler, which refreshes
the cached budget before logging.
- Persist cost: the dedup record was a full JSON encode + atomic write on
the main actor per mutation (once per event during a backlog re-fetch).
Writes now coalesce behind a 1s window, flushed immediately on
background/terminate; panic wipe stays immediate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Remove BoundedIDSet.remove, orphaned by the ExpiringIDSet migration
The drop-dedup sets that needed slot release moved to ExpiringIDSet;
remaining BoundedIDSet users only insert and check. Periphery caught it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>