- CommandProcessorTests still asserted the old "blocked … in geohash
chats" copy that this branch rewrote to the per-channel wording.
- The `mesh_peers.state.favorite` state constant was replaced by the
mutual/pending pair and left unused — Periphery (now blocking) flagged
it. Removed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Codex P1: backgrounding mid-authentication re-locked the app, but a
Face ID success that resolved just after could still clear the new
lock, bypassing the re-lock. Each lock bumps a generation and unlock
callbacks from a superseded cycle are ignored.
- Codex P2: the deliberate fail-open (device passcode removed) unlocked
but left privacy.appLockEnabled true, so the toggle kept claiming the
lock was on and re-adding a passcode silently reactivated it. The
fail-open path now turns the setting off, matching the promised
"turns itself off" behavior.
Stale-callback path pinned by a new test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Codex P1: the context-menu and VoiceOver "add favorite" actions
called onToggleFavorite directly, bypassing the one-time consent
dialog and disclosing the nostr key without asking. All add-favorite
entry points now route through requestFavoriteToggle (consent gate);
removals stay immediate.
- Codex P2: the empty star showed the "favorited — offline messaging
starts when they favorite you back" tooltip on peers that weren't
favorited at all. The pending/mutual copy now applies only once
isFavorite; an unfavorited star reads "add favorite".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Codex P1: bounded-single-line was not "exact locally generated
shape" — a self-attributed action could smuggle a preamble into the
target slot ("… hugs SECURITY: reset your keys at evil…"). The
target must now be a single name token ("you" or a whitespace-free
≤32-char nickname, optionally #abcd-suffixed); free text with spaces
degrades to a plain message.
- Codex P1: location-channel senders arrive suffixed (bob#ab12) while
handleEmote embeds the unsuffixed nickname, so the actor match
regressed every received geohash action to plain text. The actor is
now compared by base name (splitSuffix), restoring legit rendering.
Tests cover the suffixed-sender case, the suffixed target, and the
preamble spoof.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Codex P1: a background identity change appended the warning without
marking the chat unread — a security event nobody was looking at
stayed invisible until the conversation was manually opened. It now
sets the unread flag unless the chat is open.
- Codex P2: offline key rotation is the common case here, and
resolveNickname falls back to an anon prefix precisely then (no mesh
nickname, no social identity for the unverified new fingerprint).
The persisted favorite relationship's nickname is preferred.
Both pinned by tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Codex P1: the fixtures assumed the old default-ON preference — on a
clean CI process the coordinator guard drops their frames. Instead of
mutating the shared UserDefaults per test (which races parallel
suites), the live-voice reads are now injectable
(ChatLiveVoiceCoordinator.liveVoiceEnabled), the fixtures opt in per
instance, and the toggle-off test drives the provider directly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Codex P1: the manager-path withheld claim landed only in
PrivateChatManager.sentReadReceipts, while the lifecycle read pass
dedups against ChatViewModel's persisted set — enabling receipts
before the next lifecycle pass could send a receipt for a message
read while the setting was off. The withheld branch now records into
the owner's persisted set too (markReceiptHandled, wired in the
bootstrapper); test strengthened to require both sets.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The keychain is AfterFirstUnlock and private chats live in memory: an
unlocked, seized, or borrowed phone handed over everything, and the
panic wipe only helps while the phone is still in your hand. The
threat model's primary event had no defense.
- AppLockModel gates the whole UI: locked from the first frame when
enabled, re-locks when iOS backgrounds the app (macOS locks at
launch only — its windows resign focus constantly, and PrivacyScreen
already covers snapshots). The lock screen auto-triggers the system
prompt and keeps a manual retry button.
- Off by default; the settings toggle refuses to arm without a device
passcode. Deliberate fail-open: removing the passcode requires
knowing it, so a missing passcode means the owner chose that —
the lock disables itself rather than locking them out (stated in
the settings copy). Reset by panic wipe.
- NSFaceIDUsageDescription added. 5 strings x 30 locales.
- Model driven by injected providers; tests pin locked-at-launch,
unlock-only-on-success, re-lock, disabled-inert, and the setting's
default/reset.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The star read like a private bookmark but behaved like a friend
request plus an identity handshake: tapping it immediately notified
the peer ("alice favorited you") and transmitted the durable Nostr
public key — while the tapper saw nothing. And the load-bearing state,
mutuality (one-sided favorites do NOT enable offline delivery), was
invisible at the point of decision.
- One-time consent: the first favorite ever asks, naming the person
and disclosing the notification + key sharing (both star surfaces:
peer list and DM header). Acknowledged once; reset by panic wipe.
/fav (an explicit typed command) proceeds without a dialog.
- Mutuality visible where the decision happens: half star until
reciprocated, filled star when mutual, with tooltips and VoiceOver
state labels for both. The DM header state now carries
isMutualFavorite.
- FEATURES copy described favorites as notifications; it now leads
with the actual mechanism (offline messaging via nostr when mutual).
- Geohash block copy no longer implies a global block: identities are
derived per-geohash, so the same person appears as someone new in
other channels — the message says so now (both the context-menu and
/block paths).
7 new strings + 3 updated values, all 30 locales.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
processActionMessage rewrote the sender to "system" — the formatter's
trusted styling — for ANY `* … *` content containing 🫂, 🐟, or the
substring "took a screenshot". A hostile peer could render arbitrary
text as a system-authored line: `* SECURITY: your session key expired,
re-verify at evil.example — bob took a screenshot *`.
Only the exact locally-generatable action shapes qualify now, and the
actor slot must equal the actual wire sender: a peer can hug, slap, or
screenshot only as themselves, and the target slot is bounded,
single-line, and nickname-shaped. Same templates iOS and Android emit,
so legit actions render unchanged; anything else falls through as an
ordinary peer message under the sender's real name.
Pinned by tests covering the legit shapes, the original spoof payload,
actor-mismatch, and free-text-in-target-slot cases. No new strings.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
migrateNoiseKeyUpdate silently merged the DM thread onto the new key:
a peer who panic-wiped (or was replaced by whoever holds the nickname)
inherited the conversation's earned trust with no visible sign beyond
a debug log, and any earlier verification — bound to the OLD
fingerprint — vanished without a word. Signal treats this as a
full-width banner; bitchat treated it as bookkeeping.
The migration now appends a system line to the affected conversation:
"<name>'s identity key changed — this can mean a new device or a
reset. earlier verification no longer applies; verify them again
before trusting this chat." Only when there is a conversation to
protect (selected chat or migrated messages) — a favorite never
chatted with doesn't spawn a warning-only thread.
This is also the prerequisite UX for the peer-ID rotation project:
once rotation ships, identity changes become routine and MUST be
visible. 1 string x 30 locales; behavior pinned both ways by new
coordinator tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live push-to-talk defaulted ON in both directions: holding the mic in
a public channel streamed your voice to everyone in radio range before
you released, and inbound bursts auto-played out of the speaker. A
single buried toggle defaulting on was not consent — both behaviors
are now opt-in (PTTSettings.liveVoiceEnabled defaults false), and the
settings copy says exactly what turning it on does.
Recording could also not be aborted one-handed: release always sent,
and the HUD's cancel button required lifting the finger — which sent.
Sliding the finger off the mic button (>60pt) now arms cancel — the
HUD flips from the timer to "release to cancel" — and sliding back
re-arms send. The armed flag resets on every exit path (cancel,
finish, panic), pinned by a new test.
2 new strings + 1 updated, all 30 locales.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Read receipts were unconditional: on chat open and on didBecomeActive,
twice. A receipt is a presence oracle — it says the person is awake,
holding their phone, and opened the app at that exact moment, which is
precisely the metadata the stated threat model says someone should be
able to withhold. Every mainstream messenger ships this switch.
- ReadReceiptSettings (default ON = existing behavior), toggle in the
settings privacy section, reset on panic wipe.
- All four origination paths gated: mesh/nostr routing, direct mesh
receipts, geohash receipts, and PrivateChatManager's read pass.
Withheld receipts are still recorded locally as sent, so re-enabling
the setting never fires a retroactive burst disclosing past reads.
- Only outbound receipts are affected; receipts from others display.
- 2 strings x 30 locales; tests pin the default, the reset, and that
nothing reaches the transport while off.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Add 26 code-referenced keys to the catalog + a test that closes the gap
LocalizationCoverageTests validated the catalog but never the code: a
String(localized:) whose key is missing from every catalog compiles
fine and silently ships its English defaultValue to all 29 non-source
locales. That blind spot let 26 keys go untranslated while CI stayed
green: the entire notices/board composer (10), the private-media
encryption warnings and delivery-failure reasons (9), both
courier-header strings, two delivery states, two media failure
reasons, and the [? people] channel row.
- All 26 keys added with full 30-locale coverage.
- New everyCodeReferencedKeyExistsInACatalog scans the source for
literal String(localized:) keys and fails on any key absent from
both catalogs. (The audit's original count of 17 was itself an
undercount — its scan missed multi-line String( localized: calls;
the test's regex does not.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Localize CommandProcessor: all 48 command strings, 30 locales
CommandProcessor had zero String(localized:) calls — every command
error, usage hint, and the entire /help reference was hardcoded
English. The autocomplete panel (CommandInfo) is fully localized, so a
non-English speaker got localized suggestions while typing and English
the moment anything went wrong or they asked for help.
- 47 literal sites converted to String(localized:)/String(format:)
with positional specifiers where languages reorder (%1$@, %2$lld).
- /trace's "hop"/"hops" pluralization split into path_one/path_many;
the "you" chain label localized too.
- helpText and groupUsage become computed so they resolve per-locale.
- 49 command.* keys added with full 30-locale coverage (command
syntax stays verbatim; only prose translates).
- Copy fix from the audit: "blocked X. you will no longer receive
messages from them" → "…no longer see their messages" (blocking
filters at display time; packets still arrive and relay).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Localize the last hardcoded UI strings; wire up two dead translations
The stragglers from the unlocalized-strings audit:
- Nearby push notification: "bitchatters nearby!" title and the
hand-rolled English pluralization ("1 person around" / "N people
around") now localize via body_one/body_many.
- Voice recording errors (mic permission, start/too-short/save
failures) and the "Recording Error" alert title — also brought into
the lowercase house voice.
- The recording HUD's Cancel label now uses common.cancel.
- "No image selected" in both image pickers.
- The DM screenshot echo "you took a screenshot" (the wire-format
sibling stays English for Android compat).
- "Voice/Images are only available in mesh chats." had fully
translated catalog entries in all 30 locales that were never looked
up — the call sites passed the raw string instead of resolving the
key. Wired up via String(localized:).
10 new keys x 30 locales.
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>
* Add 26 code-referenced keys to the catalog + a test that closes the gap
LocalizationCoverageTests validated the catalog but never the code: a
String(localized:) whose key is missing from every catalog compiles
fine and silently ships its English defaultValue to all 29 non-source
locales. That blind spot let 26 keys go untranslated while CI stayed
green: the entire notices/board composer (10), the private-media
encryption warnings and delivery-failure reasons (9), both
courier-header strings, two delivery states, two media failure
reasons, and the [? people] channel row.
- All 26 keys added with full 30-locale coverage.
- New everyCodeReferencedKeyExistsInACatalog scans the source for
literal String(localized:) keys and fails on any key absent from
both catalogs. (The audit's original count of 17 was itself an
undercount — its scan missed multi-line String( localized: calls;
the test's regex does not.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Localize CommandProcessor: all 48 command strings, 30 locales
CommandProcessor had zero String(localized:) calls — every command
error, usage hint, and the entire /help reference was hardcoded
English. The autocomplete panel (CommandInfo) is fully localized, so a
non-English speaker got localized suggestions while typing and English
the moment anything went wrong or they asked for help.
- 47 literal sites converted to String(localized:)/String(format:)
with positional specifiers where languages reorder (%1$@, %2$lld).
- /trace's "hop"/"hops" pluralization split into path_one/path_many;
the "you" chain label localized too.
- helpText and groupUsage become computed so they resolve per-locale.
- 49 command.* keys added with full 30-locale coverage (command
syntax stays verbatim; only prose translates).
- Copy fix from the audit: "blocked X. you will no longer receive
messages from them" → "…no longer see their messages" (blocking
filters at display time; packets still arrive and relay).
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>
LocalizationCoverageTests validated the catalog but never the code: a
String(localized:) whose key is missing from every catalog compiles
fine and silently ships its English defaultValue to all 29 non-source
locales. That blind spot let 26 keys go untranslated while CI stayed
green: the entire notices/board composer (10), the private-media
encryption warnings and delivery-failure reasons (9), both
courier-header strings, two delivery states, two media failure
reasons, and the [? people] channel row.
- All 26 keys added with full 30-locale coverage.
- New everyCodeReferencedKeyExistsInACatalog scans the source for
literal String(localized:) keys and fails on any key absent from
both catalogs. (The audit's original count of 17 was itself an
undercount — its scan missed multi-line String( localized: calls;
the test's regex does not.)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Deflake AppArchitectureTests: raise the local waitUntil to settleTimeout
The file-local waitUntil defaulted to 3s — below the documented
minimumSettleTimeout floor — and the recent-chats geo-dedup wait
missed it on a loaded runner right after merge (the Combine hop
through receive(on: .main) was starved). Every caller waits for a
condition to become true, so green runs return immediately and never
pay the 30s deadline.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fix the real geo-dedup race: re-assert tracker state on each poll
The 30s deadline didn't help — the recent-chats geo-dedup wait wasn't
slow, it was permanently broken under parallel swift-test load: the
view model's own channel binding delivers its initial .mesh selection
asynchronously and resets the active participant geohash when it lands
(GeohashSubscriptionManager → setActiveParticipantGeohash(nil)),
wiping the test's setup so the dedup could never happen. Reproduced
locally with `swift test --parallel`; standalone runs never hit it.
The wait now re-asserts setActiveGeohash + recordParticipant on every
poll (both idempotent) — the same self-healing pattern the geohash
timeline test already uses for singleton interference. Interference
heals on the next poll; a genuine dedup failure still times out.
6× full parallel runs green.
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>
* Add a persistent connectivity banner and stop the radar lying
Two "the app looks fine while it isn't" problems from the UX audit:
- Bluetooth-off had exactly one signal: a dismissible alert. Once
dismissed, the app looked completely normal — empty timeline, radar
sweeping "searching for people" — while the radio was off. The radar
animation now requires a radio that can actually scan, and a
persistent red banner under the header carries the state instead:
off (tap → settings), denied (tap → settings), or unsupported
(mesh unavailable; location channels still work).
- A Tor bootstrap stall was only ever announced inside geohash
timelines (addGeohashOnlySystemMessage), so someone sitting in #mesh
or a DM whose messages route over Nostr got silence and hanging
"sent" states. The stall now also drives a chrome-level torBlocked
flag and the same banner: "tor can't connect — internet features are
paused. mesh still works."
The banner renders nothing when everything is healthy, and .unknown/
.resetting radio states deliberately stay quiet — a false "bluetooth is
off" flash at launch would be the same kind of lie. Priority and
quiet-start behavior are pinned by ConnectivityIssueTests. 4 new
strings, all 30 locales.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Review fixes: banner in the sheet, real fix targets, tor-clear tests
- The people/DM sheet covers the root header, so the connectivity
banner vanished exactly where people sit longest. It now mirrors
into the sheet via a top safe-area inset over both the people list
and the DM view.
- "tap to fix" for powered-off Bluetooth opened the macOS privacy
permission pane, where an already-authorized person can't fix
anything. New SystemSettings.bluetoothPower routes to the Bluetooth
pane; denied still goes to the privacy anchor. The one-shot alert's
settings button routes by state the same way.
- Tests pin that torBlocked clears on tor-ready and on preference
change (no stale "tor can't connect" after toggling tor off).
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>
* Make the panic wipe confirmable and its outcome visible
The triple-tap panic wipe fired instantly with no confirmation, on the
same 18pt logo whose single tap opens App Info — while the far less
destructive triple-tap-to-clear-chat 40pt below it asks first, and the
Settings-pane panic button has always confirmed. A fumbled tap on the
app's only help affordance could permanently destroy identity, keys,
and messages.
- Triple-tap now raises the same confirmation dialog the Settings pane
uses (one extra tap under duress; zero accidental wipes).
- A successful wipe leaves a "all data wiped — new identity created"
system message: in duress, "did it work?" must not be a guess — the
natural response to uncertainty is tapping again.
- A wipe that does not commit (panicRecoveryBlocked) was previously
invisible: the app looked wiped, sat silently offline, and keys could
still be on disk. It now shows a persistent red banner under the
header saying data may remain and a relaunch retries.
- Settings/privacy copy no longer says "instantly" (all 30 locales).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Review fixes: dismiss sheets on wipe; host the dialog durably
- Codex P1: the App Info danger-zone wipe left its sheet presented, so
the failed-wipe banner (and the success message) rendered underneath
an opaque modal. panicClearAllData now dismisses all chrome-owned
presentation first; pinned by a new architecture test.
- Review nit: the confirmation dialog moved from the logo Text to the
ContentView header stack, so a covered/removed header can't take a
pending dialog down with 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>
* Add a recent-chats section so DM conversations can't become unreachable
The largest IA hole from the design/UX audit: a direct conversation
with someone who went offline (and isn't a mutual favorite) had no row
anywhere in the UI. The roster filters to connected/reachable/mutual-
favorite, the header envelope only exists while unread, and /msg can't
resolve offline strangers — read a DM from a passerby, close the
sheet, and the thread was still in memory with no way back to it.
- PeerListModel now derives RecentChatRow entries from the
ConversationStore's direct conversations: people absent from the
rosters above, newest activity first, unread flag, deduplicated by
fingerprint across ephemeral/stable peer-ID mirrors (newest wins),
groups and blocked peers excluded.
- A "chats" section (same header shape as #mesh/groups) renders them
in the people sheet on both mesh and location channels — geoDMs from
a channel since left stay reachable too. Tapping reopens the DM via
the existing startConversation path; the section renders nothing
when empty.
- Rebuilds are driven by direct-conversation store changes only, so
public-timeline traffic doesn't churn the sheet.
2 new strings, all 30 locales. New architecture test pins that an
offline stranger's thread gets a row while group threads don't.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Review fixes: geo dedup and geoDM names in recent chats
- A person still visible in the geohash roster could also get a recent
chat row (the builder only subtracted mesh rows). Visible geohash
pubkeys now join the identity set via their PeerID(nostr_:) form, so
the absent-from-rosters contract holds on location channels too.
- GeoDM rows went through resolveNickname, which only consults mesh
identity sources and rendered nostr_… keys as an opaque anon
fallback. They now resolve through geohashDisplayName (Nostr key
mapping, bare-key fallback).
Both pinned in the recent-chats architecture test.
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>
* Remove the public-channel screenshot broadcast
Taking a screenshot used to announce "* nickname took a screenshot *"
to the entire active public channel — a mesh broadcast, or on geohash
channels a mined ephemeral event published to the 5 nearest Nostr
relays, permanently timestamping that this nickname was present and
active at that place. Documenting something (or someone) is a core use
of a protest app; it must not out the person doing it.
- Public channels (mesh and geohash) no longer send anything on
screenshot. The mined-event helper is deleted and the lifecycle
context trimmed of its now-unused requirements.
- A geohash timeline screenshot instead raises the existing local
location-privacy warning alert (same one the channel sheet uses) —
warn the person, tell no one.
- DM screenshot notices remain, and the local "you took a screenshot"
echo is now honest: it only appears when the notice actually went to
the peer (it used to render even when no established session existed
and nothing was sent).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Review fixes: pin the screenshot routing table
The geohash warn path lived only in AppRuntime with no coverage. The
decision is now a pure nonisolated table
(AppRuntime.resolveScreenshotResponse) pinned by tests: location sheet
or geohash timeline → local privacy alert (nothing sent), App Info →
nothing, mesh/DM → chat layer (where public channels stay silent).
Mesh deliberately gets no local alert: a mesh screenshot reveals no
place and triggers no send, so there is nothing actionable to warn
about — alerting on every screenshot would train people to dismiss the
one alert that matters. Rationale now documented on the response enum.
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>
* Stop claiming encryption and privacy properties that aren't live
Two of the app's most safety-critical signals could show more security
than actually existed:
1. getEncryptionStatus was sticky-optimistic: any peer whose fingerprint
was ever persisted resolved to noiseSecured/noiseVerified even when
the live Noise session was .none, .handshaking, or .failed. After a
cold launch or a handshake failure the DM header showed a solid lock
and the composer caption claimed "end-to-end encrypted" with no
secure session in existence. The status now reflects only the live
session; a remembered fingerprint decides what an established
session upgrades to (verified vs secured), nothing more. Peers
reachable only over Nostr keep the encrypted caption — gift-wrapped
delivery is end-to-end without a Noise session.
2. Privacy copy told three lies (all 30 locales fixed):
- "ephemeral identity — new peer ID generated regularly": the peer
ID is stable until a panic wipe (rotation is spec-only). Now
"local-only identity — your identity is a keypair created on this
device; panic wipe replaces it instantly."
- "no servers, accounts, or data collection": location channels and
internet delivery go through Nostr relays. Now "no accounts, no
phone numbers, no data collection."
- location channels: "your IP address is hidden by routing all
traffic over tor" — tor is a toggle. The description now says tor
is the default, and the sheet shows the existing red tor-off
warning at the point of joining whenever the toggle is off.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Review fixes: gate nostr availability on a stored recipient key
- Codex P2 / review: a mutual favorite with a nil peerNostrPublicKey
still reported .nostrAvailable, so the DM caption could claim
"end-to-end encrypted" with neither a Noise session nor a usable
Nostr key. BitchatPeer.connectionState (and the header fallback in
PrivateConversationModel) now require a stored recipient key —
mirroring NostrTransport's reachability rule. Regression test:
mutual + nil key → .offline; key present → .nostrAvailable.
- Copy: "local-only identity" → "device-local identity" (all 30
locales) — once a fingerprint or QR is shared it isn't "only" local;
device-local says where the keys live without over-claiming.
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>
* Deflake gift-wrap tests: settle deadlines, not latency budgets
The gift-wrap round-trip tests failed three CI runs this week
(handleGiftWrap_privateMessageStoresConversationAndMapping,
handleGiftWrap_deliveredAckUpdatesExistingMessage,
handleGiftWrap_routesEmbeddedPrivateMessageAndDeduplicates), each with
the same signature: the async NIP-17 unwrap missed a 5s wait on a
loaded runner. 40 local iterations of both suites pass clean — the
failures are scheduler starvation, exactly the class
TestConstants.settleTimeout documents.
Every positive wait in ChatViewModelExtensionsTests and
ChatNostrCoordinatorContextTests now uses settleTimeout (30s): the
explicit 5.0s literals on the gift-wrap waits, the longTimeout media
waits, and the bare-default channel-switch waits. All are
expected-true waits, so passing runs return immediately and never pay
the deadline.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Deflake SimulatedMesh TTL budget: settle discovery before the baseline
publicMessageRelaysAcrossLineTopologyWithinTTLBudget snapshotted its
frame baseline after a single 2s advance, but discovery is not quiet
by then: every first-seen peer schedules an afterglow re-announce at a
random 0.3-0.6s delay (BLEAnnounceHandler), and each of those can
cascade another relay round. Whether that traffic lands before or
after the snapshot depends on the draw — CI measured the "single
message" at 14 and 18 frames against a budget of 12.
The test now advances until the mesh goes a full window with no new
frames before taking the baseline, so the budget only ever measures
the message under test. 30 local iterations green.
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>
The job shipped with its own promotion condition — "drop
continue-on-error once the baseline proves stable" — and the baseline
has now been stable across a month of merges since it landed July 8
(#1410). Known macOS-scan false positives stay suppressed by the
committed baseline; --strict already fails the step on NEW dead code,
so the only change is that the failure now blocks instead of annotating.
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Two re-assessment findings:
- verifyScannedQR checked only staleness (now - ts > maxAge), so a
future-dated timestamp bought a QR a longer validity window than a
fresh one. The freshness check is now symmetric.
- Inbound Nostr DMs had no client-side created_at validation; the age
bound relied entirely on relays honoring the subscription's `since`
filter. Both gift-wrap decrypt paths now drop rumors outside
[now - lookback - skew, now + skew]. The inner rumor timestamp is the
sender's true send time (only the outer gift wrap is randomized per
NIP-17), so the window mirrors exactly what an honest relay already
guarantees — a dishonest relay can no longer inject stale or
future-dated DMs. Existing mitigations (persistent gift-wrap dedup,
relay-side since) are unchanged; this closes the malicious-relay gap.
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Make SwiftLint blocking in CI now that the violation backlog is zero
The lint job has been advisory since #1361, with the backlog tracked in
#1088. That backlog is now down to 28 violations, so this clears it and
flips the job to enforcing instead of adding baseline machinery:
- Fix all remaining violations: closure parameters/capture lists joined
onto their opening-brace line (BLEService, NoiseSessionManager,
ChatMediaTransferCoordinator, BLENoisePacketHandlerTests), one
trailing comma, one opening-brace spacing (both via swiftlint --fix).
- Move the two periphery:ignore comments above the doc blocks they were
orphaning; verified with a periphery --strict scan that the commands
still register (no new findings vs baseline).
- Disable the todo rule: the single TODO cites tracked issue #1434 and
is a deliberate marker, not lint debt.
- Exclude .device-lab from local runs (untracked; CI never sees it).
- CI: drop continue-on-error and the "(advisory)" label; run with
--strict so any new warning fails the job.
Full SwiftPM suite green (2,004 tests / 216 suites) after the
formatting changes; swiftlint lint --strict exits 0 with the same
0.65.0 the CI container pins.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fix is_disjoint violation introduced by #1542
The strict lint gate caught this on its first run: the mention-suggestion
key handler used modifierFlags.intersection([...]).isEmpty, which is
exactly the pattern isDisjoint(with:) expresses directly.
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>
Two hardening fixes from the repo evaluation:
- NoiseSessionManager.authenticatedRemoteKey returned true for peer IDs
that are neither 16-hex wire IDs nor full Noise-key IDs — an
accept-any-key fallback kept for test harnesses. It now fails closed;
the Noise/integration/E2E tests that relied on it address peers by
key-derived wire IDs instead (the pattern NoiseCoverageTests already
used), and a new regression test pins the rejection.
- Four SecRandomCopyBytes call sites discarded the return status. The
two verification nonces now fail their operation on error, the Nostr
device identity seed uses CryptoKit key generation (cannot fail, and
can no longer silently persist an all-zero seed), and BIP-340 aux
randomness throws on failure like the adjacent nonce path.
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Tab and arrow keys for mention autocomplete
Highlight the selected suggestion and let Tab accept it. Up/down move
the selection when the mention panel is open; Tab otherwise still
cycles focus.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Retrigger CI after flaky GeoRelayDirectory iOS test
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: navigate mention suggestions with the same macOS key monitor as commands
Arrow keys never reach SwiftUI while the composer field editor has
focus, so adopt the NSEvent local monitor from #1504. Align accept
keys (Return or Tab), add Escape to dismiss, and match highlight opacity.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix dead key monitor: gate on live state, not a value-captured Bool
A synthetic-event harness against the extracted modifier showed the
realistic path broken: the panel is hidden when the composer appears, so
onChange(of: isActive) ran on the previous render's modifier value and
installed a monitor whose closure had captured isActive == false — it
passed every key through forever. Arrows/Tab/Escape never worked on a
real Mac.
Install the monitor once for the view's lifetime and gate each event on
an isActive closure that reads the reference-typed model live. Same fix
applies to the iOS onKeyPress guards for consistency. Harness now passes
all paths including deactivate/reactivate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat: show verification seal on private message sender rows
DM-only filled seal next to verified peers' names closes the remaining
UI gap from #1439 without dressing public mesh timelines.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: skip in-string verified ✓ on private rows with SF Symbol seal
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add verified_sender catalog entry (30 locales) + live seal repaint on verify
The accessibility label existed only in code, so VoiceOver read english
in 29 locales; the coverage test can't see source-only keys. Also forward
peerIdentityStore.$verifiedFingerprints into objectWillChange so toggling
verification repaints rows in an open DM instead of waiting for the next
unrelated invalidation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat: share location channel invites via the system share sheet
Adds text-first geohash invites (deep link + App Store URL) from
channel rows and the active-channel header, with an OpSec warning
for neighborhood-or-finer cells.
Closes#1497
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: add Localizable.xcstrings entries for channel share copy
Cover all six new share/done keys across the 30-locale catalog so
LocalizationCoverageTests and non-English builds stop falling back
to English defaults.
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore: retrigger CI after unrelated VoiceRecorder flake
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat: let users set a local alias on the fingerprint sheet
Wire the existing localPetname field to a write path so peers can be
renamed locally; read paths already prefer the alias when present.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: make local aliases visible and localize the fingerprint field
Give local petnames display precedence over announced nicknames across
peer rows, DM headers, and resolveNickname; rebuild peer state after a
save so lists update immediately. Load the alias draft when the
fingerprint arrives, and add the three local-alias strings across all
30 locales.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: use macOS 13-compatible onChange for fingerprint alias sync
The two-parameter onChange API requires macOS 14; match the rest of the
views so release and Periphery builds stay green on the 13.0 deployment target.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
The channels sheet grows a "quick join" row: one tap into the region-level
geohash channel around the device region's main population center — the
same path as typing the geohash and teleporting, no location access used.
Review of the first cut (a curated "heavily censored countries" roster)
called out that a hand-picked list invites inclusion disputes, goes stale
with politics, and is App Store / geopolitical exposure. The suggestion now
derives from the device locale under one neutral rule for every country:
ISO region → the 2-char geohash cell of the main population center (the
largest metro, not always the capital: US → New York, TR → Istanbul).
219 regions covered; the twelve cells the earlier roster shipped were
independently verified in review and reproduce unchanged.
The caption is deliberately blunt, per the same review: the cell belongs
to the main population center — it is not "your country's channel" and
not your location; the channel is public and well-known, so assume it is
watched; quick join is discovery — it hides nothing and bypasses nothing.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
MacImagePickerView used English string literals, so it skipped the string catalog and localization coverage. Move the user-facing strings into Localizable.xcstrings for all supported locales.
Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
* Add macOS camera QR scanning for peer verification.
Mac verification previously only supported paste/validate. Reuse the same AVCapture metadata pipeline as iOS, keep paste as a fallback, and extend the scanner smoke test to macOS.
* Grant sandboxed macOS camera access for QR scanning.
NSCameraUsageDescription alone is not enough under App Sandbox. Add com.apple.security.device.camera so the new macOS AVCapture QR path can open the camera after user permission.
* Check camera auth before capture input for QR scanning.
Consult AVCaptureDevice.authorizationStatus before creating AVCaptureDeviceInput so smoke tests and cold launches do not trigger TCC prompts, gate the smoke test on prior authorization, and show a one-line hint when the camera is unavailable.
* docs: specify peer ID rotation for cross-platform review
Draft protocol spec for review by both iOS and Android before any
implementation. Nothing here is implemented; this is the artifact to
agree on, since the change is a wire revision neither platform can ship
alone.
The headline correction, because it is easy to get wrong: rotating the
peer ID alone accomplishes nothing. The announce carries the Noise static
key, the Ed25519 signing key and the nickname in cleartext, so a rotated
ID is re-linked to the same device on its first announce. Rotation and
announce confidentiality have to land together.
The second thing an implementer needs to know up front is that
peerID == SHA-256(noiseStaticKey)[0..8] is not a convention, it is the
mechanism that makes peer IDs unforgeable, enforced in the announce
preflight and again at handshake completion. Making IDs independent of
the key fails both checks for every peer, so a replacement binding has to
ship in the same change. The spec proposes one: an Ed25519 proof over
(context, epoch, rotating ID, static key) carried inside the completed
Noise session via the existing AuthenticatedPeerStatePacket, checked
against a pinned signing key — strictly stronger than today's
self-signed announce.
Design summary: hour-epoch IDs derived from private key material via
HKDF+HMAC so no observer can predict or link them; pairwise recognition
tags from the X25519 shared secret so mutual favourites still recognise
each other with no handshake, padded to fixed slots so the tag count does
not leak how many favourites someone has; strangers discovered by
handshake-first-identify-second over Noise XX, whose static keys are
already encrypted on the wire. Nickname moves inside the session and the
neighbour list is dropped rather than rotated.
Includes a verified impact inventory separating what breaks hard (the
handshake check, the announce preflight, the disk outbox keyed by peer ID,
private-media stable IDs and their deletion tombstones, the initiator
tie-break, fingerprint-prefix lookups) from what degrades gracefully and
what is already safe because it keys on fingerprints or Noise keys.
Rollout uses the two mechanisms already proven in this repo: a
PeerCapabilities bit (11 is next; 10 is burned) with
capabilitiesWereExplicitlyAdvertised to tell an old client from a new one
with the bit off, and observed-version gating as used for source routing.
Two findings surfaced while writing this and are recorded in the spec.
CourierEnvelope.recipientTag is HMAC keyed on the recipient's *public*
static key, and since that key is broadcast in cleartext today, any
observer in radio range can compute a peer's courier tags for any day —
so the whitepaper's "cannot link it across days" does not currently hold,
and the pattern must not be copied. And NoiseEncryptionService's
buildAnnounceSignature/verifyAnnounceSignature/canonicalAnnounceBytes are
present but production-dead, called only from tests; the binding above
deliberately uses a different context string so the two can never be
confused.
Eight open questions are left explicitly unresolved, including the
rotation period, whether unsigned v2 announces are an acceptable posture,
and whether Android's decoder tolerates trailing bytes the way iOS's does
(which decides whether padding coverage can ship ungated).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Implement the peer ID rotation primitives
Code is a better thing to argue with than prose, so the spec now has a
working, tested base under it. Every number and context string is a
concrete proposal you can reject by changing one function and watching a
test vector move.
What is implemented:
- PeerIDRotation: hour epochs with a ±1 matching window, the rotation
secret from the Noise static *private* key, per-epoch peer IDs, pairwise
recognition keys and tags from an X25519 shared secret, the fixed-width
tag block with CSPRNG padding and constant-time matching, and the
canonical bytes for the identity binding.
- AnnounceV2Packet (announceV2 = 0x05): TLV wire format carrying an epoch,
a 64-byte tag block, capabilities and an optional bridge cell — and
nothing else. No nickname, no public keys, no neighbour list. Rejects a
wrong-width tag block on both encode and decode, since a short block
would disclose how many mutual favourites someone has, and rejects
non-canonical capability encodings the way AuthenticatedPeerStatePacket
does. Unknown TLVs are skipped for forward compatibility.
- 37 tests, three of which are hex vectors cross-checked against an
independent implementation written from the spec alone (Python
hmac/hashlib, HKDF extract-then-expand, empty salt) and matching byte
for byte. That is the property Android needs: the document is sufficient
to reproduce the numbers without reading this code.
What is deliberately NOT implemented: nothing emits a v2 announce, and
BLEService parses the type and explicitly ignores it. Consuming presence
needs both the replacement identity binding and a decision on how
unverified presence appears in the peer list, and accepting it now would
put unauthenticated entries in front of people.
Adding the message type forced three policy decisions, all reviewable:
- Not gossip-synced. Syncing presence would defeat the point — a device
never in radio range could collect tag blocks, turning a local beacon
into a network-wide one.
- Not padded. At ~75 bytes the smallest bucket would triple the airtime of
the most frequent packet in the protocol; the format is already
near-constant width, and fixing the capability and geohash field widths
would be cheaper than padding.
- Parsed but ignored on receive, as above.
Notably the v2 announce is *smaller* than v1 (~75 vs ~229 bytes): dropping
two 32-byte keys, the neighbour list and the signature more than pays for
64 bytes of tags, so unlinkability here costs less airtime rather than
more.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Mark the rotation primitives periphery:ignore
The dead-code scan correctly flagged both new types as unused, which they
intentionally are: they exist to be reviewed and argued with before the
protocol change they belong to can ship.
Annotated in place rather than added to .periphery.baseline.json so the
reason sits next to the code and disappears with it, following the
existing convention in MessageRouter. Both notes say to delete the
annotation once the mesh starts using the type.
`periphery scan --strict` locally: no unused code detected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Fix two P1 flaws in the recognition tag design (Codex #1487)
Both findings are correct and both were real. This is the argument for
shipping code next to the prose: neither was obvious in the design text.
**Tags were symmetric, which leaked the social graph.** `HMAC(K_AB, epoch)`
produces the same 8 bytes for both parties, so an observer who saw one
value in two different announces would learn those two devices are mutual
favourites, and could link their two rotating IDs to each other — handing
over exactly the graph the design exists to hide, plus a cross-epoch
correlation handle. Tags are now directional: the MAC covers the ordered
sender and recipient static public keys, so A→B and B→A differ. Both
parties can still compute both directions because both hold both keys.
**Tags were replayable under any ID.** A tag depending only on
(pair, epoch) could be lifted from a recorded announce and replayed in a
fresh announce under an attacker-chosen ID; the recipient would match and
treat that ID as the favourite, and since epoch-1 is accepted it would
keep working into the next period. The MAC now covers the announced peer
ID, which reduces this to replaying the victim's own presence.
That residual is unfixable while announces are unsigned, so the spec now
states plainly that recognition is a hint only: presence may be populated,
but routing a DM or showing a verified badge must wait for a handshake
whose static key equals the favourite that produced the match. O4 is
rewritten around that, with the two alternatives named (per-epoch
ephemeral signing key, or a freshness nonce echoed by the recipient).
Tests: two regression cases named for the findings, plus a
wrong-direction-does-not-match case so the directional fix cannot silently
become cosmetic. The vector table now gives both directions, because their
difference is the security property — an implementation that produces one
value for both has reintroduced the flaw. Recomputed independently in
Python from the spec and matched byte for byte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: record that padding changes are cross-platform coordinated
O7 began as a question about whether Android tolerates trailing bytes.
The firmer answer, found while attempting the padding fix unilaterally:
toBinaryDataForSigning encodes with padding enabled, so the padding bytes
are inside the signed material for every signed packet. Changing the
algorithm changes the signed byte stream and breaks verification against
any peer that has not made the identical change.
So both outstanding padding fixes — coverage beyond Noise frames, and the
gap where a frame needing over 255 bytes of padding ships unpadded — are
wire changes requiring both platforms, not local cleanups. O7 now says so,
and names the two things to settle.
Also updates the related-work section: dropping the neighbour list and
randomizing origin TTL did turn out to be unilateral and have landed
separately.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Close the review findings on the rotation spec
**P1 — the binding proof was replayable onto another session.** The §4.5
verifier checklist omitted the check that the proof's noiseStaticPublicKey
equals the remote static key the Noise session actually established. The
proof is a self-contained signed blob with nothing tying it to the session
it arrives on, so a peer M that had seen A's proof could replay it verbatim
inside M's own session with B; B would verify A's signature, see a
well-formed binding, and on first contact TOFU-pin A's signing key against
M's fingerprint. Added as the first item in the checklist, with the attack
written out, because "signed" and "bound to this conversation" are
different properties and the difference is easy to lose in a bullet list.
**announceV2 is 0x2C, not 0x05.** 0x05 only looks free. It has been
recycled twice — announce, then bulkTransferResponse, then fragmentStart
until #446 — so an old peer could still map it to a fragment header and
misparse presence as a partial message. Values above voiceFrame = 0x29
have only ever been allocated forward, and 0x2A/0x2B belong to the courier
spray-ack work, leaving 0x2C. Confirmed never used anywhere in this
repository's history.
**Outbound priority is now stated, not inherited.** announceV2 fell through
to `default: .high`. High is the right answer — presence is small,
time-bounded to its epoch and useless once stale — but for a type nothing
emits yet, a fall-through means the choice gets made without anyone seeing
it.
**Reverted unexplained pbxproj churn.** Xcode had rewritten resource-phase
ordering and dropped a share-extension entitlements membership exception;
none of it belongs in this PR. The file now matches main byte for byte.
**O7 said "payloads" where the arithmetic is over encoded frames.** The
241-256 / 497-768 / 1009-1792 ranges are what `pad` receives, which is the
whole encoded packet, not the payload alone.
**Added O9: a seized device recomputes every past peer ID.** K_rot is
long-lived, so peerID_e is computable for any epoch by whoever holds it —
someone who seizes a phone, or pulls the static key from a backup, can go
back over historical radio captures and identify which were this device.
Rotation defends against the passive observer, not against later key
compromise. A hash ratchet would give forward secrecy for the ID stream at
the cost of state that must survive restarts, tolerate clock jumps, and
resynchronise after a gap — a real trade rather than an obvious win, so it
is written down as a question rather than silently adopted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: rotation capability bit is 14 now — 11-13 claimed by in-flight work
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
All four surfaced in the 1.7.1 RC window and are behavior-neutral:
- sendPacket(to:) discarded sendPacketDirected's Bool through generic
onEngine, tripping unused-result (from #1547's engine-domain flip).
- Both _test_drain*Pipeline helpers captured non-Sendable self in
@Sendable dispatch closures; they only need the queue, which is
Sendable — capture that instead.
- removeEphemeralSession returned removeValue's result out of the
barrier closure, tripping unused-result on sync(flags:execute:).
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
waitUntilActivationBegan hardcoded a 5-second DispatchSemaphore timeout
— below the 10s house floor and invisible to TestTimingHygieneTests
(it's a semaphore wait, not a helper-timeout parameter). On a starved
runner the window expired before the recorder's session-acquire task
was scheduled, failing cancelWhileSessionAcquireIsInFlightNeverCreates-
ARecorder — 7 sightings, including three in the last two days (#1506
and #1528 merge runs, #1550's PR run).
The fix is extracted verbatim from #1107 (mmalmi), which carries it but
is blocked on a V3 rebase: both test gates (activation and padding)
drop their DispatchSemaphore + timeout for an untimed async-event wait
(VoiceRecorderAsyncEvent), so there is no timing constant left to
starve — the test framework's own timeout is the backstop. Extracting
it unblocks CI now; #1107's rebase will see this file already matching
its branch.
Verified (count-checked via xcresulttool): 7/7 VoiceRecorderTests on
the iOS simulator, and 7/7 x 5 consecutive runs under 16x CPU
oversubscription.
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
`BitchatFilePacket.decode` skips tags it does not recognise
(`case nil: continue`), which is what keeps the TLV list a floor rather
than a ceiling: a field the sender considered optional costs the receiver
that field, not the whole file.
Nothing pinned it. The behaviour is load-bearing for any peer, version or
third-party client that adds a field this build has not seen, and it is
also where the two implementations diverge — the Android decoder returns
null on an unknown tag, which is why `PrivateMediaMessageIdentity` has to
derive its receipt key from fields already on the wire instead of adding
one. Worth a test on the side that gets it right so it cannot quietly
drift into the strict behaviour.
Two cases, both hand-built so they do not depend on our own encoder:
an unknown TLV between MIME_TYPE and CONTENT (where an encoder appending
content last would put it), and one trailing CONTENT. Changing
`case nil: continue` to `return nil` fails both.
Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
Media payload writes used .atomic alone and inherited the container
default; the courier store, outbox, gossip archive, and receipt index
all state their protection class at the write site. Media now follows
the same convention: until-first-user-authentication on payload writes
and on every site that creates a media directory (the store's helpers,
live captures, the outgoing writers, and the files/ root creators), so
recordings that save as they go inherit it. A best-effort launch
migration stamps files written by older builds, applying only to items
at the container default or weaker so it can never downgrade, running
detached after the retention sweep from #1484. On stock devices the
container default already yields this class, so behavior does not
change; the protection is now stated in the code instead of inherited.
Full iOS suite green; macOS builds; swiftlint adds no violations.
Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
* fix: re-inject environment objects into the people sheet
Sheets hosting a NavigationStack can drop inherited EnvironmentObjects on
some iOS versions, crashing ContentPeopleListView / MessageListView (#1558).
Co-authored-by: Cursor <cursoragent@cursor.com>
* test: note people-sheet environment contract in smoke mount
Make the #1558 regression visible next to the ContentView / people-sheet
smoke mounts so a future env-object trim is harder to miss.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
* Cohere per-link Noise auth and rebind containment into BLELinkAuthState
The authenticated-link owners, the reconnect revalidation policy, and
the two rebind-containment cooldowns were four loose bleQueue-owned
maps whose invariants lived in call-site discipline: every teardown
path had to remember to retire the proof AND close the revalidation
epoch (the pair appeared seven times), and both cooldowns hand-rolled
the same prune-check-record dance. BLELinkAuthState owns them as whole
transitions — retireLink, retireLinks(ownedBy:), permitRebind,
permitRedundantRetirement — with the ownership question (bleQueue
today, engine after the option-B flip) answered in one place.
No behavior change; the one call-site reordering (redundant retirement
computes the survivor before the cooldown check instead of after) is
outcome-equivalent since the cooldown only ever recorded when a
survivor existed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Split identity-link bindings out of the physical link store
BLELinkStateStore owned two different kinds of truth: what physical
links exist (CB handles, connect lifecycles, characteristics, stream
assemblers) and who each link belongs to (peer bindings in both roles
plus the preferred-peripheral reverse map for directed sends and fanout
collapse). The bindings now live on BLELinkBindings — same bleQueue
ownership, whole-transition methods, direct tests for the rotation
reverse-map cleanup and the preferred-link survivor repair that were
previously only exercised end to end. Composed operations that need
both truths (remove-with-repair, direct link state, the subscribed-
central snapshot, bind-only-live-links) live on the transport as
explicitly bleQueue-confined helpers.
This is the structural half of the option-B boundary flip
(docs/BLE-ARCHITECTURE-V3.md): ownership of the bindings can now move
to the engine without touching what-links-exist. An audit of every
physical clear/remove found three sites (emergency clear, both
unauthorized branches) that needed explicit binding-clear pairing under
the split — each now clears both.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fix iOS-gated constructors and preserve containment cooldowns on reset
CI caught what the macOS SwiftPM build cannot see: two #if os(iOS)
sites still passed the peerID field that slice B1 removed from
BLEPeripheralLinkState (willRestoreState in BLEService and
armPendingBackgroundConnects in BLERadioController). Both fixed and
verified with a local iOS simulator xcodebuild.
Codex also caught a real regression: BLELinkAuthState.removeAll()
cleared the rebind/retirement cooldown maps, which the original panic
and emergency reset paths deliberately left alive. A stable
CoreBluetooth UUID must not earn a fresh rebind allowance just because
the session state around it was wiped. removeAll() now clears only the
proofs and revalidation epochs, and BLELinkAuthStateTests pins the
survival invariant along with the other auth-state transitions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Link layer slice 3: the option-B domain flip — bindings and link-auth move to the engine
The identity domain (BLELinkBindings + BLELinkAuthState) is now owned
by the engine queue, with a DEBUG dispatchPrecondition trapping any
access from another queue. bleQueue keeps only physical link state.
What changed shape:
- Receive path is sans-I/O: bleQueue decodes frames and hands
(packet, linkID) up through ingestDecodedPacket (panic lifecycle
captured at the handoff); attributeAndHandlePacket resolves the
sender binding, rejects spoofed senders, applies raw-announce
binding, and records ingress on the engine. Per-link frame order is
preserved end to end (both queues serial), which supersedes the old
batch-local TOCTOU binding in the notification path.
- The rotation rebind is one engine slot: containment checks, proof
retirement, binding flip, reconnect decision, and rotated-identity
retirement run straight-line; only CoreBluetooth cancels hop to
bleQueue. The engine->bleQueue->engine ping-pong is gone, along with
the _test_afterVerifiedDirectRebindEnqueued pause hook — the test
that used it now asserts the atomicity directly (a paused engine
wedged the old gate design into a three-queue deadlock).
- Authenticated-send eligibility (notifyOrEnqueueIfAccepted,
writeOrEnqueueIfAccepted) is checked on the engine, serialized
against rebinds by construction; only physical admission
(updateValue/write/backpressure) runs on bleQueue.
- Teardown splits into discardPeripheralLinkPhysical (bleQueue, inline
in the delegates) + retirePeripheralLinkIdentity (engine hop with
survivor repair reading liveness via readLinkState). A binding can
briefly outlive its physical link; liveness queries join against the
physical store and the queued retirement converges the two.
- Gossip delegate sends enter the engine via onEngine — safe because
mesh.sync sits above the engine in the sync order (production engine
code only async-dispatches into the manager).
- checkPeerConnectivity rides an engine slot from the bleQueue
maintenance tick.
No wire changes. 1,974 tests green (parallel and serial), iOS
simulator build clean, Periphery clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Link layer slice 4: deterministic multi-node mesh simulation — and the panic-announce bug it caught
SimulatedMesh wires real CoreBluetooth-free BLEService engines
edge-to-edge through the outbound packet tap and _test_ingestFrame
(the production attribution path the B2 flip created), with per-edge
synthetic link IDs and manual-scheduler time. Five multi-node tests
run in ~40ms with no wall-clock waits:
- announce exchange binds simulated links and connects peers
- Noise sessions establish end-to-end (real crypto, both directions)
- a public message relays across a line topology inside a TTL/frame
budget (storm bound asserted)
- an 8x duplicate flood delivers exactly once
- a panic rotation rebinds the survivor's link exactly once and stays
— the scenario that previously needed two phones and log archaeology
Fidelity boundary (documented in the harness): no physical links, so
fanout planning and backpressure are not exercised; attribution,
binding, dedup, TTL, relay decisions, and sessions are the real
engine code.
The simulator found a real bug on its first run: the forced-announce
throttle's lastSent survived a panic, so a rotation within
bleForceAnnounceMinIntervalSeconds of the last announce silently
swallowed the new identity's announce — leaving it invisible to the
mesh until the next maintenance cycle. Today's device test only
passed because the previous announce happened to be minutes old.
BLEAnnounceThrottle gains reset(), called from the panic slot so the
rotated identity owes no throttle debt; pinned by a unit test and the
mesh rotation test.
New DEBUG seams: _test_ingestFrame (production ingress attribution),
_test_forceAnnounce, _test_fenceEngine.
1,980 tests green, Periphery clean, iOS simulator build clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Link layer slice 5: name the port — BLELinkEvent, one engine entry, delegates in their own files
The upward half of the link-layer port is now a type. BLELinkEvent
enumerates everything the bleQueue link layer tells the engine:
frameDecoded plus the four physical lifecycle transitions
(peripheralLinkEnded, centralLinkEnded, allPeripheralLinksEnded,
allCentralLinksEnded). Every bleQueue→engine crossing goes through
emitLinkEvent into one engine consumer (handleLinkEvent) — the
scattered messageQueue.async identity hops in the delegates collapse
into event emission, and the engine-side retirement/bookkeeping logic
now lives in one switch.
The CoreBluetooth delegate extensions move to their own files as
physical bookkeeping plus event emission:
- BLEService+LinkLayerCentralRole.swift (CBCentralManagerDelegate +
CBPeripheralDelegate)
- BLEService+LinkLayerPeripheralRole.swift (CBPeripheralManagerDelegate
+ write accumulation)
BLEService.swift drops from 7,836 to ~7,100 lines. The physical-domain
members the role files share flip private→internal; the queue contract
is enforced by the existing DEBUG traps and grep guards, not access
control. (Two of the flips — isAppActive, logBluetoothStatus — only
surfaced on the iOS build; macOS SwiftPM cannot see #if os(iOS) code.
Verified with a local iOS simulator build.)
The simulated mesh now drives lifecycle events through the identical
enum a radio does: linkDropEventRetiresBindingAndReconnectHeals covers
drop → identity retirement → last-link peer bookkeeping → re-announce
heal, entirely through the port. New seam _test_resetAnnounceThrottle
models elapsed wall-clock for the throttle (deliberately separate from
_test_forceAnnounce so the panic-rotation test keeps its regression
value: the production panic path must do its own reset). The panic
test's containment re-announces reset throttles explicitly so those
assertions exercise real delivered announces instead of silently
throttled ones. noiseSessionEstablishesEndToEnd gains a bounded
scheduler-time settle loop after a one-in-many parallel-suite flake
(no wall-clock waits).
Deliberately not done (recorded in docs/BLE-ARCHITECTURE-V3.md): a
formal handle(event)->[Effect] system and further engine-domain file
splits — both would flip the engine's private state to internal for
cosmetic file counts; the effect formalization rides future feature-
module extractions instead.
1,981 tests green, Periphery clean, iOS simulator build clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Baseline logBluetoothStatus for the macOS Periphery scan
Its callers are all inside #if os(iOS) (willRestoreState in both role
files plus the app-state handlers), so the macOS-scheme scan sees the
now-internal declaration with zero callers — the same class as the
baselined candidateCount. Verified 1-USR diff; the previously private
mangled variant was already baselined, which is why the pre-split scan
never flagged it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fix#1538: release stale bindings on rotation instead of leaving a ghost
With two live links to one phone, a panic rotation healed only the link
the verified announce arrived on. The second link kept its binding to
the retired identity, so that dead ID stayed in the peer list — and was
kept alive by the NEW identity's own traffic, since a bound link
attributes non-announce frames to its bound peer. It only healed when
the stale link physically dropped.
The issue proposed exempting the containment rule via retiredBy[X] = Y
so the second link could rebind. Two problems: the exemption's stated
precondition (X removed by retireRotatedPeer) can never hold in this
scenario — the retire is gated on X having no remaining links, which is
false precisely because the stale link exists — and it would loosen a
security rule to fix a liveness bug.
Instead the rotation now RELEASES every link still bound to the
rotated-away identity (unbind + retire that link's Noise proof) and
retires the identity. No containment rule changes: unbinding is
strictly less trusting than any binding, and it is correct under both
readings of a second link bound to the retired ID — same physical
device (the field case), or one link is a spoofer holding a forged
binding, since a peer ID is a Noise-key fingerprint and two devices
cannot both legitimately own it. Released links reconverge through the
ordinary unbound-link path: the next raw direct announce binds them to
whoever they actually carry.
Reproduced and fixed under the slice-4 simulator, which is why this
lands as tests rather than another two-phone session:
- duplicateLinkPanicRotationLeavesNoGhostAndHealsBothLinks fails
without the fix (ghost in both knownPeers and getConnectedPeers,
duplicate link still bound to the dead ID)
- replayedVerifiedAnnounceCannotStealALinkOrEvictTheVictim pins the
#1401 containment rule against exactly the attack this fix had to
avoid re-opening, with a positive control proving the refusal is the
containment check and not duplicate suppression
Harness gains connectDuplicateLinks (two links to one peer, modelled in
the central role — the links we cannot cancel, and the only role whose
bindings a CB-free harness can form), silence (range loss without a
link event, so a packet can be captured that the far side never saw),
and emittedPackets (the attacker's capture buffer).
Residual, documented at the fix: an attacker who binds their own link
to X by replaying X's raw announce can drive a rebind there and so
evict X's registry entry; X's next announce restores it, and the
per-link rebind cooldown bounds the rate. This is the same class of
capability the containment already accepts, not a new one.
1,983 tests green, Periphery clean, iOS simulator build clean.
Closes#1538
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fix the retire↔reconnect oscillation: redundant-link survivor is the newest connection
Field-observed July 31 on main: with a restored old-address link and a
fresh-address duplicate to the same phone, redundant-link consolidation
kept choosing the restored link as survivor (it carried the announce
ingress and the binding) and cancelling the fresh one — which the radio
promptly rediscovered and reconnected, because the fresh link sits on
the BLE address the peer still advertises. Retire, reconnect, repeat at
the retirement cooldown (~1/min) until the ingress happened to flip.
Battery and airtime noise on every restore-with-duplicates.
BLERedundantLinkPolicy now prefers the most recently CONNECTED
candidate. Only the newest connection lives on the currently advertised
address; the older-address link cannot return once cancelled, so
consolidation converges on the first pass. BLEPeripheralLinkState gains
lastConnectedAt (set by markConnected; nil for restored links, whose
connect predates the process — exactly the 'stale address' signal).
Security note: physical connect recency is a signal an announce replay
cannot nominate, unlike the previous ingress-link preference — the
announce anchors (ingress, then most recently bound) are demoted to
tie-breakers and the fallback for all-restored links. Writability still
trumps everything: a newest link mid-service-rediscovery is never kept
over a writable duplicate. Containment (bound-links-only, one
retirement per peer per cooldown, peer keeps a live link) unchanged.
Six policy tests pin the new order, including the field scenario
(restored link holding both announce anchors loses to the fresh
connection) and the legacy fallback.
1,988 tests green, Periphery clean, iOS simulator build clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Defer consolidation while the newest connection is still mid-discovery
Codex P2 on #1566: a fresh duplicate that has connected but not yet
finished service discovery was excluded from the writable candidate
set, so the policy kept the older writable (restored) link and
cancelled the freshly advertised connection — recreating the
retire↔reconnect oscillation inside the discovery window. Now, when
the physically newest connection is not writable yet while a writable
duplicate exists, consolidation defers to a later announce instead of
guessing. Also documents that RSSI is deliberately not a policy input
(Chessing234's rule-pinning ask) and pins the defer window, the
restored-anchor variant, the co-newest writable tie, and the
all-unwritable recency path with 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>
SimulatedMesh.addNode installed the outbound tap one statement after
setNickname, but setNickname force-announces asynchronously on the
engine. When a starved runner let that slot run inside the gap, the
announce was emitted invisibly while still stamping the wall-clock
announce throttle, and announceAll's forced announce — arriving well
inside the 0.15s forced minimum interval — was swallowed. No discovery
traffic ever reached the mesh, so bindings stayed nil and peer lists
empty: the exact 4-issue signature that failed three main runs and one
PR run on July 30.
Reproduced deterministically by forcing the ordering with a 5ms sleep
after setNickname: all 8 SimulatedMesh tests fail on the old harness
and pass on the fixed one.
Fixes: install the tap before setNickname so an early nickname announce
is captured instead of lost; reset each node's throttle in announceAll
so wall-clock throttle debt can never swallow the discovery round
(forceAnnounce(from:) deliberately keeps no-reset — the panic-rotation
tests pin the production reset behavior through it); and take the lock
around addNode's array appends, which could race the tap reading
`emitted` on an earlier node's engine.
Verified: suite green normally, 8/8 tests x 6 runs under 16x CPU
oversubscription, and 8/8 under the adversarial forced ordering —
all count-verified via xcresulttool (an earlier single-test
-only-testing filter silently matched zero tests, so every result
here was re-checked against reported test counts).
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
immediateLegacyRestartDuringCompletionGrace injected a 0.03s initiator
completion grace period and needed the restart initiation to arrive
inside it. Constructing the restarted service (keypair generation) sits
between starting that clock and processing the message, so on a starved
CI runner the window expired first, the initiation was processed as a
legitimate fresh handshake, and the nil-expectations cascaded — the
most-sighted flake in CI (7 runs across #1502, #1477, #883, #1364, and
main).
The test now injects a grace period no test run can outlive, so the
in-grace suppression and the duplicate-initiation coalescing are decided
deterministically, and fires the deferred recovery through a DEBUG hook
on NoiseSessionManager instead of waiting out the real timer. The hook
cancels the scheduled work item before requesting recovery, so the
converged-once assertion cannot double-fire either.
Verified (count-checked via xcresulttool): the full 30-test
NoiseEncryptionServiceTests suite green on the iOS simulator, and
30/30 x 5 consecutive runs under 16x CPU oversubscription (a 6th run
was lost to a simulator app-launch refusal under load — no tests
executed). The old test did not reproduce locally in 2 suite runs
under the same load; the starvation needs the slow 2-core CI runner,
so the diagnosis rests on the mechanism plus the identical assertion
signature in all seven CI sightings.
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
reservePendingStarts() decremented availableSlots for every dequeued pending
transfer before checking whether it would actually be admitted. A request
blocked because its transferId is already active (a resend of in-flight
content sitting at the front of the queue) still consumed a slot even
though it was deferred back into the queue rather than started -- so a
single blocked front-of-queue item could zero out the budget and end the
loop before ever reaching a later, unrelated, genuinely startable pending
transfer. That transfer then sat starved until some other transfer
happened to complete and trigger another pass, rather than starting
immediately when real capacity was already available.
Move the decrement to the point where a transfer is actually admitted into
activeTransfers, so only genuine starts spend the budget.