* Drop letterless string catalog keys so swift build works on Xcode 27
* Test that every manual catalog key can become a Swift symbol
* Silence Xcode 27's new import and capture warnings
* Drop the try markers Xcode 27 flags as unnecessary in tests
* Drop the try on the ECDH call that stopped throwing in #1782
* Tag the group hashtag with the app language and format the header count
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In dark mode the people sheet showed a dark-gray band over the
home-indicator inset, with the list rows scrolling across it. The App
Info and fingerprint sheets had the same band, and App Info also had a
gray strip under its navigation bar.
The gray is the sheet's own background: systemBackground at the elevated
level, which is #1C1C1E in dark mode. Since #1337 the Matrix backdrop is a
bare Color inside ThemedRootBackground. Passed to .background(_:) as a
view, it stops at the safe area, where the old Color background (a
ShapeStyle) extended into it. The glass gradient already ignores the
safe area, so give the Matrix branch the same modifier.
In light mode the band is white on white, which is why it only showed in
dark mode. It is not the iOS 26 scroll edge effect: hiding that effect
leaves the band pixel-identical.
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Tell my own messages apart by sender ID, not nickname
* Mute mention and hug alerts only for my own messages, by sender ID
* Keep my geo-DM rows mine when merging threads; sync receipts by ID
* Drop my memoized geochat sender ID on the switch to #mesh
* Keep my geo-DM threads out of a namesake's chat
* Cover my own rows from the real send paths
* Wipe the view model's own location manager on panic
* Keep my geo-DM threads out of a namesake's chat on migration
* Send through the composer's entry points in the send-path tests
* Say the restamp in consolidateMessages is defense in depth
* Pin that a thread with my replies stays out of a namesake's chat
* Judge my rows by sender ID when migrating a chat by nickname
* Count only the peer's rows as evidence in a geo-DM thread
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Gate geochat scaling regressions in the performance floor check
* Base the geochat format floor on its first CI run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Remove the public batching flag that never suppressed animations
* Stop republishing unchanged composer state to every message row
* Scan message rows once and cache media row headers
* Keep media rows to mesh senders and single-component filenames
* Keep the message list where the reader left it
* Retry the scroll to the bottom only when it did not land
* Republish media capability only on change; keep bridged text as text
* Land timeline switches in one change and shrink the window at the bottom
* Follow only a message of mine that landed since the mark
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Stop rebuilding the geochat roster on every inbound event
* Keep geochat names and counts current after the roster change
* Rebuild mesh rows from every peer snapshot
* Mark teleport only for activity that counts as presence
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Make the geohash people list linear in the participant count
* Keep GeohashPeopleList's equality nonisolated
* Give the palette tests their own location manager
* Keep the palette key's fields out of Periphery's assign-only check
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixespermissionlesstech/bitchat#1755 via 0.24.0 because the latest release drops the SharedSourcesPlugin build tool that failed Xcode 27 plug-in validation and compiles under the Swift 6.4 toolchain.
Co-authored-by: csjones <637026+csjones@users.noreply.github.com>
* feat: add ComposerDraftStore for per-conversation drafts
Keep unfinished composer text keyed by mesh, geohash, or DM peer so
channel switches stop silently discarding what someone was typing.
* feat: restore composer drafts across channel and DM switches
Save on leave and background, reload on enter, clear after send, and
wipe drafts during panic clear.
* test: cover ComposerDraftStore isolation, truncation, and reset
Pinned cases for mesh/geo/DM keys and the panic-wipe clear path.
* test: assert composer draft map stays within maxDraftCount
Newest keys win when the store is already full so prefs cannot grow without bound.
* fix: import BitFoundation so ComposerDraftStore sees PeerID
iOS target build failed without the module import that SPM resolved
transitively but Xcode did not. Also keep the eviction test inside the
test suite type.
* fix: clear in-memory composer draft on panic wipe
ComposerDraftStore.reset() alone left messageText in ContentView, so the
next background transition could persist the pre-wipe draft again.
* Address Jack review: keep composer drafts in memory only (#1569).
* ci: retrigger after unrelated SimulatedMesh flake
* ci: retrigger checks after simulator flake
* test: serialize ComposerDraftStoreTests
The in-memory draft map is process-global; parallel tests were racing
replaceAllForTesting and under-counting during eviction checks.
* Document why rotating the service UUID does not work
PEER-ID-ROTATION.md §2 lists "hiding that bitchat is in use" as a non-goal
and says the service UUID is "tracked separately." Nothing tracked it, so
the idea keeps coming back without its constraints. This is that document.
The conclusion is negative and worth writing down: hiding that bitchat is
running is not achievable for an open-source app that supports discovery
between strangers. Two independent reasons, either sufficient. iOS requires
background scanners to name the exact UUIDs they want, so any value a
stranger can pre-compute to find us, an adversary running the same source
derives identically — stranger discovery (G4) and invisibility to a
code-reading adversary are mutually exclusive in one advertisement. And
mechanically, third-party iOS apps cannot control advertisement contents at
all, cannot change advertised services while backgrounded, and have their
UUID collapsed into a 128-bit overflow bitmask in the background anyway —
so the real exposure is foreground, where iOS permits rotation least.
Also recorded: every comparable deployment (Exposure Notification, Find My,
Fast Pair, Eddystone-EID) and every comparable mesh app (Bridgefy, Berty,
qaul, Meshtastic) fixes the identifier and rotates the payload, if anything;
Briar is the only structurally different answer and it gets there by not
broadcasting. What survives any UUID change, ranked by how cheaply it can be
used — GATT connect-and-probe is decisive at ~3.7s/device on a Raspberry Pi,
and is invariant under rotation because app detection is a lookup, not a
discrimination problem. Three ways rotation would make things actively worse
(shrunken anonymity set, the restoration stale-identifier trap, anti-tracking
standardisation surfacing us as an "unknown tracker"). And what is actually
available instead, with peer-ID rotation ranked first because it targets the
far larger exposure: identifying a specific person, not merely a bitchat user.
Two load-bearing claims are undocumented by Apple and flagged for device
verification rather than asserted (§6). RF-fingerprinting is deliberately
described as a targeted tool rather than a dragnet; an earlier draft
overstated it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Correct the foreground-rotation reading; drop the DULT claim
Section 2.2 closed by saying rotation's value would sit where iOS
permits it least, which reverses the section's own argument. The only
mechanical constraint it cites on changing the UUID applies while
backgrounded; the foreground is precisely where CoreBluetooth allows
stopping advertising, swapping the service and starting again, a cycle
BLEService+LinkLayerPeripheralRole already runs. Retitled from
"forecloses" to "constrains", rewrote the closing passage, and softened
the "two independent reasons" framing, since independence was the point
of the objection. The conclusion is unchanged: foreground rotation
defeats a stale-list scanner but not an adversary running the public
derivation, and connect-and-probe identifies us regardless.
Section 5's third downside claimed anti-tracking standardisation might
flag us as an unknown tracker. DULT scopes to small location-tracking
accessories identified by a specific service-data UUID, and platform
alerts cover conformant tags rather than arbitrary advertisers; the spec
also mandates address rotation for those accessories, which makes
rotation a requirement of it rather than a trigger for it. Dropped, and
the section is now two ways. Nothing else referenced it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Validate relay-directory changes in PR CI
The weekly automation validates a relay snapshot before it pushes its
branch, but that check lives inside the scheduled job. A change to the
reviewed relay directory that arrives as a pull request is validated by
nothing at all, which is the ground two relay-data PRs were closed on:
"PR CI doesn't run the validator, and untraceable relay-directory
updates aren't something we can merge on faith" (#1632, also #1673).
This runs the validator on a pull request touching relays/ or the
validator itself, against the base branch's reviewed copy, and publishes
the row count, unique-relay count and SHA-256 to the job summary. The
hash is the point: the relay automation already publishes one for every
snapshot it proposes, so a reviewer can now match a proposed file
against it instead of taking provenance on faith.
The gate deliberately runs the base branch's validator rather than the
pull request's copy of it. Otherwise a change that edits the data and
the validator together certifies itself -- dropping 86% of the directory
passes if the same commit relaxes MIN_BASELINE_FRACTION, and the summary
still reports "validated". The scheduled job validates from a main
checkout for the same reason. The pull request's own validator is still
exercised by the unit-test step.
A pull_request run executes the workflow definition from the pull
request's own head, so a pull request can always edit this file and
change its own gate. That path is visible in the diff, which is what
review is for; what the reviewed-validator gate closes is the quiet one.
Read-only and never pull_request_target: the job runs PR-authored code,
so it holds no write scope and no secrets. checkout is @v5 like the
other read-only PR workflows rather than SHA-pinned, since that pin
exists to protect the write-capable token in fetch_georelays.yml.
What it catches: malformed rows, out-of-range coordinates, insecure
hosts, conflicting duplicate relays, and snapshots that lose half or
double the baseline's relays. What it does not: a re-ordered file whose
rows are each valid still passes -- with a different hash, which the
summary makes visible.
The accompanying test asserts the job's security-relevant shape as text,
following the precedent in test_fetch_georelays_workflow.py: the
trigger, the absent write scope, --output never pointed at the tracked
CSV, and that the gate reads the validator from the base branch.
* Split the gate from the code under review
Codex review on d627b9f found three holes, all confirmed by rehearsal.
The gate ran the base branch's validator, but read the candidate from the
worktree and ran the pull request's unit tests first. A test file that
rewrites relays/online_relays_gps.csv at import time therefore decided
what the gate validated: the summary reported 441 rows and the hash of a
file the pull request never proposed, exit 0, while the 60-row CSV in the
commit was what would merge. The published hash is the whole argument for
reviewing relay data on evidence, so a hash that can describe different
bytes than the ones merging is worse than no hash at all.
The workflow is now two jobs with an explicit split:
- gate is the trust boundary. It checks out the base commit, so nothing
the pull request wrote is present to run, and reads the candidate as a
git object pinned to the event's head SHA instead of from a worktree.
Both inputs come from the event, so a base branch that advances while
the job is queued cannot change the verdict.
- proposed-validator runs pull-request code, so it is not a trust
boundary. It runs the unit tests and, new here, the proposed validator
against the real directory. Without that a validator change is only
ever exercised against small synthetic fixtures: capping MAX_ROWS at
400 passes the unit tests and the gate, then breaks the weekly
automation after merge with "CSV exceeds 400 data rows".
Checking out the base commit also removes last round's git show of the
validator -- at base.sha the reviewed validator is simply the one in the
workspace.
Rehearsed per case, with each branch's diff verified first: a malicious
PR that swaps the CSV from a test now exits 1 where it previously passed
green; the current automation snapshot still validates to the hash
published in #1617; a corrupt CSV, a stricter validator, and a CI-only
change all land where they should.
* Add golden test vectors for the courier wire format
The courier layer is only implementable from the Swift source today, and three
of the ways a second client gets it wrong fail silently — it builds, connects,
and delivers nothing, with no error at either end:
- `expiry` is milliseconds. Seconds makes every envelope read as long expired,
so it is dropped at deposit with nothing logged.
- The signature does not cover the wire bytes. It covers a re-encoding with
ttl zeroed, isRSR cleared and the signature omitted, which is then PKCS#7
padded — a spray receipt is 46 bytes unsigned and unpadded, but 256 bytes
signed. Signing the 46 produces a signature that never verifies.
- The signing key is Ed25519. CryptoKit spells it Curve25519.Signing, so
reaching for a "Curve25519" primitive elsewhere yields X25519 key agreement.
Prose cannot defend against that class of bug; a fixture can. These vectors
pin the envelope TLV, the copies clamp, recipient-tag derivation, the
ciphertext hash, and the padded signing pre-image, and they are asserted from
the implementation so they cannot drift without a test failing.
Signature bytes are deliberately not pinned: CryptoKit's Ed25519 signing is
randomized rather than the deterministic RFC 8032 construction, so two
signatures over identical input differ and both verify. A second
implementation will not reproduce these bytes and does not need to — the
pre-image is what must match.
docs/courier-test-vectors.json carries the same values for implementers who
are not running Swift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Courier vectors: use a real deposit frame and pin every byte
Both changes address the Codex review on #1472.
The signing vector used type 0x2a, which is not a MessageType on main — it is
the spray-receipt opcode from a separate unmerged branch, so the frame decoded
to nothing and was misleading as an end-to-end courier vector. It now encodes
an actual courierEnvelope deposit: type 0x04 carrying the encoded envelope
from the TLV vector as its payload. 14 header + 8 sender + 8 recipient + 74
envelope = 104 unsigned and unpadded, 256 signed, 168 on the wire. The
envelope is under the 100-byte compression threshold, so the compressed path
stays out of it.
The pre-image assertions checked only length, the first three bytes, the flags
byte and the padding suffix. A change to timestamp encoding, payload length or
field layout could have left the test green while the JSON went stale — which
made the claim that these cannot drift without a test failing untrue as
written. Both the unpadded and padded sequences are now compared in full.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Courier vectors: pin the pre-image in the signature test too
The signature test signed whatever pre-image it was handed and verified it,
which is self-consistent by construction — it would have passed over a wrong
canonicalization. Mutating an input (timestamp +1ms) proved it: the
comparison test failed and this one did not.
It now pins the pre-image before signing, so both tests fail on any change to
field layout, timestamp encoding or payload length.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Courier vectors: drive the tests from the published JSON
Addresses both asks in the #1472 review.
The tests duplicated every vector as a Swift literal, so a format change that
updated only the Swift side would have left docs/courier-test-vectors.json
silently stale — the document was published as authoritative but nothing
checked it. Each test now reads the file and asserts against what it says.
The NoiseTestVectors.json precedent does not transfer directly: that fixture
lives inside its test target and loads from the test bundle, whereas this one
lives at repo-root docs/, outside the package, so SwiftPM cannot carry it as a
resource. It resolves the path from #filePath instead, the way
bitchatTests/LocalizationCoverageTests already reaches the repo root. CI runs
this package with the full tree checked out, and the target is not in the
xcodeproj, so there is no bundle-only run to break.
Two things beyond a mechanical swap:
- The published `label` for the tag HMAC is now load-bearing. The
implementation's context string is private, so the test recomputes the tag
from the label in the file and compares — otherwise the document could name
the wrong label and nothing would notice.
- A dedicated test fails loudly if the file is missing or has lost a key,
rather than letting a rename skip every assertion while the suite reports
green.
Also fixes the stale prose. The review caught "the finished 110-byte wire
packet"; the same deleted spray-receipt example had left three more wrong
numbers in packetSigning._comment — the padding worked example read 46 + 16 =
62 with shortfall 210 = 0xd2, where the real frame is 104 + 16 = 120 with
shortfall 152 = 0x98.
Proven by mutation, not by green: flipping a byte in envelopeTLV.encoded, a
byte in signingPreimage, and the HMAC label each fail the matching test, and
removing the file fails all seven.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Courier vectors: fail the length checks before indexing the bytes
Cross-model review (agy) on the previous commit.
The length assertions were #expect, so a frame of the wrong size reported the
mismatch and then kept going into `unpadded[2]` and the flags-offset reads.
Data subscripting past the end traps, so the process would die and take the
rest of the run's results with it — the failure mode where you learn least at
the moment you need to learn most. They are #require now.
Also marks the ciphertextHash vector as the weak one it is. Its derivation has
no consumer on main: the function that reads it arrives with the courier-spray
work, so the truncation is spelled out in the test rather than called, and a
future helper that truncates differently would not fail here. Said plainly in
the test rather than left for the next reader to discover.
Verified by mutation: declaring the wrong unsignedUnpaddedLength now fails both
signing tests with a reported expectation instead of a crash.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Courier vectors: move the ciphertext-hash vector to where it can be checked
The remaining piece of the spray-receipt example this PR already removed once.
`ciphertextHash` documents the envelope identity a spray receipt carries, and
nothing on main carries one — `CourierStore.ciphertextHash` arrives with the
courier-spray branch. So the test could not call the function the vector
describes; it re-spelled the truncation instead, which would have passed just
as green against a helper that truncated differently. A vector nobody can check
is worse than no vector, because it reads as verified.
It moves to the spray PR, where the production function exists and the
assertion can go through it.
Everything left in the file is now asserted against code that runs on main.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Courier vectors: decode the two keys nothing was reading
Cross-model review (codex) on the previous commits.
`packet.payloadIs` and `signature.verify` were the only non-comment keys the
Decodable shapes did not declare, so either could be renamed or deleted from the
published file and every test stayed green — the exact staleness this PR exists
to prevent, surviving in the two fields that tell a second implementer what to
feed the verifier.
Both are declared now, and `payloadIs` is checked rather than merely present:
it states a byte count, and that count is asserted against the real encoded
payload. Prose that states a number is a claim like any other.
Verified by mutation: renaming either key fails decoding, and stating 73 bytes
where the payload is 74 fails the length assertion alone.
Codex also read the pre-fix diff and re-reported the unchecked Data indexing
already fixed two commits ago, and claimed Ed25519 signing is deterministic so
the non-determinism assertion should fail. The latter is wrong for CryptoKit
specifically: `Curve25519.Signing` randomizes rather than following RFC 8032's
deterministic construction, which the test asserts and the suite confirms on
every run. No change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(l10n): report file and line for an untranslated key
The catalog guard named only the file a missing key was written in, so
finding it in a 900-line view meant grepping. Collect each literal
`String(localized:)` reference with its repo-relative path and line, and
print one `path:line key` per line instead of a comma-joined blob.
* test(l10n): check each target against the catalog it bundles
The guard accepted a key found in either catalog. The share extension
bundles only its own, so a key it references that lives solely in the app
catalog ships English there just as surely as one that exists nowhere -
which is the gap that let the share-extension strings drift.
Check `bitchat/` against the app catalog and `bitchatShareExtension/`
against the extension catalog, and say in the failure which of the two
cases it is.
* test: check shared sources against the extension catalog too
bitchat/Services/SharedContentHandoff.swift and bitchat/Services/
TransportConfig.swift are compiled into bitchatShareExtension as well
(project.pbxproj membership exceptions), so a key they reference has to
resolve in both catalogs -- each target bundles only its own.
The exception list is read from project.pbxproj rather than hardcoded,
so the check follows the set when it changes.
* Compile mention autocomplete regex through SafeRegex.
try? NSRegularExpression silently disabled @-mentions on compile failure; SafeRegex logs and returns a never-matching pattern instead, matching MessageFormattingEngine.
* Route mention autocomplete through shared SafeRegex pattern.
Add MessageFormattingEngine.Patterns.mentionAutocomplete and use it from AutocompleteService so mention matching compiles through the same fail-closed helper as message formatting.
messageTimes/lastMessages kept an entry for every geohash ever seen —
prune() only emptied a geohash's timestamp array, never dropped the
dictionary entry itself. roaming across many regions over a long
session leaked one entry per distinct geohash for the life of the
process. sweep other geohashes on each recorded message and drop the
ones that have fully aged out.
* fix: reject a signed byte pair in Data(hexString:)
UInt8(_:radix:) accepts a leading sign, so the two-character chunks this
parser feeds it let "+f" through as 0x0f. The doc comment already promises nil
for a string containing invalid hex characters, and it did not deliver that.
The cost is a second spelling for every identity-bearing hex string the app
parses -- Noise keys, Nostr pubkeys, fingerprints, signatures, routing IDs.
A 64-character peer ID of "+b" pairs passed PeerID.isValid, reported isHex
false and isNoiseKeyHex true at the same time, and resolved to the same
32-byte key and the same short routing ID as the genuine peer it shadowed.
Require two hex digits before parsing the pair.
* test: cover hex parsing and the PeerID validity it backs
DataHexTests covers what the parser accepts (plain, uppercase, 0x-prefixed,
whitespace-padded, every byte value round-tripped) alongside what it must
refuse, including the signed pairs that used to alias a genuine key.
PeerIDTests gains the 64-character negatives it was missing -- the short form
already had them -- and asserts isHex and isNoiseKeyHex agree.
* Trigger Noise session rekey on absolute age, not just idle time
needsRenegotiation() had two triggers: message count nearing the 1
billion-message ceiling, and lastActivityTime idle for longer than
sessionTimeout (24h). Neither fires for a session with steady traffic:
message count realistically never approaches a billion, and
lastActivityTime resets on every encrypt/decrypt, so a continuously
active conversation never looks idle.
Meanwhile encrypt/decrypt both hard-fail with sessionExpired once
sessionStartTime crosses the same 24h sessionTimeout, unconditionally,
with no rekey ever having been scheduled to prevent it. Two peers who
keep a conversation open past 24h (a stationary mesh node, or two
phones left open overnight) hit this simultaneously: every message
throws sessionExpired on both sides, isEstablished() still reports true
so the UI keeps showing the session as secured, MessageRouter never
falls back off the transport because canDeliverSecurely never goes
false, and nothing re-handshakes because the one self-heal path
(BLENoisePacketHandler's decrypt-failure recovery) requires an inbound
ciphertext to fail to decrypt for a session-establishment reason, not
sessionExpired specifically -- so it's excluded. The conversation is
silently and permanently dead until an app restart or a BLE
disconnect/reconnect forces revalidation.
Adds a third needsRenegotiation() trigger on sessionStartTime age
directly, firing at 90% of sessionTimeout -- the rekey timer polls
every 60s (NoiseEncryptionService.rekeyCheckInterval), leaving over two
hours of margin to complete a full handshake before the hard cutoff.
No Xcode/Swift toolchain available here, so this is verified by
tracing, not compiling: manually walked the new
continuouslyActiveSessionStillNeedsRenegotiationBeforeExpiry test's
inputs through both the pre-fix and post-fix needsRenegotiation() logic
(confirmed the pre-fix code returns false for a session with fresh
lastActivityTime and old sessionStartTime, and the fix flips it to
true), checked every other call site of needsRenegotiation() and
getSessionsNeedingRekey() in the test suite to confirm none depend on
the old always-false behavior at this age, and confirmed via
git apply -R / git apply that the source diff is exactly the traced
change with no other drift.
* Fix stale comment in needsRenegotiation idle time check
getPeerID(for:) and geohash geoNicknames.first(where:) returned the
first match, so /msg and /block could hit the wrong person when two
peers share a name. Accept an unsuffixed nick only when it is unique;
otherwise require the people-list #xxxx suffix.
The decoder accepted a payload of any type up to the framed-file
ceiling, compressed or not, and the stores holding decoded packets
evicted by count only.
- New PacketPayloadLimits (BitFoundation): per-type decoded payload
caps, enforced in BinaryProtocol.decodeCore for compressed and
uncompressed payloads. Only fileTransfer and noiseEncrypted keep the
framed-file cap; every other type is capped at >= 2x the largest
payload current iOS and Android encoders produce.
- The unreachable 50,000:1 ratio guard becomes DEFLATE's 1032:1 bound.
- Fragment reassembly is limited by the claimed type's frame ceiling,
and a reassembled packet of a different type is dropped.
- Gossip PacketStores, archive restore and the directed relay spool
get byte budgets alongside their count caps.
- Broadcast file transfers are validated before being tracked for sync.
- Public mesh messages larger than a v1 frame are not delivered.
Receive-side only; senders and the wire format are unchanged.
Also: a re-delivered fragment replaces its index's bytes in the assembly
size projection instead of counting twice, and a test-harness race in
GossipSyncManagerTests.concurrentPacketIntakeAndSyncRequest is fixed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sync replies (RSR) skipped the packet timestamp check, and nothing
downstream bounded future timestamps: the gossip freshness checks,
archive restore and the archived-echo key only handled the past.
- BLEIngressPacketGuard: RSR packets may still be old, but not dated
beyond now + skew (TransportConfig.bleMaxTimestampSkewMs, 120 s).
- ChatPublicConversationCoordinator.archivedEchoKey is total for any
Date instead of using a trapping UInt64 conversion.
- GossipSyncManager.isPacketFresh rejects packets dated beyond the same
skew, which gates every store, sync serving and archive restore;
restore rewrites the archive when it drops entries.
- BLEAnnounceHandler tracks only verified announces for sync.
- LocationNotesManager clamps note created_at to now.
- LocationNotesManager clamps future note timestamps to the board's
clock-skew allowance (not to now), so bridged copies of board posts
still dedupe in UnifiedNotices.merge.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
applyGroupState authorized state only against the creator named in the
state itself, not the creator of the group already stored under that
groupID, and handled removal before any existing-group check.
- applyGroupState: for a group already held, require the stored
creator's fingerprint and signing key, and a non-stale epoch, before
the removal branch.
- GroupStore.upsert refuses to change an existing group's creator;
updateRoster keeps the creator's entry pinned; loadFromDisk re-checks
the same invariants and keeps one entry per groupID.
- First ChatGroupCoordinator tests.
Ports the approach from #1587 by @areebahmeddd.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The recipient-less Nostr encoders used for geohash DMs wrote the local
mesh peer ID into the embedded packet's sender field, and the inbound
geohash DM path sent the delivery ack before the block check.
- encodePMForNostrNoRecipient / encodeAckForNostrNoRecipient no longer
take a sender ID and write fresh random bytes per envelope; the
geohash transport no longer sets senderPeerID. No iOS or Android
receiver reads this field.
- Inbound geohash DMs run the block check, then dedup, then the ack.
- The recipient-addressed (favorites) path is unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <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>