Compare commits

...

19 Commits

Author SHA1 Message Date
Taksh
fa64e8cb67 ci: retrigger checks after SimulatedMesh flake 2026-08-06 07:14:14 +05:30
Taksh
333746b08a Address Jack review on quote-in-composer (#1570).
Add content.message.quote to the 30-locale catalog; use a plain sender
header (no @) so quotes do not re-ping; drop the shared draft when the
composer target changes; and only skip the newline insert after a real
trailing newline (not a trailing space).
2026-08-06 07:14:05 +05:30
Taksh
6103e3f4e5 test: cover message quote formatting and draft append behavior
Pinned multiline quotes, system-sender omission, and non-destructive append.
2026-08-06 07:13:32 +05:30
Taksh
3152eeb139 feat: add quote-in-composer beside copy on message context menu
Keeps an existing draft and inserts a sender-attributed quote block
so replies can reference nearby chat without retyping.
2026-08-06 07:13:32 +05:30
Taksh
c249cb75ef feat: extract MessageClipboard for plaintext and quote formatting
Centralize pasteboard writes and composer quote blocks so message
actions stay testable without SwiftUI.
2026-08-06 07:13:32 +05:30
Taksh Kothari
1f59e814f9
Keyboard navigation for @-mention suggestions (#1542)
* 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>
2026-08-01 14:51:31 +02:00
Taksh Kothari
0152344554
feat: verification seal on private message sender rows (#1573)
* 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>
2026-08-01 14:51:28 +02:00
Taksh Kothari
681c180060
feat: share location channel invites via the system share sheet (#1513)
* 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>
2026-08-01 10:23:41 +01:00
Taksh Kothari
c66015d029
feat: local alias field on the fingerprint sheet (#1507)
* 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>
2026-08-01 10:23:14 +01:00
Rod Bahmanyari
68edb34469
Location channels: locale-derived one-tap quick join (#1444)
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>
2026-08-01 10:22:26 +01:00
Felix-Ayush
2c9a4e07c6
Localize hardcoded macOS image picker copy. (#1478)
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>
2026-08-01 10:03:52 +01:00
Felix-Ayush
8f9489790d
Add macOS camera QR scanning for peer verification (#1477)
* 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.
2026-08-01 10:03:48 +01:00
jack
948d6a85b9
Peer ID rotation: working primitives + spec for iOS/Android review (#1487)
* 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>
2026-08-01 09:41:15 +01:00
jack
9edb7c26ef
Silence the four release-build warnings (#1583)
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>
2026-07-31 14:29:47 +01:00
jack
6f32363774
Deflake VoiceRecorderTests: replace timed semaphores with async events (#1572)
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>
2026-07-31 11:50:24 +01:00
Vincenzo Palazzo
59a9f628df
test: pin that unknown file TLVs are skipped, not fatal (#1550)
`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>
2026-07-31 11:50:21 +01:00
heyaim
7b39d72bec
Give media the explicit file-protection class other stores use (#1552)
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>
2026-07-31 11:40:02 +01:00
Taksh Kothari
3a75567f5c
fix: stop EnvironmentObject crash in the people sheet (#1567)
* 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>
2026-07-31 11:04:55 +01:00
jack
f269617004
Fix the retire↔reconnect oscillation: redundant-link survivor is the newest connection (#1566)
* 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>
2026-07-31 10:30:51 +01:00
52 changed files with 7452 additions and 344 deletions

View File

@ -152,18 +152,23 @@ final class AppRuntime: ObservableObject {
NetworkActivationService.shared.start()
GeohashPresenceService.shared.start()
checkForSharedContent()
expireAgedMedia()
performMediaMaintenance()
record(.launched)
record(.startupCompleted)
}
/// Drops media that has outlived the retention window. Off the main thread
/// and best-effort: the sweep walks the media tree, and nothing at launch
/// depends on its result.
private func expireAgedMedia() {
Task(priority: .utility) {
BLEIncomingFileStore().expireAgedMedia()
/// Drops media that has outlived the retention window, then applies the
/// explicit protection class to files that older builds wrote without
/// one. Expiry runs first so the migration never touches files the
/// sweep is about to delete. Detached because `AppRuntime` is
/// main-actor and both passes go file by file through the media tree;
/// best-effort, nothing at launch depends on their results.
private func performMediaMaintenance() {
Task.detached(priority: .utility) {
let store = BLEIncomingFileStore()
store.expireAgedMedia()
store.migrateFileProtectionIfNeeded()
}
}

View File

@ -9,6 +9,7 @@ import UIKit
final class ConversationUIModel: ObservableObject {
@Published private(set) var showAutocomplete = false
@Published private(set) var autocompleteSuggestions: [String] = []
@Published private(set) var selectedAutocompleteIndex = 0
@Published private(set) var currentNickname: String
@Published private(set) var isBatchingPublic = false
@Published private(set) var canSendMediaInCurrentContext = true
@ -36,6 +37,7 @@ final class ConversationUIModel: ObservableObject {
self.isBatchingPublic = chatViewModel.isBatchingPublic
self.showAutocomplete = chatViewModel.showAutocomplete
self.autocompleteSuggestions = chatViewModel.autocompleteSuggestions
self.selectedAutocompleteIndex = chatViewModel.selectedAutocompleteIndex
self.canSendMediaInCurrentContext = chatViewModel.canSendMediaInCurrentContext
bind()
@ -104,6 +106,31 @@ final class ConversationUIModel: ObservableObject {
chatViewModel.completeNickname(nickname, in: &text)
}
/// Accept the currently highlighted mention suggestion, if any.
func completeSelectedSuggestion(in text: inout String) -> Bool {
guard showAutocomplete,
autocompleteSuggestions.indices.contains(selectedAutocompleteIndex)
else { return false }
_ = completeNickname(autocompleteSuggestions[selectedAutocompleteIndex], in: &text)
return true
}
/// Dismiss the mention suggestion panel without inserting (Escape).
func dismissAutocomplete() {
guard showAutocomplete else { return }
chatViewModel.showAutocomplete = false
chatViewModel.autocompleteSuggestions = []
chatViewModel.autocompleteRange = nil
chatViewModel.selectedAutocompleteIndex = 0
}
func moveAutocompleteSelection(by delta: Int) {
guard showAutocomplete, !autocompleteSuggestions.isEmpty else { return }
let count = min(4, autocompleteSuggestions.count)
let next = (selectedAutocompleteIndex + delta + count) % count
chatViewModel.selectedAutocompleteIndex = next
}
func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString {
chatViewModel.formatMessageAsText(message, colorScheme: colorScheme, theme: theme)
}
@ -128,6 +155,18 @@ final class ConversationUIModel: ObservableObject {
message.sender == currentNickname || message.senderPeerID == chatViewModel.meshService.myPeerID
}
/// Whether a private-message row should show the filled verification seal
/// next to the sender name (#1439). Scoped to DMs only public timelines
/// have different trust semantics and stay undressed.
func showsVerifiedSeal(for message: BitchatMessage) -> Bool {
guard message.isPrivate,
message.sender != "system",
!isSentByCurrentUser(message),
let peerID = message.senderPeerID else { return false }
guard let fingerprint = chatViewModel.getFingerprint(for: peerID) else { return false }
return chatViewModel.peerIdentityStore.isVerified(fingerprint)
}
func senderDisplayName(for peerID: PeerID, fallbackMessages: [BitchatMessage]) -> String? {
if peerID.isGeoDM || peerID.isGeoChat {
return chatViewModel.geohashDisplayName(for: peerID)
@ -193,6 +232,10 @@ final class ConversationUIModel: ObservableObject {
.receive(on: DispatchQueue.main)
.assign(to: &$autocompleteSuggestions)
chatViewModel.$selectedAutocompleteIndex
.receive(on: DispatchQueue.main)
.assign(to: &$selectedAutocompleteIndex)
chatViewModel.$isBatchingPublic
.receive(on: DispatchQueue.main)
.assign(to: &$isBatchingPublic)
@ -219,6 +262,15 @@ final class ConversationUIModel: ObservableObject {
self?.refreshComputedState()
}
.store(in: &cancellables)
// Verify/unverify while a DM is open must repaint existing rows
// showsVerifiedSeal is computed per render, so forward the store change.
chatViewModel.peerIdentityStore.$verifiedFingerprints
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
}
private func refreshComputedState() {

View File

@ -213,7 +213,7 @@ final class PeerListModel: ObservableObject {
return MeshPeerRow(
peerID: peer.peerID,
displayName: isMe ? chatViewModel.nickname : peer.nickname,
displayName: isMe ? chatViewModel.nickname : peer.displayName,
isMe: isMe,
hasUnread: chatViewModel.hasUnreadMessages(for: peer.peerID),
isBlocked: !isMe && chatViewModel.isPeerBlocked(peer.peerID),
@ -248,7 +248,7 @@ final class PeerListModel: ObservableObject {
self.groupRows = groupRows
renderID = (
meshRows.map {
"\($0.id)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
"\($0.id)-\($0.displayName)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
} +
geohashPeople.map {
"geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)"

View File

@ -294,6 +294,21 @@ final class PrivateConversationModel: ObservableObject {
if conversationPeerID.isGeoDM, case .location(let channel) = locationChannelsModel.selectedChannel {
return "#\(channel.geohash)/@\(chatViewModel.geohashDisplayName(for: conversationPeerID))"
}
// Local alias wins over a live peer row's announced nickname.
if headerPeerID.id.count == 16 {
let candidates = chatViewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
if let identity = candidates.first,
let social = chatViewModel.identityManager.getSocialIdentity(for: identity.fingerprint),
let pet = social.localPetname, !pet.isEmpty {
return pet
}
} else if let noiseKey = headerPeerID.noiseKey {
let fingerprint = noiseKey.sha256Fingerprint()
if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint),
let pet = social.localPetname, !pet.isEmpty {
return pet
}
}
if let displayName = peer?.displayName {
return displayName
}
@ -308,23 +323,15 @@ final class PrivateConversationModel: ObservableObject {
if headerPeerID.id.count == 16 {
let candidates = chatViewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
if let identity = candidates.first,
let social = chatViewModel.identityManager.getSocialIdentity(for: identity.fingerprint) {
if let pet = social.localPetname, !pet.isEmpty {
return pet
}
if !social.claimedNickname.isEmpty {
return social.claimedNickname
}
let social = chatViewModel.identityManager.getSocialIdentity(for: identity.fingerprint),
!social.claimedNickname.isEmpty {
return social.claimedNickname
}
} else if let noiseKey = headerPeerID.noiseKey {
let fingerprint = noiseKey.sha256Fingerprint()
if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint) {
if let pet = social.localPetname, !pet.isEmpty {
return pet
}
if !social.claimedNickname.isEmpty {
return social.claimedNickname
}
if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint),
!social.claimedNickname.isEmpty {
return social.claimedNickname
}
}

View File

@ -8,6 +8,9 @@ struct FingerprintPresentationState: Equatable {
let theirFingerprint: String?
let myFingerprint: String
let isVerified: Bool
/// User-assigned local alias (petname), if any distinct from the
/// peer-claimed nickname.
let localPetname: String?
/// Number of currently-valid vouches from peers the user verified
/// (0 when the peer is explicitly verified the stronger badge wins).
let voucherCount: Int
@ -20,6 +23,11 @@ struct FingerprintPresentationState: Equatable {
var canToggleVerification: Bool {
encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified
}
/// Alias field is editable once we know who we're looking at.
var canEditLocalAlias: Bool {
theirFingerprint != nil
}
}
enum VerificationScanOutcome: Equatable {
@ -75,6 +83,46 @@ final class VerificationModel: ObservableObject {
chatViewModel.unverifyFingerprint(for: peerID)
}
/// Persist a local alias for this peer. Empty/whitespace clears it so the
/// claimed nickname shows again. Display paths prefer `localPetname`
/// when set (#1439).
func setLocalPetname(_ petname: String?, for peerID: PeerID) {
let statusPeerID = chatViewModel.getShortIDForNoiseKey(peerID)
guard let fingerprint = chatViewModel.getFingerprint(for: statusPeerID) else { return }
let trimmed = petname?.trimmingCharacters(in: .whitespacesAndNewlines)
let normalized: String? = (trimmed?.isEmpty == false) ? trimmed : nil
let existing = chatViewModel.identityManager.getSocialIdentity(for: fingerprint)
let claimed = existing?.claimedNickname
?? chatViewModel.meshService.peerNickname(peerID: statusPeerID)
?? chatViewModel.resolveNickname(for: statusPeerID)
var identity = existing ?? SocialIdentity(
fingerprint: fingerprint,
localPetname: nil,
claimedNickname: claimed,
trustLevel: .unknown,
isFavorite: false,
isBlocked: false,
notes: nil
)
identity.localPetname = normalized
// Prefer the mesh-announced name for claimedNickname so we don't
// persist a previous alias as the "claimed" identity.
if let announced = chatViewModel.meshService.peerNickname(peerID: statusPeerID),
!announced.isEmpty {
identity.claimedNickname = announced
} else if identity.claimedNickname.isEmpty {
identity.claimedNickname = claimed
}
chatViewModel.identityManager.updateSocialIdentity(identity)
// Rebuild peer rows so PeerList / DM header pick up the new display name
// without waiting for an unrelated mesh event.
chatViewModel.unifiedPeerService.refreshPeers()
NotificationCenter.default.post(name: Notification.Name("peerStatusUpdated"), object: nil)
objectWillChange.send()
}
func isVerified(peerID: PeerID) -> Bool {
guard let fingerprint = chatViewModel.getFingerprint(for: peerID) else { return false }
return peerIdentityStore.isVerified(fingerprint)
@ -86,6 +134,8 @@ final class VerificationModel: ObservableObject {
let theirFingerprint = chatViewModel.getFingerprint(for: statusPeerID)
let peerNickname = resolveDisplayName(for: peerID, statusPeerID: statusPeerID)
let isVerified = theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false
let localPetname = theirFingerprint
.flatMap { chatViewModel.identityManager.getSocialIdentity(for: $0)?.localPetname }
// Vouch state is recomputed on read: only vouchers still in the
// verified set count, so removing a verification silently retires the
@ -110,6 +160,7 @@ final class VerificationModel: ObservableObject {
theirFingerprint: theirFingerprint,
myFingerprint: chatViewModel.getMyFingerprint(),
isVerified: isVerified,
localPetname: localPetname,
voucherCount: vouchers.count,
voucherNames: voucherNames
)
@ -158,6 +209,15 @@ final class VerificationModel: ObservableObject {
}
private func resolveDisplayName(for peerID: PeerID, statusPeerID: PeerID) -> String {
// Prefer an explicit local alias even when a live peer row exists
// peer.displayName already does this once UnifiedPeerService rebuilds,
// but read social identity directly so the fingerprint sheet header
// updates before that rebuild lands.
if let fingerprint = chatViewModel.getFingerprint(for: statusPeerID),
let pet = chatViewModel.identityManager.getSocialIdentity(for: fingerprint)?.localPetname,
!pet.isEmpty {
return pet
}
if let peer = chatViewModel.getPeer(byID: statusPeerID) {
return peer.displayName
}
@ -171,9 +231,6 @@ final class VerificationModel: ObservableObject {
}
let fingerprint = data.sha256Fingerprint()
if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint) {
if let pet = social.localPetname, !pet.isEmpty {
return pet
}
if !social.claimedNickname.isEmpty {
return social.claimedNickname
}

View File

@ -206,7 +206,7 @@ enum ImageUtils {
} else {
directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true)
}
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: BLEIncomingFileStore.mediaProtectionAttributes)
return directory.appendingPathComponent(fileName)
}

View File

@ -244,7 +244,7 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
let directory = base
.appendingPathComponent("files", isDirectory: true)
.appendingPathComponent("voicenotes/outgoing", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: BLEIncomingFileStore.mediaProtectionAttributes)
return directory.appendingPathComponent("voice_\(burstID.hexEncodedString()).m4a")
}
}

View File

@ -300,7 +300,7 @@ actor VoiceRecorder {
let baseDirectory = try outputDirectory
?? applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true)
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: nil)
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: BLEIncomingFileStore.mediaProtectionAttributes)
return baseDirectory.appendingPathComponent(fileName)
}

View File

@ -663,7 +663,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func removeEphemeralSession(peerID: PeerID) {
queue.sync(flags: .barrier) {
self.ephemeralSessions.removeValue(forKey: peerID)
_ = self.ephemeralSessions.removeValue(forKey: peerID)
}
}

File diff suppressed because it is too large Load Diff

View File

@ -24,7 +24,7 @@ extension BitchatMessage {
do {
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let filesDir = base.appendingPathComponent("files", isDirectory: true)
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: BLEIncomingFileStore.mediaProtectionAttributes)
self.filesDir = filesDir
} catch {
filesDir = nil

View File

@ -15,6 +15,10 @@ struct BitchatPeer: Equatable {
// Nostr identity (if known)
var nostrPublicKey: String?
/// Device-local alias (petname). Never sent over the wire; when set it
/// outranks the peer-claimed `nickname` for display only.
var localPetname: String?
// Connection state
enum ConnectionState {
@ -51,7 +55,10 @@ struct BitchatPeer: Equatable {
// Display helpers
var displayName: String {
nickname.isEmpty ? String(peerID.id.prefix(8)) : nickname
if let localPetname, !localPetname.isEmpty {
return localPetname
}
return nickname.isEmpty ? String(peerID.id.prefix(8)) : nickname
}
var statusIcon: String {
@ -78,13 +85,15 @@ struct BitchatPeer: Equatable {
nickname: String,
lastSeen _: Date = Date(),
isConnected: Bool = false,
isReachable: Bool = false
isReachable: Bool = false,
localPetname: String? = nil
) {
self.peerID = peerID
self.noisePublicKey = noisePublicKey
self.nickname = nickname
self.isConnected = isConnected
self.isReachable = isReachable
self.localPetname = localPetname
// Load favorite status - will be set later by the manager
self.favoriteStatus = nil

View File

@ -140,6 +140,20 @@ struct BLEIncomingFileStore: @unchecked Sendable {
/// orphans a previous session left behind.
static let liveCapturePrefix = "voice_live_"
/// Media payloads follow the same at-rest posture as the app's other
/// persistence layers (courier, outbox, receipt index): protected until
/// first unlock, so the launch-time retention sweep can still run after
/// a reboot. Applied to the media directories so recordings that save
/// as they go (live captures, `AVAudioRecorder`) inherit it, and stated
/// explicitly at the payload write site like every other store.
static var mediaProtectionAttributes: [FileAttributeKey: Any]? {
#if os(iOS)
return [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication]
#else
return nil
#endif
}
/// Exposed so callers that write progressively into the store's
/// directories (live voice captures) share the same file manager.
let fileManager: FileManager
@ -223,7 +237,7 @@ struct BLEIncomingFileStore: @unchecked Sendable {
isDirectory: true
),
withIntermediateDirectories: true,
attributes: nil
attributes: Self.mediaProtectionAttributes
)
}
} catch {
@ -268,7 +282,7 @@ struct BLEIncomingFileStore: @unchecked Sendable {
/// write progressively instead of via `save` (live voice captures).
func incomingDirectory(subdirectory: String) throws -> URL {
let directory = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: Self.mediaProtectionAttributes)
return directory
}
@ -284,7 +298,7 @@ struct BLEIncomingFileStore: @unchecked Sendable {
do {
let base = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
try fileManager.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil)
try fileManager.createDirectory(at: base, withIntermediateDirectories: true, attributes: Self.mediaProtectionAttributes)
let sanitized = sanitizedFileName(
preferredName,
defaultName: "\(defaultPrefix)_\(Self.timestampString(from: dateProvider()))",
@ -306,7 +320,11 @@ struct BLEIncomingFileStore: @unchecked Sendable {
),
forceRandomizedName: reservedPaths == nil
)
try data.write(to: destination, options: .atomic)
var options: Data.WritingOptions = [.atomic]
#if os(iOS)
options.insert(.completeFileProtectionUntilFirstUserAuthentication)
#endif
try data.write(to: destination, options: options)
payloadCoordination.pendingDeliveryPaths.insert(
destination.standardizedFileURL.path
)
@ -650,9 +668,90 @@ struct BLEIncomingFileStore: @unchecked Sendable {
return removed
}
/// Stamps the media directories and any resident payloads with the
/// explicit protection class, covering files written by builds that
/// relied on the container default. Runs every launch: re-stamping an
/// equal class is a metadata no-op, and anything carrying a stronger
/// class is left alone, so repetition is cheap and can never downgrade.
/// In-flight live captures are skipped for symmetry with the retention
/// sweep; they receive the class at creation and need no repair.
/// Best-effort like the sweep it runs alongside; a file that cannot be
/// stamped is logged, not fatal, and the migration moves on to the next
/// item. Returns the number of items stamped so the launch path and
/// tests can observe coverage.
@discardableResult
func migrateFileProtectionIfNeeded() -> Int {
#if os(iOS)
guard let attributes = Self.mediaProtectionAttributes else { return 0 }
var stamped = 0
guard let base = try? filesDirectory() else { return 0 }
for subdirectory in Self.mediaSubdirectories {
let dir = base.appendingPathComponent(subdirectory, isDirectory: true)
guard fileManager.fileExists(atPath: dir.path) else { continue }
let files = (try? fileManager.contentsOfDirectory(
at: dir,
includingPropertiesForKeys: [.isRegularFileKey, .isDirectoryKey, .fileProtectionKey],
options: [.skipsHiddenFiles]
)) ?? []
stamped += stampProtectionIfWeaker(dir, requireRegularFile: false, attributes: attributes)
for fileURL in files {
guard !fileURL.lastPathComponent.hasPrefix(Self.liveCapturePrefix) else { continue }
stamped += stampProtectionIfWeaker(fileURL, requireRegularFile: true, attributes: attributes)
}
}
return stamped
#else
return 0
#endif
}
#if os(iOS)
/// Applies the class to one item, but only when the item currently sits
/// at the container default or weaker. The list names the classes that
/// are safe to replace; anything else, including classes added in later
/// iOS versions, is left alone. Only regular files are stamped when
/// `requireRegularFile` is set (and only real directories otherwise),
/// matching the caution the legacy-file removal path applies; symlinks
/// and other non-regular files are left untouched.
private func stampProtectionIfWeaker(
_ itemURL: URL,
requireRegularFile: Bool,
attributes: [FileAttributeKey: Any]
) -> Int {
let values = try? itemURL.resourceValues(
forKeys: [.isRegularFileKey, .isDirectoryKey, .fileProtectionKey]
)
if requireRegularFile {
guard values?.isRegularFile == true else { return 0 }
} else {
guard values?.isDirectory == true else { return 0 }
}
if let current = values?.fileProtection,
current != .none,
current != .completeUntilFirstUserAuthentication {
return 0
}
do {
try fileManager.setAttributes(attributes, ofItemAtPath: itemURL.path)
return 1
} catch let error as CocoaError where error.code == .fileNoSuchFile {
// Quota eviction or a deletion commit on another store instance
// can delete an item out from under this migration; that is not
// a failure.
return 0
} catch {
SecureLogger.warning(
"⚠️ Failed to migrate media file protection: \(error)",
category: .security
)
return 0
}
}
#endif
private func filesDirectory() throws -> URL {
let filesDir = try rootDirectory().appendingPathComponent("files", isDirectory: true)
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: Self.mediaProtectionAttributes)
return filesDir
}

View File

@ -8,6 +8,12 @@ struct BLEPeripheralLinkState {
var isConnecting: Bool
var isConnected: Bool
var lastConnectionAttempt: Date?
/// When didConnect last fired for this link. Nil for links restored
/// already-connected (their connect predates this process), which is
/// exactly the signal redundant-link consolidation needs: a restored
/// link lives on an old BLE address the peer no longer advertises,
/// so it must never be kept over a freshly connected duplicate.
var lastConnectedAt: Date? = nil
var assembler: NotificationStreamAssembler
}
@ -112,11 +118,12 @@ final class BLELinkStateStore {
)
}
func markConnected(_ peripheral: CBPeripheral) {
func markConnected(_ peripheral: CBPeripheral, at now: Date = Date()) {
let peripheralID = peripheral.identifier.uuidString
if updatePeripheral(peripheralID, {
$0.isConnecting = false
$0.isConnected = true
$0.lastConnectedAt = now
}) == nil {
setPeripheralState(
BLEPeripheralLinkState(
@ -125,6 +132,7 @@ final class BLELinkStateStore {
isConnecting: false,
isConnected: true,
lastConnectionAttempt: nil,
lastConnectedAt: now,
assembler: NotificationStreamAssembler()
),
for: peripheralID

View File

@ -15,7 +15,15 @@ enum BLEOutboundPacketPolicy {
// voiceFrame is deliberately unpadded: padding to the 512 block would
// push every ~490-byte signed voice packet over the MTU into the
// fragment path.
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage, .voiceFrame:
//
// announceV2 is unpadded too, but for a different reason and it is worth
// revisiting: it is ~75 bytes, so the smallest bucket would triple the
// airtime of the most frequently sent packet in the protocol. Its length
// is already near-constant by construction (the tag block is fixed
// width); the residual variation is the capability width and whether a
// bridge geohash is present. Making those fixed-width would be cheaper
// than padding. See docs/PEER-ID-ROTATION.md.
case .none, .announce, .announceV2, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage, .voiceFrame:
return false
}
}
@ -27,6 +35,13 @@ enum BLEOutboundPacketPolicy {
return .fragment(totalFragments: fragmentTotalCount(from: packet.payload))
case .fileTransfer:
return .fileTransfer
case .announceV2:
// Stated rather than inherited from `default`. Presence is small,
// time-bounded to its epoch, and useless once stale, so it belongs
// with the other control traffic at high priority but that should
// be a decision on the record, not a fall-through, since this type
// is not emitted yet and nobody would notice the choice being made.
return .high
default:
return .high
}

View File

@ -21,24 +21,53 @@ enum BLERedundantLinkPolicy {
/// A link mid-service-rediscovery (didModifyServices cleared it)
/// must never be kept over a writable duplicate.
let hasCharacteristic: Bool
/// When didConnect last fired for this link in this process. Nil
/// for restored links, whose connect predates the relaunch.
let lastConnectedAt: Date?
init(uuid: String, peerID: PeerID?, isConnected: Bool, hasCharacteristic: Bool) {
init(
uuid: String,
peerID: PeerID?,
isConnected: Bool,
hasCharacteristic: Bool,
lastConnectedAt: Date? = nil
) {
self.uuid = uuid
self.peerID = peerID
self.isConnected = isConnected
self.hasCharacteristic = hasCharacteristic
self.lastConnectedAt = lastConnectedAt
}
}
/// The link to keep when a peer has several connected bound peripheral
/// links, or nil when there is nothing to consolidate. Prefers the
/// ingress link of the verified direct announce that triggered the check
/// (the strongest liveness proof available), falling back to the peer's
/// most recently bound link but only among writable links while any
/// exist: keeping a characteristic-less link and cancelling the writable
/// links, or nil when there is nothing to consolidate.
///
/// Prefers the most recently CONNECTED candidate. Duplicates arise when
/// the peer reappears under a fresh BLE address (privacy address
/// rotation) while an older connection typically state-restored
/// lives on: only the newest connection sits on the address the peer
/// still advertises. Cancelling that one instead just gets it
/// rediscovered and reconnected, a retirereconnect oscillation at the
/// retirement cooldown (field-observed July 31); the older-address link
/// cannot return once cancelled, so consolidation converges immediately.
/// Physical connect recency is also a signal an announce replay cannot
/// nominate, unlike the previous ingress-link preference announce
/// anchors (ingress, then most recently bound) now only break ties and
/// serve links with no connect timestamp at all. Link "health" signals
/// like RSSI are deliberately not inputs: they are transient and the
/// stale-address link often reads stronger; connect recency is the only
/// signal that tracks address currency.
///
/// The survivor must be writable while any writable candidate exists:
/// keeping a characteristic-less link and cancelling the writable
/// duplicate would strand outbound traffic on the central link until
/// rediscovery finishes. When neither anchor is a viable candidate,
/// consolidation waits for a later announce rather than guessing.
/// rediscovery finishes. But when the physically NEWEST connection is
/// the one that is not writable yet (service discovery still running),
/// consolidation defers entirely selecting an older writable link
/// would cancel the freshly advertised connection and recreate the
/// oscillation. When no candidate is identifiable, consolidation waits
/// for a later announce rather than guessing.
static func keptPeripheralUUID(
ingressPeripheralUUID: String?,
mostRecentlyBoundUUID: String?,
@ -51,6 +80,42 @@ enum BLERedundantLinkPolicy {
let writable = bound.filter(\.hasCharacteristic)
let candidates = writable.isEmpty ? bound : writable
// The newest connection is still mid-service-discovery while a
// writable (typically restored, stale-address) duplicate exists:
// defer to a later announce instead of keeping the older link and
// cancelling the one connection on the currently advertised address.
if !writable.isEmpty,
let newestBoundDate = bound.compactMap(\.lastConnectedAt).max(),
!writable.contains(where: { $0.lastConnectedAt == newestBoundDate }) {
return nil
}
if let newestDate = candidates.compactMap(\.lastConnectedAt).max() {
let newest = candidates.filter { $0.lastConnectedAt == newestDate }
if newest.count == 1 {
return newest[0].uuid
}
return anchoredChoice(
among: newest,
ingressPeripheralUUID: ingressPeripheralUUID,
mostRecentlyBoundUUID: mostRecentlyBoundUUID
) ?? newest.map(\.uuid).min()
}
return anchoredChoice(
among: candidates,
ingressPeripheralUUID: ingressPeripheralUUID,
mostRecentlyBoundUUID: mostRecentlyBoundUUID
)
}
/// The pre-timestamp anchors: the verified announce's ingress link,
/// then the peer's most recently bound link.
private static func anchoredChoice(
among candidates: [PeripheralLink],
ingressPeripheralUUID: String?,
mostRecentlyBoundUUID: String?
) -> String? {
if let ingressPeripheralUUID, candidates.contains(where: { $0.uuid == ingressPeripheralUUID }) {
return ingressPeripheralUUID
}

View File

@ -2943,7 +2943,7 @@ extension BLEService: GossipSyncManager.Delegate {
func sendPacket(to peerID: PeerID, packet: BitchatPacket) {
onEngine {
sendPacketDirected(packet, to: peerID)
_ = sendPacketDirected(packet, to: peerID)
}
}
@ -3336,9 +3336,12 @@ extension BLEService {
}
func _test_drainPrivateMediaSendPipeline() async {
// Capture only the (Sendable) queue, not self, so the @Sendable
// dispatch closures carry no non-Sendable state.
let queue = messageQueue
await withCheckedContinuation { continuation in
self.messageQueue.async { [weak self] in
self?.messageQueue.async {
queue.async {
queue.async {
continuation.resume()
}
}
@ -3357,9 +3360,10 @@ extension BLEService {
}
func _test_drainNoiseMessagePipeline() async {
let queue = messageQueue
await withCheckedContinuation { continuation in
self.messageQueue.async {
self.messageQueue.async {
queue.async {
queue.async {
continuation.resume()
}
}
@ -5979,7 +5983,16 @@ extension BLEService {
switch context.messageType {
case .announce:
handleAnnounce(packet, from: senderID)
case .announceV2:
// Parsed and ignored on purpose. The wire format and derivations are
// implemented and tested (see PeerIDRotation, AnnounceV2Packet), but
// consuming presence from it needs the replacement identity binding
// and the peer-list policy for unverified presence, both of which are
// still open questions in docs/PEER-ID-ROTATION.md. Accepting it now
// would add unauthenticated entries to the peer list.
break
case .message:
handleMessage(packet, from: senderID)
@ -6376,7 +6389,8 @@ extension BLEService {
store.peripheralStates.map {
(uuid: $0.peripheral.identifier.uuidString,
isConnected: $0.isConnected,
hasCharacteristic: $0.characteristic != nil)
hasCharacteristic: $0.characteristic != nil,
lastConnectedAt: $0.lastConnectedAt)
}
}
return physical.map {
@ -6384,7 +6398,8 @@ extension BLEService {
uuid: $0.uuid,
peerID: linkBindings.peer(forPeripheralID: $0.uuid),
isConnected: $0.isConnected,
hasCharacteristic: $0.hasCharacteristic
hasCharacteristic: $0.hasCharacteristic,
lastConnectedAt: $0.lastConnectedAt
)
}
}

View File

@ -0,0 +1,49 @@
//
// ChannelShare.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
/// Builds plain-text location-channel invites for the system share sheet (#1497).
///
/// Text-first on purpose: a `bitchat://` deep link is dead weight for people
/// who have not installed yet, and SMS does not reliably linkify custom
/// schemes. The payload always includes the App Store URL and the geohash a
/// person can type under location channels after installing.
enum ChannelShare {
/// App Store listing used in out-of-app invites.
static let appStoreURL = "https://apps.apple.com/us/app/bitchat-mesh/id6748219622"
/// Neighborhood (6) and finer imply a small cell sharing that over SMS
/// discloses location interest to the carrier and both handsets.
static let precisionWarningMinimumLength = 6
static func shouldWarn(forGeohash geohash: String) -> Bool {
geohash.count >= precisionWarningMinimumLength
}
/// Channel-not-presence framing: "join #x", never "I'm in #x".
static func payload(forGeohash geohash: String) -> String {
let gh = geohash.lowercased()
return String(
format: String(
localized: "channel.share.payload",
defaultValue: "join the #%1$@ channel on bitchat: bitchat://geohash/%1$@ — new to bitchat? get it at %2$@ then type #%1$@ under location channels.",
comment: "Plain-text share payload for a location channel; %1$@ is the geohash, %2$@ is the App Store URL"
),
locale: .current,
gh,
appStoreURL
)
}
}
/// Identifiable wrapper so `.sheet(item:)` can present the system share UI.
struct ChannelSharePayload: Identifiable {
let id = UUID()
let text: String
}

View File

@ -0,0 +1,62 @@
//
// MessageClipboard.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
/// Clipboard helpers for message actions. Kept out of the view so unit tests
/// can assert quote formatting without standing up SwiftUI.
enum MessageClipboard {
/// Plain message body for the pasteboard.
static func copyPlaintext(_ content: String) {
#if os(iOS)
UIPasteboard.general.string = content
#elseif os(macOS)
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(content, forType: .string)
#endif
}
/// Builds a composer-ready quote block (`> line` per line, trailing blank).
/// Sender header is plain text (`> alice:`), not `@alice`, so quoting does
/// not re-fire mention notifications for the quoted person or for names
/// inside the quoted body (those stay behind `> ` and are not live tokens).
static func quoteForComposer(_ content: String, sender: String?) -> String {
let body = content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !body.isEmpty else { return "" }
let header: String
if let sender, !sender.isEmpty, sender != "system" {
header = "> \(sender):\n"
} else {
header = ""
}
let quoted = body
.split(separator: "\n", omittingEmptySubsequences: false)
.map { "> \($0)" }
.joined(separator: "\n")
return header + quoted + "\n\n"
}
/// Appends a quote to an existing composer draft without wiping it.
/// Only a trailing newline skips inserting another separator a trailing
/// space must still get a newline so the quote marker starts its own line.
static func appendQuote(to draft: String, content: String, sender: String?) -> String {
let quote = quoteForComposer(content, sender: sender)
guard !quote.isEmpty else { return draft }
if draft.isEmpty { return quote }
if draft.hasSuffix("\n") {
return draft + quote
}
return draft + "\n" + quote
}
}

View File

@ -195,7 +195,8 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
nickname: peerInfo.nickname,
lastSeen: peerInfo.lastSeen,
isConnected: peerInfo.isConnected,
isReachable: isReachable
isReachable: isReachable,
localPetname: localPetname(forFingerprint: fingerprint)
)
// Check for favorite status
@ -218,7 +219,8 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
nickname: favorite.peerNickname,
lastSeen: favorite.lastUpdated,
isConnected: false,
isReachable: false
isReachable: false,
localPetname: localPetname(forFingerprint: favorite.peerNoisePublicKey.sha256Fingerprint())
)
peer.favoriteStatus = favorite
@ -227,6 +229,21 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
return peer
}
/// Rebuild peer rows after a social-identity write (local alias, etc.) so
/// display names update without waiting for a mesh event.
func refreshPeers() {
updatePeers()
}
private func localPetname(forFingerprint fingerprint: String?) -> String? {
guard let fingerprint,
let petname = identityManager.getSocialIdentity(for: fingerprint)?.localPetname,
!petname.isEmpty else {
return nil
}
return petname
}
// MARK: - Public Methods
/// Get peer by ID

View File

@ -57,6 +57,11 @@ struct SyncTypeFlags: OptionSet {
// Live voice is only useful now; replaying stale audio frames via
// sync would waste airtime (receivers drop them as stale anyway).
case .voiceFrame: return nil
// Rotating-ID presence is valid only inside its epoch, and gossiping it
// would defeat the point: a synced announce would let a device that was
// never in radio range collect tag blocks, turning a local presence
// beacon into a network-wide one.
case .announceV2: return nil
// Prekey bundles gossip like board posts. The bitfield is a
// wire-tolerant little-endian UInt64 (1-8 bytes, unknown high bits
// ignored by `type(forBit:)`), so bits 8+ need no format change: old

View File

@ -0,0 +1,113 @@
import Foundation
/// The one-tap "quick join" suggestion in the channels sheet: the
/// region-level geohash channel around the device region's main population
/// center. Derived from the device locale no location access, no GPS; the
/// tap reuses the same path as typing the geohash and teleporting.
///
/// This replaced an earlier curated list of heavily censored countries. A
/// hand-picked roster invites disputes over who is on it and goes stale
/// with every political shift; deriving the suggestion from the locale
/// gives every country the same treatment under one rule.
///
/// Quick join is channel discovery, not protection: region cells are
/// public, well known, and trivially enumerable, so the suggested channel
/// must be assumed watched. Joining it hides nothing and bypasses nothing.
struct QuickJoinSuggestion {
let regionCode: String
let geohash: String
/// Regional-indicator flag emoji derived from the ISO code.
var flag: String {
regionCode.unicodeScalars.reduce(into: "") { result, scalar in
if let indicator = Unicode.Scalar(127397 + scalar.value) {
result.unicodeScalars.append(indicator)
}
}
}
var localizedName: String {
Locale.current.localizedString(forRegionCode: regionCode) ?? regionCode
}
/// The suggestion for the device's region, or nil when the region is
/// unknown or unmapped (the section is hidden then).
static func current(for locale: Locale = .current) -> QuickJoinSuggestion? {
guard let code = locale.region?.identifier.uppercased(),
let geohash = regionCells[code] else { return nil }
return QuickJoinSuggestion(regionCode: code, geohash: geohash)
}
/// ISO 3166-1 alpha-2 region the 2-character geohash cell over the
/// country's main population center the largest metro, not always the
/// capital (US New York, TR Istanbul, MM Yangon), because that is
/// the cell where a country's channel actually forms. Someone elsewhere
/// in the country lands in this cell too; the caption in the sheet says
/// so rather than calling it "your country's channel".
///
/// Coverage is every UN member state plus inhabited territories a
/// device locale plausibly reports; a missing code just hides the row.
/// Cells are 11.25° × 5.625°, so city-level coordinates are ample. The
/// table is generated by geohashing each center's coordinates; the
/// twelve entries the earlier roster shipped were independently
/// verified in review and reproduce unchanged, and entries near a cell
/// boundary were checked by hand. Distinct countries can legitimately
/// share a cell (Seoul and Pyongyang are both "wy") cells are big.
private static let regionCells: [String: String] = [
"AD": "sp", "AE": "th", "AF": "tw", "AG": "de",
"AL": "sr", "AM": "sz", "AO": "kq", "AR": "69",
"AT": "u2", "AU": "r3", "AW": "d6", "AZ": "tp",
"BA": "sr", "BB": "dd", "BD": "wh", "BE": "u1",
"BF": "ef", "BG": "sx", "BH": "th", "BI": "kx",
"BJ": "s1", "BM": "dt", "BN": "w8", "BO": "6s",
"BR": "6g", "BS": "dk", "BT": "tu", "BW": "ke",
"BY": "u9", "BZ": "d5", "CA": "dp", "CD": "kr",
"CF": "s2", "CG": "kr", "CH": "u0", "CI": "eb",
"CL": "66", "CM": "s0", "CN": "wx", "CO": "d2",
"CR": "d1", "CU": "dh", "CV": "e6", "CW": "d6",
"CY": "sw", "CZ": "u2", "DE": "u3", "DJ": "sf",
"DK": "u3", "DM": "dd", "DO": "d7", "DZ": "sn",
"EC": "6p", "EE": "ud", "EG": "st", "ER": "sf",
"ES": "ez", "ET": "sc", "FI": "ud", "FJ": "ru",
"FM": "x9", "FO": "gg", "FR": "u0", "GA": "s0",
"GB": "gc", "GD": "dd", "GE": "sz", "GF": "db",
"GG": "gb", "GH": "eb", "GI": "ey", "GL": "fg",
"GM": "ed", "GN": "e9", "GP": "dd", "GQ": "s0",
"GR": "sw", "GT": "9f", "GU": "x4", "GW": "ed",
"GY": "d9", "HK": "we", "HN": "d4", "HR": "u2",
"HT": "d7", "HU": "u2", "ID": "qq", "IE": "gc",
"IL": "sv", "IM": "gc", "IN": "tt", "IQ": "sv",
"IR": "tn", "IS": "ge", "IT": "sr", "JE": "gb",
"JM": "d7", "JO": "sv", "JP": "xn", "KE": "kz",
"KG": "tx", "KH": "w6", "KI": "xb", "KM": "kv",
"KN": "de", "KP": "wy", "KR": "wy", "KW": "tj",
"KY": "d5", "KZ": "tx", "LA": "w7", "LB": "sy",
"LC": "dd", "LI": "u0", "LK": "tc", "LR": "ec",
"LS": "kd", "LT": "u9", "LU": "u0", "LV": "ud",
"LY": "sm", "MA": "ev", "MC": "sp", "MD": "u8",
"ME": "sr", "MG": "mh", "MH": "xc", "MK": "sr",
"ML": "ef", "MM": "w4", "MN": "y2", "MO": "we",
"MQ": "dd", "MR": "ee", "MT": "sq", "MU": "mk",
"MV": "t8", "MW": "kv", "MX": "9g", "MY": "w2",
"MZ": "ke", "NA": "k7", "NC": "rs", "NE": "s4",
"NG": "s1", "NI": "d4", "NL": "u1", "NO": "u4",
"NP": "tu", "NR": "rx", "NZ": "rc", "OM": "tk",
"PA": "d1", "PE": "6m", "PF": "2s", "PG": "rq",
"PH": "wd", "PK": "tk", "PL": "u3", "PR": "de",
"PS": "sv", "PT": "ey", "PW": "wc", "PY": "6e",
"QA": "th", "RE": "mh", "RO": "sx", "RS": "sr",
"RU": "uc", "RW": "kx", "SA": "th", "SB": "rw",
"SC": "mp", "SD": "sd", "SE": "u6", "SG": "w2",
"SI": "u2", "SK": "u2", "SL": "e9", "SM": "sr",
"SN": "ed", "SO": "t0", "SR": "dc", "SS": "s8",
"ST": "s0", "SV": "d4", "SY": "sv", "SZ": "ke",
"TD": "s6", "TG": "s1", "TH": "w4", "TJ": "tw",
"TL": "qy", "TM": "tq", "TN": "sn", "TO": "2h",
"TR": "sx", "TT": "d9", "TV": "ry", "TW": "ws",
"TZ": "ky", "UA": "u8", "UG": "s8", "US": "dr",
"UY": "6c", "UZ": "tx", "VA": "sr", "VC": "dd",
"VE": "d9", "VI": "de", "VN": "w7", "VU": "rs",
"WS": "2j", "XK": "sr", "YE": "sf", "YT": "mj",
"ZA": "ke", "ZM": "kt", "ZW": "ks",
]
}

View File

@ -353,7 +353,11 @@ final class ChatLiveVoiceCoordinator {
// Eviction skips voice_live_* names, so partials still streaming in
// are safe no matter which caller triggers enforcement.
fileStore.enforceQuota(reservingBytes: TransportConfig.pttMaxBurstBytes)
fileManager.createFile(atPath: fileURL.path, contents: nil)
fileManager.createFile(
atPath: fileURL.path,
contents: nil,
attributes: BLEIncomingFileStore.mediaProtectionAttributes
)
guard let handle = try? FileHandle(forWritingTo: fileURL) else {
SecureLogger.error("PTT: cannot open capture file for burst \(burstID.hexEncodedString())", category: .session)
try? fileManager.removeItem(at: fileURL)

View File

@ -1899,7 +1899,7 @@ private extension ChatMediaTransferCoordinator {
try FileManager.default.createDirectory(
at: filesDirectory,
withIntermediateDirectories: true,
attributes: nil
attributes: BLEIncomingFileStore.mediaProtectionAttributes
)
return filesDirectory
}

View File

@ -68,7 +68,10 @@ final class ChatMessageFormatter {
suffixStyle.foregroundColor = baseColor.opacity(0.6)
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
}
if isVerifiedSender {
// Private rows render a filled SF Symbol seal beside the lock
// (TextMessageView / MediaMessageView); skip the in-string there
// so verified DMs don't show two markers.
if isVerifiedSender, !message.isPrivate {
appendVerifiedSeal(to: &result, baseColor: baseColor, design: design)
}
result.append(AttributedString("> ").mergingAttributes(senderStyle))
@ -388,7 +391,7 @@ final class ChatMessageFormatter {
suffixStyle.foregroundColor = baseColor.opacity(0.6)
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
}
if isVerifiedSender {
if isVerifiedSender, !message.isPrivate {
appendVerifiedSeal(to: &result, baseColor: baseColor, design: design)
}
result.append(AttributedString("> ").mergingAttributes(senderStyle))

View File

@ -477,15 +477,21 @@ final class ChatPeerIdentityCoordinator {
return peerID.id
}
// Local aliases outrank announced nicknames so a saved petname is
// actually visible after the fingerprint sheet dismisses.
if let fingerprint = getFingerprint(for: peerID),
let identity = context.socialIdentity(forFingerprint: fingerprint),
let petname = identity.localPetname,
!petname.isEmpty {
return petname
}
if let nickname = context.meshPeerNicknames()[peerID] {
return nickname
}
if let fingerprint = getFingerprint(for: peerID),
let identity = context.socialIdentity(forFingerprint: fingerprint) {
if let petname = identity.localPetname {
return petname
}
return identity.claimedNickname
}

View File

@ -50,6 +50,15 @@ struct TextMessageView: View {
.padding(.trailing, 4)
.accessibilityHidden(true)
}
if conversationUIModel.showsVerifiedSeal(for: message) {
Image(systemName: "checkmark.seal.fill")
.font(.bitchatSystem(size: 8))
.foregroundColor(Color.green.opacity(0.85))
.padding(.trailing, 4)
.accessibilityLabel(
String(localized: "content.accessibility.verified_sender", defaultValue: "Verified sender", comment: "Accessibility label for the seal next to a verified peer's name on a private message")
)
}
if message.isBridged {
Image(systemName: "network")
.font(.bitchatSystem(size: 8))

View File

@ -2,6 +2,9 @@ import SwiftUI
#if os(iOS)
import UIKit
#endif
#if os(macOS)
import AppKit
#endif
struct ContentComposerView: View {
@EnvironmentObject private var conversationUIModel: ConversationUIModel
@ -29,7 +32,7 @@ struct ContentComposerView: View {
VStack(alignment: .leading, spacing: 6) {
if conversationUIModel.showAutocomplete && !conversationUIModel.autocompleteSuggestions.isEmpty {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(conversationUIModel.autocompleteSuggestions.prefix(4)), id: \.self) { suggestion in
ForEach(Array(conversationUIModel.autocompleteSuggestions.prefix(4).enumerated()), id: \.element) { index, suggestion in
Button(action: {
_ = conversationUIModel.completeNickname(suggestion, in: &messageText)
}) {
@ -43,6 +46,11 @@ struct ContentComposerView: View {
.padding(.horizontal, 12)
.padding(.vertical, 3)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
index == conversationUIModel.selectedAutocompleteIndex
? palette.secondary.opacity(0.15)
: Color.clear
)
}
.buttonStyle(.plain)
}
@ -73,7 +81,28 @@ struct ContentComposerView: View {
.textInputAutocapitalization(.sentences)
#endif
.submitLabel(.send)
.modifier(AutocompleteKeyboardNavigationModifier(
isActive: { conversationUIModel.showAutocomplete
&& !conversationUIModel.autocompleteSuggestions.isEmpty },
onMove: { delta in
conversationUIModel.moveAutocompleteSelection(by: delta)
},
onAccept: {
conversationUIModel.completeSelectedSuggestion(in: &messageText)
},
onDismiss: {
conversationUIModel.dismissAutocomplete()
}
))
// Return while the mention panel is open completes the
// highlight instead of sending matches command suggestions
// (#1504) and keeps Tab/Return/Escape on one convention.
.onSubmit {
if conversationUIModel.showAutocomplete,
!conversationUIModel.autocompleteSuggestions.isEmpty,
conversationUIModel.completeSelectedSuggestion(in: &messageText) {
return
}
onSendMessage()
// Only the return-key path: it steals focus on iOS, so
// every message would cost a tap to reopen the keyboard.
@ -374,3 +403,104 @@ private extension ContentComposerView {
)
}
}
/// Arrow/Tab/Return/Escape navigation for the mention suggestion list.
///
/// Deployment targets are iOS 16 / macOS 13, so `.onKeyPress` (iOS 17 /
/// macOS 14+) is gated and unavailable on the minimum OS. Separately, on
/// macOS the single-line field editor consumes `moveUp:`/`moveDown:` itself,
/// so arrow keys never reach SwiftUI while the composer has focus the
/// same reason command suggestions (#1504) use an `NSEvent` local monitor.
/// Mentions follow that mechanism on macOS and keep `.onKeyPress` for iOS 17+.
private struct AutocompleteKeyboardNavigationModifier: ViewModifier {
/// Live activity check, not a captured Bool. The macOS monitor closure is
/// registered once for the view's lifetime; a plain `Bool` would freeze
/// the value captured at install time (this is a value type), so a panel
/// that opens after the monitor installs would never intercept a key.
/// The provider closes over the reference-typed model and reads current
/// state on every event.
let isActive: () -> Bool
let onMove: (Int) -> Void
let onAccept: () -> Bool
let onDismiss: () -> Void
#if os(macOS)
@State private var keyMonitor: Any?
#endif
func body(content: Content) -> some View {
#if os(macOS)
content
.onAppear { installKeyMonitor() }
.onDisappear { removeKeyMonitor() }
#else
if #available(iOS 17.0, *) {
content
.onKeyPress(.upArrow) {
guard isActive() else { return .ignored }
onMove(-1)
return .handled
}
.onKeyPress(.downArrow) {
guard isActive() else { return .ignored }
onMove(1)
return .handled
}
.onKeyPress(.tab) {
guard isActive() else { return .ignored }
return onAccept() ? .handled : .ignored
}
.onKeyPress(.escape) {
guard isActive() else { return .ignored }
onDismiss()
return .handled
}
} else {
content
}
#endif
}
#if os(macOS)
private func installKeyMonitor() {
guard keyMonitor == nil else { return }
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
handleKeyDown(event)
}
}
private func removeKeyMonitor() {
if let keyMonitor {
NSEvent.removeMonitor(keyMonitor)
}
keyMonitor = nil
}
/// Standard autocomplete navigation (aligned with #1504): arrows move
/// the highlight, return/tab insert, escape dismisses. Returning nil
/// consumes the event so return completes instead of sending while the
/// list is up. Inactive monitors pass everything through.
private func handleKeyDown(_ event: NSEvent) -> NSEvent? {
guard isActive(),
event.modifierFlags.intersection([.command, .option, .control]).isEmpty else {
return event
}
switch event.keyCode {
case 126: // up arrow
onMove(-1)
return nil
case 125: // down arrow
onMove(1)
return nil
case 36, 48: // return, tab
return onAccept() ? nil : event
case 53: // escape
onDismiss()
return nil
default:
return event
}
}
#endif
}

View File

@ -30,6 +30,10 @@ struct ContentHeaderView: View {
/// timeline is showing) they should light the pin too.
@ObservedObject private var nearbyNotes = NearbyNotesCounter.shared
@State private var pendingShareGeohash: String?
@State private var showSharePrecisionWarning = false
@State private var activeSharePayload: ChannelSharePayload?
/// The bridged-people count belongs to the mesh channel only.
private var showBridgedPeerCount: Bool {
if case .location = locationChannelsModel.selectedChannel { return false }
@ -213,6 +217,16 @@ struct ContentHeaderView: View {
channel.geohash
)
)
Button(action: { requestHeaderShare(forGeohash: channel.geohash) }) {
Image(systemName: "square.and.arrow.up")
.font(.bitchatSystem(size: 12))
.headerTapTarget()
}
.buttonStyle(.plain)
.accessibilityLabel(
String(localized: "channel.share.action", defaultValue: "share channel", comment: "Accessibility label for sharing the active location channel")
)
}
Button(action: { appChromeModel.isLocationChannelsSheetPresented = true }) {
@ -336,8 +350,37 @@ struct ContentHeaderView: View {
} message: {
Text("content.alert.screenshot.message")
}
.confirmationDialog(
String(localized: "channel.share.precision_warning.title", defaultValue: "share a precise location channel?", comment: "Title of the confirmation before sharing a neighborhood-or-finer geohash invite"),
isPresented: $showSharePrecisionWarning,
titleVisibility: .visible
) {
Button(String(localized: "channel.share.precision_warning.confirm", defaultValue: "share anyway", comment: "Confirms sharing a fine-precision location channel after the OpSec warning")) {
if let gh = pendingShareGeohash {
activeSharePayload = ChannelSharePayload(text: ChannelShare.payload(forGeohash: gh))
}
pendingShareGeohash = nil
}
Button("common.cancel", role: .cancel) {
pendingShareGeohash = nil
}
} message: {
Text(String(localized: "channel.share.precision_warning.message", defaultValue: "this channel covers a small area. an invite sent over sms or imessage is visible to the carrier and both handsets — it discloses interest in that place, not only that someone uses bitchat.", comment: "Body of the confirmation before sharing a fine-precision geohash invite"))
}
.sheet(item: $activeSharePayload) { payload in
ShareActivityView(text: payload.text)
}
.themedChromePanel(edge: .top)
}
private func requestHeaderShare(forGeohash geohash: String) {
if ChannelShare.shouldWarn(forGeohash: geohash) {
pendingShareGeohash = geohash
showSharePrecisionWarning = true
} else {
activeSharePayload = ChannelSharePayload(text: ChannelShare.payload(forGeohash: geohash))
}
}
}
private extension View {

View File

@ -92,6 +92,9 @@ struct ContentView: View {
@EnvironmentObject private var conversationUIModel: ConversationUIModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var sharedContentImportModel: SharedContentImportModel
@EnvironmentObject private var peerListModel: PeerListModel
@EnvironmentObject private var publicChatModel: PublicChatModel
@EnvironmentObject private var privateInboxModel: PrivateInboxModel
@StateObject private var voiceRecordingVM = VoiceRecordingViewModel()
@State private var messageText = ""
@ -251,12 +254,17 @@ struct ContentView: View {
.frame(minWidth: 600, minHeight: 400)
#endif
.onChange(of: selectedPrivatePeerID) { newValue in
// Shared composer draft must not travel across conversation
// targets (quoting a DM then opening public would otherwise
// one-tap-broadcast private content).
messageText = ""
if newValue != nil {
showSidebar = true
}
sharedContentImportModel.updateDestination(sharedContentDestination)
}
.onChange(of: locationChannelsModel.selectedChannel) { _ in
messageText = ""
sharedContentImportModel.updateDestination(sharedContentDestination)
}
.sheet(
@ -297,6 +305,17 @@ struct ContentView: View {
showImagePicker: $showImagePicker,
imagePickerSourceType: $imagePickerSourceType
)
// Sheets + NavigationStack can drop inherited EnvironmentObjects on
// some iOS versions (#1558). Re-inject every model the sheet tree
// reads so ContentPeopleListView / MessageListView never crash.
.environmentObject(appChromeModel)
.environmentObject(privateConversationModel)
.environmentObject(verificationModel)
.environmentObject(conversationUIModel)
.environmentObject(locationChannelsModel)
.environmentObject(peerListModel)
.environmentObject(publicChatModel)
.environmentObject(privateInboxModel)
#else
ContentPeopleSheetView(
showSidebar: $showSidebar,
@ -314,6 +333,14 @@ struct ContentView: View {
onSendMessage: sendMessage,
showMacImagePicker: $showMacImagePicker
)
.environmentObject(appChromeModel)
.environmentObject(privateConversationModel)
.environmentObject(verificationModel)
.environmentObject(conversationUIModel)
.environmentObject(locationChannelsModel)
.environmentObject(peerListModel)
.environmentObject(publicChatModel)
.environmentObject(privateInboxModel)
#endif
}
.sheet(isPresented: $appChromeModel.isAppInfoPresented) {

View File

@ -14,6 +14,8 @@ struct FingerprintView: View {
let peerID: PeerID
@Environment(\.dismiss) var dismiss
@ThemedPalette private var palette
@State private var aliasDraft: String = ""
@State private var didLoadAlias = false
private var textColor: Color { palette.primary }
@ -26,6 +28,21 @@ struct FingerprintView: View {
static let verifiedBadge: LocalizedStringKey = "fingerprint.badge.verified"
static let notVerifiedBadge: LocalizedStringKey = "fingerprint.badge.not_verified"
static let verifiedMessage: LocalizedStringKey = "fingerprint.message.verified"
static let localAlias = String(
localized: "fingerprint.local_alias.label",
defaultValue: "local alias",
comment: "Label for the local-only alias field on the fingerprint sheet"
)
static let localAliasPlaceholder = String(
localized: "fingerprint.local_alias.placeholder",
defaultValue: "name for this person",
comment: "Placeholder for the local alias field on the fingerprint sheet"
)
static let localAliasHint = String(
localized: "fingerprint.local_alias.hint",
defaultValue: "only on this device. leave blank to use their claimed nickname.",
comment: "Explanation under the local alias field"
)
static func verifyHint(_ nickname: String) -> String {
String(
format: String(localized: "fingerprint.message.verify_hint", comment: "Instruction to compare fingerprints with a named peer"),
@ -85,6 +102,26 @@ struct FingerprintView: View {
.padding()
.background(palette.secondary.opacity(0.1))
.cornerRadius(8)
if fingerprintState.canEditLocalAlias {
VStack(alignment: .leading, spacing: 8) {
Text(verbatim: Strings.localAlias)
.bitchatFont(size: 12, weight: .bold)
.foregroundColor(textColor.opacity(0.7))
TextField(Strings.localAliasPlaceholder, text: $aliasDraft)
.bitchatFont(size: 14)
.foregroundColor(textColor)
.padding(10)
.background(palette.secondary.opacity(0.1))
.cornerRadius(8)
.onSubmit { commitAlias() }
Text(verbatim: Strings.localAliasHint)
.bitchatFont(size: 11)
.foregroundColor(textColor.opacity(0.6))
}
}
// Their fingerprint
VStack(alignment: .leading, spacing: 8) {
@ -248,6 +285,37 @@ struct FingerprintView: View {
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.themedSheetBackground()
.onAppear {
syncAliasDraft(from: fingerprintState, force: true)
}
.onChange(of: fingerprintState.theirFingerprint) { _ in
// Fingerprint can arrive after the sheet opens; load (or reload)
// the saved alias then, otherwise an empty draft looks like a clear.
syncAliasDraft(from: fingerprintState, force: false)
}
.onDisappear {
commitAlias()
}
}
/// Populate `aliasDraft` from the persisted petname once we know the
/// fingerprint. `force` reloads even if we already loaded (onAppear).
private func syncAliasDraft(from state: FingerprintPresentationState, force: Bool) {
guard state.canEditLocalAlias else { return }
if didLoadAlias && !force { return }
aliasDraft = state.localPetname ?? ""
didLoadAlias = true
}
private func commitAlias() {
let fingerprintState = verificationModel.fingerprintPresentation(for: peerID)
guard fingerprintState.canEditLocalAlias else { return }
// Don't treat "never loaded a draft" as an intentional clear.
guard didLoadAlias else { return }
let current = fingerprintState.localPetname ?? ""
let draft = aliasDraft.trimmingCharacters(in: .whitespacesAndNewlines)
guard draft != current else { return }
verificationModel.setLocalPetname(draft.isEmpty ? nil : draft, for: peerID)
}
private func formatFingerprint(_ fingerprint: String) -> String {

View File

@ -14,18 +14,25 @@ struct MacImagePickerView: View {
let completion: (URL?) -> Void
@Environment(\.dismiss) private var dismiss
private enum Strings {
static let title: LocalizedStringKey = "mac.image_picker.title"
static let select = String(localized: "mac.image_picker.select", comment: "Button that opens the macOS open-panel to pick an image")
static let panelMessage = String(localized: "mac.image_picker.panel_message", comment: "Message shown in the macOS NSOpenPanel when picking an image")
static let cancel = String(localized: "mac.image_picker.cancel", comment: "Cancel button for the macOS image picker sheet")
}
var body: some View {
VStack(spacing: 16) {
Text("Choose an image")
Text(Strings.title)
.font(.headline)
Button("Select Image") {
Button(Strings.select) {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.allowedContentTypes = [.image, .png, .jpeg, .heic]
panel.message = "Choose an image to send"
panel.message = Strings.panelMessage
if panel.runModal() == .OK {
completion(panel.url)
@ -35,7 +42,7 @@ struct MacImagePickerView: View {
}
.buttonStyle(.borderedProminent)
Button("Cancel") {
Button(Strings.cancel) {
completion(nil)
}
.buttonStyle(.bordered)

View File

@ -12,6 +12,10 @@ struct LocationChannelsSheet: View {
@ThemedPalette private var palette
@State private var customGeohash: String = ""
@State private var customError: String? = nil
/// Geohash waiting on the fine-precision OpSec confirmation before share.
@State private var pendingShareGeohash: String?
@State private var showSharePrecisionWarning = false
@State private var activeSharePayload: ChannelSharePayload?
private enum Strings {
static let title: LocalizedStringKey = "location_channels.title"
@ -24,10 +28,30 @@ struct LocationChannelsSheet: View {
static let teleport: LocalizedStringKey = "location_channels.action.teleport"
static let bookmarked: LocalizedStringKey = "location_channels.bookmarked_section_title"
static let quickJoinTitle = String(localized: "location_channels.quick_join.title", defaultValue: "quick join", comment: "Section header in the location channels sheet for the one-tap suggestion of the region channel derived from the device region")
static func quickJoinDescription(_ regionName: String) -> String {
String(
format: String(localized: "location_channels.quick_join.description", defaultValue: "the region channel where people from %@ tend to gather — the wide cell around the main population center, not your location. it's public and well-known, so assume it's watched: quick join saves typing a geohash; it doesn't hide you or bypass blocks.", comment: "Caption under the quick join row; %@ is the localized country/region name. States plainly that the cell is the main population center's (not the person's location), that the channel must be assumed watched, and that quick join is discovery, not circumvention"),
locale: .current,
regionName
)
}
static func quickJoinLabel(_ regionName: String) -> String {
String(
format: String(localized: "location_channels.quick_join.join_label", defaultValue: "join the %@ region channel", comment: "Accessibility label for the quick join row; %@ is the localized country/region name"),
locale: .current,
regionName
)
}
static let invalidGeohash = String(localized: "location_channels.error.invalid_geohash", comment: "Error shown when a custom geohash is invalid")
static let switchChannelHint = String(localized: "location_channels.accessibility.switch_hint", comment: "Accessibility hint on a channel row explaining activation switches to it")
static let addBookmark = String(localized: "location_channels.accessibility.add_bookmark", comment: "Accessibility action name for bookmarking a channel")
static let removeBookmark = String(localized: "location_channels.accessibility.remove_bookmark", comment: "Accessibility action name for removing a channel bookmark")
static let shareChannel = String(localized: "channel.share.action", defaultValue: "share channel", comment: "Context-menu / accessibility action that shares a location-channel invite")
static let sharePrecisionTitle = String(localized: "channel.share.precision_warning.title", defaultValue: "share a precise location channel?", comment: "Title of the confirmation before sharing a neighborhood-or-finer geohash invite")
static let sharePrecisionMessage = String(localized: "channel.share.precision_warning.message", defaultValue: "this channel covers a small area. an invite sent over sms or imessage is visible to the carrier and both handsets — it discloses interest in that place, not only that someone uses bitchat.", comment: "Body of the confirmation before sharing a fine-precision geohash invite")
static let shareAnyway = String(localized: "channel.share.precision_warning.confirm", defaultValue: "share anyway", comment: "Confirms sharing a fine-precision location channel after the OpSec warning")
static func meshTitle(_ count: Int) -> String {
let label = String(localized: "location_channels.mesh_label", comment: "Label for the mesh channel row")
@ -163,6 +187,39 @@ struct LocationChannelsSheet: View {
}
}
.onChange(of: locationChannelsModel.availableChannels) { _ in }
.confirmationDialog(
Strings.sharePrecisionTitle,
isPresented: $showSharePrecisionWarning,
titleVisibility: .visible
) {
Button(Strings.shareAnyway) {
if let gh = pendingShareGeohash {
presentShare(forGeohash: gh)
}
pendingShareGeohash = nil
}
Button("common.cancel", role: .cancel) {
pendingShareGeohash = nil
}
} message: {
Text(Strings.sharePrecisionMessage)
}
.sheet(item: $activeSharePayload) { payload in
ShareActivityView(text: payload.text)
}
}
private func requestShare(forGeohash geohash: String) {
if ChannelShare.shouldWarn(forGeohash: geohash) {
pendingShareGeohash = geohash
showSharePrecisionWarning = true
} else {
presentShare(forGeohash: geohash)
}
}
private func presentShare(forGeohash geohash: String) {
activeSharePayload = ChannelSharePayload(text: ChannelShare.payload(forGeohash: geohash))
}
private var closeButton: some View {
@ -204,12 +261,21 @@ struct LocationChannelsSheet: View {
.accessibilityLabel(locationChannelsModel.isBookmarked(channel.geohash) ? Strings.removeBookmark : Strings.addBookmark)
},
accessoryActionTitle: locationChannelsModel.isBookmarked(channel.geohash) ? Strings.removeBookmark : Strings.addBookmark,
accessoryAction: { locationChannelsModel.toggleBookmark(channel.geohash) }
accessoryAction: { locationChannelsModel.toggleBookmark(channel.geohash) },
shareGeohash: channel.geohash,
onShare: { requestShare(forGeohash: channel.geohash) }
) {
locationChannelsModel.markTeleported(for: channel.geohash, false)
locationChannelsModel.select(ChannelID.location(channel))
isPresented = false
}
.contextMenu {
Button {
requestShare(forGeohash: channel.geohash)
} label: {
Label(Strings.shareChannel, systemImage: "square.and.arrow.up")
}
}
.padding(.vertical, 6)
}
} else if locationChannelsModel.permissionState == .authorized {
@ -236,6 +302,12 @@ struct LocationChannelsSheet: View {
customTeleportSection
.padding(.vertical, 8)
if QuickJoinSuggestion.current() != nil {
sectionDivider
quickJoinSection
.padding(.vertical, 8)
}
let bookmarkedList = locationChannelsModel.bookmarks
if !bookmarkedList.isEmpty {
sectionDivider
@ -319,6 +391,46 @@ struct LocationChannelsSheet: View {
}
}
/// One tap into the region channel around the device region's main
/// population center derived from the locale, no location access, no
/// roster (see QuickJoinSuggestion). The caption is deliberately blunt
/// that the cell is public and watched: discovery, not circumvention.
@ViewBuilder
private var quickJoinSection: some View {
if let suggestion = QuickJoinSuggestion.current() {
VStack(alignment: .leading, spacing: 8) {
Text(Strings.quickJoinTitle)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
Button(action: {
locationChannelsModel.teleport(to: suggestion.geohash)
isPresented = false
}) {
HStack {
Text(verbatim: "\(suggestion.flag) \(suggestion.localizedName)")
.bitchatFont(size: 14)
.foregroundColor(palette.primary)
Spacer()
Text(verbatim: "#\(suggestion.geohash)")
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
}
.padding(.vertical, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(Strings.quickJoinLabel(suggestion.localizedName))
.accessibilityHint(Strings.switchChannelHint)
Text(Strings.quickJoinDescription(suggestion.localizedName))
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
private func bookmarkedSection(_ entries: [String]) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(Strings.bookmarked)
@ -347,7 +459,9 @@ struct LocationChannelsSheet: View {
.accessibilityLabel(locationChannelsModel.isBookmarked(gh) ? Strings.removeBookmark : Strings.addBookmark)
},
accessoryActionTitle: locationChannelsModel.isBookmarked(gh) ? Strings.removeBookmark : Strings.addBookmark,
accessoryAction: { locationChannelsModel.toggleBookmark(gh) }
accessoryAction: { locationChannelsModel.toggleBookmark(gh) },
shareGeohash: gh,
onShare: { requestShare(forGeohash: gh) }
) {
let inRegional = locationChannelsModel.availableChannels.contains { $0.geohash == gh }
if !inRegional && !locationChannelsModel.availableChannels.isEmpty {
@ -358,6 +472,13 @@ struct LocationChannelsSheet: View {
locationChannelsModel.select(ChannelID.location(channel))
isPresented = false
}
.contextMenu {
Button {
requestShare(forGeohash: gh)
} label: {
Label(Strings.shareChannel, systemImage: "square.and.arrow.up")
}
}
.padding(.vertical, 6)
.onAppear { locationChannelsModel.resolveBookmarkNameIfNeeded(for: gh) }
@ -391,6 +512,8 @@ struct LocationChannelsSheet: View {
@ViewBuilder trailingAccessory: () -> some View = { EmptyView() },
accessoryActionTitle: String? = nil,
accessoryAction: (() -> Void)? = nil,
shareGeohash: String? = nil,
onShare: (() -> Void)? = nil,
action: @escaping () -> Void
) -> some View {
HStack(alignment: .center, spacing: 8) {
@ -438,6 +561,9 @@ struct LocationChannelsSheet: View {
if let accessoryActionTitle, let accessoryAction {
Button(accessoryActionTitle, action: accessoryAction)
}
if shareGeohash != nil, let onShare {
Button(Strings.shareChannel, action: onShare)
}
}
}

View File

@ -48,6 +48,15 @@ struct MediaMessageView: View {
.padding(.trailing, 4)
.accessibilityHidden(true)
}
if conversationUIModel.showsVerifiedSeal(for: message) {
Image(systemName: "checkmark.seal.fill")
.font(.bitchatSystem(size: 8))
.foregroundColor(Color.green.opacity(0.85))
.padding(.trailing, 4)
.accessibilityLabel(
String(localized: "content.accessibility.verified_sender", defaultValue: "Verified sender", comment: "Accessibility label for the seal next to a verified peer's name on a private message")
)
}
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .center, spacing: 4) {
Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme))

View File

@ -143,13 +143,23 @@ struct MessageListView: View {
}
}
Button("content.message.copy") {
#if os(iOS)
UIPasteboard.general.string = message.content
#else
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(message.content, forType: .string)
#endif
MessageClipboard.copyPlaintext(message.content)
}
if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
message.sender != "system" {
Button {
messageText = MessageClipboard.appendQuote(
to: messageText,
content: message.content,
sender: message.sender
)
} label: {
Text(String(
localized: "content.message.quote",
defaultValue: "quote in composer",
comment: "Context menu action that inserts a quoted copy of the message into the composer draft"
))
}
}
if isResendableFailedMessage(message) {
Button("content.actions.resend") {

View File

@ -0,0 +1,58 @@
//
// ShareActivityView.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import SwiftUI
/// Hosts the system share UI after an optional OpSec confirmation (#1497).
struct ShareActivityView: View {
let text: String
@Environment(\.dismiss) private var dismiss
var body: some View {
#if os(iOS)
ShareActivityController(items: [text])
.ignoresSafeArea()
#elseif os(macOS)
VStack(alignment: .leading, spacing: 16) {
Text(text)
.font(.body)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
HStack {
Spacer()
ShareLink(item: text) {
Label(
String(localized: "channel.share.action", defaultValue: "share channel", comment: "Button that opens the system share sheet for a location channel invite"),
systemImage: "square.and.arrow.up"
)
}
Button(String(localized: "common.done", defaultValue: "done", comment: "Dismisses a sheet")) {
dismiss()
}
.keyboardShortcut(.cancelAction)
}
}
.padding()
.frame(minWidth: 360)
#endif
}
}
#if os(iOS)
import UIKit
private struct ShareActivityController: UIViewControllerRepresentable {
let items: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: items, applicationActivities: nil)
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}
#endif

View File

@ -1,6 +1,7 @@
import SwiftUI
import CoreImage
import CoreImage.CIFilterBuiltins
import AVFoundation
#if os(iOS)
import UIKit
#else
@ -109,19 +110,27 @@ struct ImageWrapper: View {
}
}
/// Placeholder scanner UI; real camera scanning will be added later.
/// Peer verification QR scanner. Uses the camera on iOS and macOS; macOS also
/// keeps a paste/validate fallback for machines without a usable camera.
struct QRScanView: View {
@EnvironmentObject private var verificationModel: VerificationModel
@ThemedPalette private var palette
var isActive: Bool = true
var onSuccess: (() -> Void)? = nil // Called when verification succeeds
@State private var input = ""
@State private var result: String = "" // not shown for iOS scanner
@State private var result: String = ""
@State private var lastValid: String = ""
@State private var cameraUnavailable = false
private enum Strings {
static let pastePrompt: LocalizedStringKey = "verification.scan.paste_prompt"
static let validate: LocalizedStringKey = "verification.scan.validate"
static let cameraUnavailable = String(
localized: "verification.scan.camera_unavailable",
defaultValue: "Camera unavailable — paste a QR below.",
comment: "Shown over the scanner preview when no camera is available or permission was denied"
)
static func requested(_ nickname: String) -> String {
String(
format: String(localized: "verification.scan.status.requested", comment: "Status text when verification is requested for a nickname"),
@ -135,69 +144,83 @@ struct QRScanView: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
#if os(iOS)
CameraScannerView(isActive: isActive) { code in
// Deduplicate: ignore if we just processed this exact QR code
guard code != lastValid else { return }
switch verificationModel.verifyScannedPayload(code) {
case .requested:
// Successfully initiated verification; remember this QR to prevent re-scanning
lastValid = code
// Close scanner and return to "My QR" view
onSuccess?()
case .notFound, .invalid:
// Ignore invalid/no-match reads and keep scanning
break
ZStack {
CameraScannerView(isActive: isActive, onUnavailable: { cameraUnavailable = true }) { code in
handleScannedCode(code, announceResult: false)
}
if cameraUnavailable {
Text(Strings.cameraUnavailable)
.bitchatFont(size: 13, weight: .medium)
.foregroundColor(palette.secondary)
.multilineTextAlignment(.center)
.padding(16)
}
}
.frame(height: 260)
.clipShape(RoundedRectangle(cornerRadius: 8))
#else
#if os(macOS)
Text(Strings.pastePrompt)
.bitchatFont(size: 14, weight: .medium)
TextEditor(text: $input)
.frame(height: 100)
.border(palette.secondary.opacity(0.4))
Button(Strings.validate) {
// Deduplicate: ignore if we just processed this exact QR
guard input != lastValid else {
result = Strings.requested("") // Already processed
return
}
switch verificationModel.verifyScannedPayload(input) {
case .requested(let nickname):
result = Strings.requested(nickname)
lastValid = input
// Close scanner and return to "My QR" view
onSuccess?()
case .notFound:
result = Strings.notFound
case .invalid:
result = Strings.invalid
}
handleScannedCode(input, announceResult: true)
}
.buttonStyle(.bordered)
if !result.isEmpty {
Text(result)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
}
#endif
// No status text under camera per design
Spacer()
}
.padding()
}
private func handleScannedCode(_ code: String, announceResult: Bool) {
guard code != lastValid else {
if announceResult {
result = Strings.requested("")
}
return
}
switch verificationModel.verifyScannedPayload(code) {
case .requested(let nickname):
lastValid = code
if announceResult {
result = Strings.requested(nickname)
}
onSuccess?()
case .notFound:
if announceResult {
result = Strings.notFound
}
case .invalid:
if announceResult {
result = Strings.invalid
}
}
}
}
#if os(iOS)
import AVFoundation
struct CameraScannerView: UIViewRepresentable {
typealias UIViewType = PreviewView
var isActive: Bool
var onUnavailable: (() -> Void)? = nil
var onCode: (String) -> Void
func makeUIView(context: Context) -> PreviewView {
let view = PreviewView()
context.coordinator.setup(sessionOwner: view, onCode: onCode)
context.coordinator.setup(
previewLayer: view.videoPreviewLayer,
onCode: onCode,
onUnavailable: onUnavailable
)
context.coordinator.setActive(isActive)
return view
}
@ -206,68 +229,7 @@ struct CameraScannerView: UIViewRepresentable {
context.coordinator.setActive(isActive)
}
func makeCoordinator() -> Coordinator { Coordinator() }
final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
private var onCode: ((String) -> Void)?
private weak var owner: PreviewView?
private let session = AVCaptureSession()
private var isRunning = false
private var permissionGranted = false
private var desiredActive = false
func setup(sessionOwner: PreviewView, onCode: @escaping (String) -> Void) {
self.owner = sessionOwner
self.onCode = onCode
session.beginConfiguration()
session.sessionPreset = .high
guard let device = AVCaptureDevice.default(for: .video),
let input = try? AVCaptureDeviceInput(device: device),
session.canAddInput(input) else { return }
session.addInput(input)
let output = AVCaptureMetadataOutput()
guard session.canAddOutput(output) else { return }
session.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
if output.availableMetadataObjectTypes.contains(.qr) {
output.metadataObjectTypes = [.qr]
}
session.commitConfiguration()
sessionOwner.videoPreviewLayer.session = session
// Request permission and start
AVCaptureDevice.requestAccess(for: .video) { granted in
self.permissionGranted = granted
if granted && self.desiredActive && !self.isRunning {
self.setActive(true)
}
}
}
func setActive(_ active: Bool) {
desiredActive = active
guard permissionGranted else { return }
if active && !isRunning {
isRunning = true
DispatchQueue.global(qos: .userInitiated).async {
if !self.session.isRunning { self.session.startRunning() }
}
} else if !active && isRunning {
isRunning = false
DispatchQueue.global(qos: .userInitiated).async {
if self.session.isRunning { self.session.stopRunning() }
}
}
}
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
for obj in metadataObjects {
guard let m = obj as? AVMetadataMachineReadableCodeObject,
m.type == .qr,
let str = m.stringValue else { continue }
onCode?(str)
}
}
}
func makeCoordinator() -> CameraScannerCoordinator { CameraScannerCoordinator() }
final class PreviewView: UIView {
override static var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
@ -279,8 +241,166 @@ struct CameraScannerView: UIViewRepresentable {
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
}
}
#elseif os(macOS)
struct CameraScannerView: NSViewRepresentable {
typealias NSViewType = PreviewView
var isActive: Bool
var onUnavailable: (() -> Void)? = nil
var onCode: (String) -> Void
func makeNSView(context: Context) -> PreviewView {
let view = PreviewView()
context.coordinator.setup(
previewLayer: view.videoPreviewLayer,
onCode: onCode,
onUnavailable: onUnavailable
)
context.coordinator.setActive(isActive)
return view
}
func updateNSView(_ nsView: PreviewView, context: Context) {
context.coordinator.setActive(isActive)
}
func makeCoordinator() -> CameraScannerCoordinator { CameraScannerCoordinator() }
final class PreviewView: NSView {
let videoPreviewLayer = AVCaptureVideoPreviewLayer()
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
wantsLayer = true
videoPreviewLayer.videoGravity = .resizeAspectFill
layer = CALayer()
layer?.addSublayer(videoPreviewLayer)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func layout() {
super.layout()
videoPreviewLayer.frame = bounds
}
}
}
#endif
final class CameraScannerCoordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
private var onCode: ((String) -> Void)?
private var onUnavailable: (() -> Void)?
private let session = AVCaptureSession()
private var isRunning = false
private var permissionGranted = false
private var desiredActive = false
private var didConfigureSession = false
private weak var previewLayer: AVCaptureVideoPreviewLayer?
func setup(
previewLayer: AVCaptureVideoPreviewLayer,
onCode: @escaping (String) -> Void,
onUnavailable: (() -> Void)? = nil
) {
self.onCode = onCode
self.onUnavailable = onUnavailable
self.previewLayer = previewLayer
previewLayer.session = session
// Check authorization before creating AVCaptureDeviceInput so tests and
// cold launches do not trigger a TCC prompt just by constructing input.
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
permissionGranted = true
if !configureSessionIfNeeded() {
reportUnavailable()
}
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { granted in
DispatchQueue.main.async {
self.permissionGranted = granted
if granted {
if !self.configureSessionIfNeeded() {
self.reportUnavailable()
return
}
if self.desiredActive && !self.isRunning {
self.setActive(true)
}
} else {
self.reportUnavailable()
}
}
}
default:
permissionGranted = false
reportUnavailable()
}
}
@discardableResult
private func configureSessionIfNeeded() -> Bool {
guard !didConfigureSession else { return true }
session.beginConfiguration()
session.sessionPreset = .high
guard let device = AVCaptureDevice.default(for: .video),
let input = try? AVCaptureDeviceInput(device: device),
session.canAddInput(input) else {
session.commitConfiguration()
return false
}
session.addInput(input)
let output = AVCaptureMetadataOutput()
guard session.canAddOutput(output) else {
session.commitConfiguration()
return false
}
session.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
if output.availableMetadataObjectTypes.contains(.qr) {
output.metadataObjectTypes = [.qr]
}
session.commitConfiguration()
previewLayer?.session = session
didConfigureSession = true
return true
}
private func reportUnavailable() {
DispatchQueue.main.async {
self.onUnavailable?()
}
}
func setActive(_ active: Bool) {
desiredActive = active
guard permissionGranted, didConfigureSession else { return }
if active && !isRunning {
isRunning = true
DispatchQueue.global(qos: .userInitiated).async {
if !self.session.isRunning { self.session.startRunning() }
}
} else if !active && isRunning {
isRunning = false
DispatchQueue.global(qos: .userInitiated).async {
if self.session.isRunning { self.session.stopRunning() }
}
}
}
func metadataOutput(
_ output: AVCaptureMetadataOutput,
didOutput metadataObjects: [AVMetadataObject],
from connection: AVCaptureConnection
) {
for obj in metadataObjects {
guard let m = obj as? AVMetadataMachineReadableCodeObject,
m.type == .qr,
let str = m.stringValue else { continue }
onCode?(str)
}
}
}
// Combined sheet: shows my QR by default with a button to scan instead
struct VerificationSheetView: View {
@EnvironmentObject private var verificationModel: VerificationModel
@ -320,19 +440,12 @@ struct VerificationSheetView: View {
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
.foregroundColor(accentColor)
#if os(iOS)
QRScanView(isActive: showingScanner, onSuccess: {
showingScanner = false
})
.environmentObject(verificationModel)
.frame(height: 280)
.frame(minHeight: 280)
.clipShape(RoundedRectangle(cornerRadius: 10))
#else
QRScanView(onSuccess: {
showingScanner = false
})
.environmentObject(verificationModel)
#endif
}
.padding()
.frame(maxWidth: .infinity)

View File

@ -10,6 +10,8 @@
</array>
<key>com.apple.security.device.bluetooth</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.device.microphone</key>
<true/>
<key>com.apple.security.personal-information.location</key>

View File

@ -0,0 +1,26 @@
//
// ChannelShareTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
@testable import bitchat
struct ChannelShareTests {
@Test func payloadIncludesGeohashDeepLinkAndStoreURL() {
let text = ChannelShare.payload(forGeohash: "u4pru")
#expect(text.contains("#u4pru"))
#expect(text.contains("bitchat://geohash/u4pru"))
#expect(text.contains(ChannelShare.appStoreURL))
#expect(!text.lowercased().contains("i'm in"))
}
@Test func precisionWarningStartsAtNeighborhood() {
#expect(!ChannelShare.shouldWarn(forGeohash: "u4pru")) // city = 5
#expect(ChannelShare.shouldWarn(forGeohash: "u4pruy")) // neighborhood = 6
#expect(ChannelShare.shouldWarn(forGeohash: "u4pruyzd"))
}
}

View File

@ -383,6 +383,10 @@ struct ChatPeerIdentityCoordinatorContextTests {
)
#expect(coordinator.resolveNickname(for: identityPeer) == "bob!")
// Local alias outranks a live mesh announce for the same peer.
context.nicknamesByPeerID[identityPeer] = "bob"
#expect(coordinator.resolveNickname(for: identityPeer) == "bob!")
#expect(coordinator.resolveNickname(for: unknownPeer) == "anonfeed")
#expect(coordinator.getMyFingerprint() == "my-fingerprint")
}

View File

@ -75,6 +75,70 @@ final class BitchatFilePacketTests: XCTestCase {
XCTAssertEqual(decoded.content, content)
}
/// The TLV tag list is a floor, not a ceiling: a decoder that bails on the
/// first tag it does not know makes the format unextendable, because a field
/// the sender considered optional costs the receiver the whole file. This
/// decoder skips them (`case nil: continue`) and that has to stay true it
/// is load-bearing for any peer, version or third-party client that adds a
/// field we have not seen. `PrivateMediaMessageIdentity` exists precisely
/// because the Android decoder does *not* do this, so the asymmetry is real
/// and worth pinning on the side that gets it right.
func testDecodeSkipsUnknownTLVTypesInsteadOfDroppingTheFile() throws {
let content = Data((0..<64).map { UInt8($0) })
let unknownValue = Data("some-message-id".utf8)
var data = Data()
// fileName
data.append(0x01)
data.append(contentsOf: [0x00, 0x09])
data.append(Data("photo.jpg".utf8))
// fileSize
data.append(0x02)
data.append(contentsOf: [0x00, 0x04])
data.append(contentsOf: [0x00, 0x00, 0x00, UInt8(content.count)])
// mimeType
data.append(0x03)
data.append(contentsOf: [0x00, 0x0A])
data.append(Data("image/jpeg".utf8))
// An unknown tag, where an encoder appending content last would put it
data.append(0x05)
data.append(contentsOf: [0x00, UInt8(unknownValue.count)])
data.append(unknownValue)
// content
data.append(0x04)
data.append(contentsOf: [0x00, 0x00, 0x00, UInt8(content.count)])
data.append(content)
let decoded = try XCTUnwrap(BitchatFilePacket.decode(data))
XCTAssertEqual(decoded.fileName, "photo.jpg")
XCTAssertEqual(decoded.mimeType, "image/jpeg")
XCTAssertEqual(decoded.fileSize, UInt64(content.count))
XCTAssertEqual(decoded.content, content)
}
/// Same contract for an extension that trails the content, which a decoder
/// stopping at the first unknown tag would also lose.
func testDecodeSkipsAnUnknownTLVTrailingTheContent() throws {
let content = Data(repeating: 0x7F, count: 16)
var data = Data()
data.append(0x01)
data.append(contentsOf: [0x00, 0x08])
data.append(Data("note.m4a".utf8))
data.append(0x04)
data.append(contentsOf: [0x00, 0x00, 0x00, UInt8(content.count)])
data.append(content)
data.append(0x7F)
data.append(contentsOf: [0x00, 0x04])
data.append(Data([0x11, 0x11, 0x11, 0x11]))
let decoded = try XCTUnwrap(BitchatFilePacket.decode(data))
XCTAssertEqual(decoded.fileName, "note.m4a")
XCTAssertNil(decoded.mimeType)
XCTAssertEqual(decoded.fileSize, UInt64(content.count))
XCTAssertEqual(decoded.content, content)
}
func testPrivateMediaMessageIdentityConvergesAcrossPeerIDAliases() throws {
let senderKey = Data(repeating: 0x11, count: 32)
let recipientKey = Data(repeating: 0x22, count: 32)

View File

@ -7,8 +7,8 @@ struct BLERedundantLinkPolicyTests {
private let peer = PeerID(str: "1122334455667788")
private let otherPeer = PeerID(str: "8877665544332211")
private func link(_ uuid: String, _ peerID: PeerID?, connected: Bool = true, writable: Bool = true) -> BLERedundantLinkPolicy.PeripheralLink {
BLERedundantLinkPolicy.PeripheralLink(uuid: uuid, peerID: peerID, isConnected: connected, hasCharacteristic: writable)
private func link(_ uuid: String, _ peerID: PeerID?, connected: Bool = true, writable: Bool = true, connectedAt: Date? = nil) -> BLERedundantLinkPolicy.PeripheralLink {
BLERedundantLinkPolicy.PeripheralLink(uuid: uuid, peerID: peerID, isConnected: connected, hasCharacteristic: writable, lastConnectedAt: connectedAt)
}
@Test
@ -131,4 +131,144 @@ struct BLERedundantLinkPolicyTests {
)
#expect(Set(retiring) == Set(["p-stale-1", "p-stale-2"]))
}
// MARK: Connect-recency preference (the July 31 retirereconnect fix)
@Test
func newestConnectionWinsOverIngressAndBindingAnchors() {
// Field oscillation: the restored old-address link (no connect
// timestamp) carried the announce ingress AND the binding, so it
// kept winning and the cancelled fresh-address link kept getting
// rediscovered and reconnected. Physical connect recency must beat
// both announce anchors.
let now = Date()
let kept = BLERedundantLinkPolicy.keptPeripheralUUID(
ingressPeripheralUUID: "p-restored",
mostRecentlyBoundUUID: "p-restored",
links: [
link("p-restored", peer),
link("p-fresh", peer, connectedAt: now)
],
peerID: peer
)
#expect(kept == "p-fresh")
}
@Test
func amongTimestampedLinksTheNewestWins() {
let now = Date()
let kept = BLERedundantLinkPolicy.keptPeripheralUUID(
ingressPeripheralUUID: "p-older",
mostRecentlyBoundUUID: "p-older",
links: [
link("p-older", peer, connectedAt: now.addingTimeInterval(-30)),
link("p-newer", peer, connectedAt: now)
],
peerID: peer
)
#expect(kept == "p-newer")
}
@Test
func newestLinkMidDiscoveryDefersInsteadOfKeepingOlderWritable() {
// The fresh connection hasn't finished service discovery, so it is
// not writable yet. Keeping the older writable (restored) link now
// would cancel the one connection on the currently advertised
// address and recreate the oscillation defer to a later announce.
let now = Date()
let kept = BLERedundantLinkPolicy.keptPeripheralUUID(
ingressPeripheralUUID: "p-writable",
mostRecentlyBoundUUID: "p-writable",
links: [
link("p-writable", peer, connectedAt: now.addingTimeInterval(-30)),
link("p-fresh-bare", peer, writable: false, connectedAt: now)
],
peerID: peer
)
#expect(kept == nil)
}
@Test
func restoredWritableAnchorAlsoDefersToFreshUnwritableLink() {
// Same discovery window as above, but the writable duplicate is a
// restored link with no connect timestamp at all the exact field
// topology. It must not win just because the fresh link is bare.
let kept = BLERedundantLinkPolicy.keptPeripheralUUID(
ingressPeripheralUUID: "p-restored",
mostRecentlyBoundUUID: "p-restored",
links: [
link("p-restored", peer),
link("p-fresh-bare", peer, writable: false, connectedAt: Date())
],
peerID: peer
)
#expect(kept == nil)
}
@Test
func coNewestWritableLinkStillWinsOverBareTwin() {
// Two links share the newest timestamp and one is writable: no
// discovery window to wait out the writable co-newest survives.
let now = Date()
let kept = BLERedundantLinkPolicy.keptPeripheralUUID(
ingressPeripheralUUID: nil,
mostRecentlyBoundUUID: nil,
links: [
link("p-bare", peer, writable: false, connectedAt: now),
link("p-writable", peer, connectedAt: now)
],
peerID: peer
)
#expect(kept == "p-writable")
}
@Test
func allUnwritableDuplicatesConsolidateByConnectRecency() {
// No writable link exists at all: nothing can be stranded, so the
// newest connection consolidates immediately.
let now = Date()
let kept = BLERedundantLinkPolicy.keptPeripheralUUID(
ingressPeripheralUUID: "p-older",
mostRecentlyBoundUUID: "p-older",
links: [
link("p-older", peer, writable: false, connectedAt: now.addingTimeInterval(-30)),
link("p-newer", peer, writable: false, connectedAt: now)
],
peerID: peer
)
#expect(kept == "p-newer")
}
@Test
func allRestoredLinksFallBackToAnnounceAnchors() {
// No connect timestamps at all (every link restored): the legacy
// ingress-then-binding preference still decides.
let kept = BLERedundantLinkPolicy.keptPeripheralUUID(
ingressPeripheralUUID: "p-ingress",
mostRecentlyBoundUUID: "p-bound",
links: [link("p-ingress", peer), link("p-bound", peer)],
peerID: peer
)
#expect(kept == "p-ingress")
}
@Test
func timestampTiesBreakByAnchorsThenDeterministically() {
let now = Date()
let anchored = BLERedundantLinkPolicy.keptPeripheralUUID(
ingressPeripheralUUID: "p-b",
mostRecentlyBoundUUID: nil,
links: [link("p-a", peer, connectedAt: now), link("p-b", peer, connectedAt: now)],
peerID: peer
)
#expect(anchored == "p-b")
let unanchored = BLERedundantLinkPolicy.keptPeripheralUUID(
ingressPeripheralUUID: nil,
mostRecentlyBoundUUID: nil,
links: [link("p-b", peer, connectedAt: now), link("p-a", peer, connectedAt: now)],
peerID: peer
)
#expect(unanchored == "p-a")
}
}

View File

@ -114,4 +114,83 @@ struct MediaRetentionTests {
func defaultRetentionIsSevenDays() {
#expect(BLEIncomingFileStore.defaultMediaRetention == 7 * 24 * 60 * 60)
}
#if os(iOS)
/// Media was the one persistence layer that never stated a protection
/// class at its write site, so payloads inherited the container
/// default. Saves must survive the added write option,
/// and on device the class must read back. The simulator's filesystem
/// does not model data protection (the attribute reads back nil there),
/// so the readback assertion is device-only.
@Test
func savedMediaSurvivesExplicitProtectionClass() throws {
let root = makeRoot()
defer { try? FileManager.default.removeItem(at: root) }
let store = BLEIncomingFileStore(baseDirectory: root)
let payload = Data([0xFF, 0xD8, 0xFF, 0xD9])
let saved = try #require(store.save(
data: payload,
preferredName: "note.m4a",
subdirectory: "voicenotes/incoming",
fallbackExtension: "m4a",
defaultPrefix: "voice"
))
#expect(try Data(contentsOf: saved) == payload)
#if !targetEnvironment(simulator)
let protection = try FileManager.default.attributesOfItem(
atPath: saved.path
)[.protectionKey] as? FileProtectionType
#expect(protection == .completeUntilFirstUserAuthentication)
#endif
}
/// Files written before payloads carried an explicit class are stamped
/// by the launch-time migration that follows the retention sweep: the
/// directory plus each resident file, without error. In-flight live
/// captures are left alone, exactly as the sweep leaves them: the
/// coordinator may still be writing to one through an open FileHandle,
/// and new captures receive the class at creation. Readback is device-only for the same
/// reason as above.
@Test
func migrationStampsPreexistingMediaAndSkipsLiveCaptures() throws {
let root = makeRoot()
defer { try? FileManager.default.removeItem(at: root) }
let store = BLEIncomingFileStore(baseDirectory: root)
let incoming = try store.incomingDirectory(subdirectory: "voicenotes/incoming")
let legacy = try write(
"received.m4a",
in: incoming,
modified: Date(timeIntervalSinceNow: -60)
)
_ = try write(
"\(BLEIncomingFileStore.liveCapturePrefix)00112233445566ff_dm.aac",
in: incoming,
modified: Date(timeIntervalSinceNow: -60)
)
// Exactly the directory itself plus the legacy file; strict equality
// is what proves the live capture was not stamped.
#expect(store.migrateFileProtectionIfNeeded() == 2)
#expect(FileManager.default.fileExists(atPath: legacy.path))
#if !targetEnvironment(simulator)
let protection = try FileManager.default.attributesOfItem(
atPath: legacy.path
)[.protectionKey] as? FileProtectionType
#expect(protection == .completeUntilFirstUserAuthentication)
#endif
}
/// A store with no media on disk has nothing to stamp.
@Test
func migrationWithNoMediaIsANoOp() {
let root = makeRoot()
defer { try? FileManager.default.removeItem(at: root) }
let store = BLEIncomingFileStore(baseDirectory: root)
#expect(store.migrateFileProtectionIfNeeded() == 0)
}
#endif
}

View File

@ -0,0 +1,46 @@
import Foundation
import Testing
@testable import bitchat
struct MessageClipboardTests {
@Test func quoteWrapsEachLineAndAddsSender() {
let quoted = MessageClipboard.quoteForComposer("hello\nworld", sender: "alice")
#expect(quoted == "> alice:\n> hello\n> world\n\n")
}
@Test func quoteSkipsSystemSenderHeader() {
let quoted = MessageClipboard.quoteForComposer("joined", sender: "system")
#expect(quoted == "> joined\n\n")
}
@Test func emptyContentYieldsEmptyQuote() {
#expect(MessageClipboard.quoteForComposer(" ", sender: "alice").isEmpty)
}
@Test func appendQuotePreservesExistingDraft() {
let result = MessageClipboard.appendQuote(
to: "already typing",
content: "prior message",
sender: "bob"
)
#expect(result == "already typing\n> bob:\n> prior message\n\n")
}
@Test func appendQuoteOntoEmptyDraft() {
let result = MessageClipboard.appendQuote(
to: "",
content: "solo",
sender: "carol"
)
#expect(result == "> carol:\n> solo\n\n")
}
@Test func appendQuoteAfterTrailingSpaceStartsNewLine() {
let result = MessageClipboard.appendQuote(
to: "draft with space ",
content: "quoted",
sender: "dana"
)
#expect(result == "draft with space \n> dana:\n> quoted\n\n")
}
}

View File

@ -542,6 +542,9 @@ struct ViewSmokeTests {
])
try? await Task.sleep(nanoseconds: 50_000_000)
// ContentView + people sheet must mount with the full feature-model
// set (peerList / publicChat / privateInbox included). Missing any of
// those crashes the NavigationStack sheet on some iOS versions (#1558).
_ = mount(installSmokeEnvironment(ContentView(), featureModels: featureModels))
_ = mount(installSmokeEnvironment(ContentPeopleSheetHarness(), featureModels: featureModels))
@ -800,18 +803,29 @@ struct ViewSmokeTests {
#expect(deliveryStatusSnapshot(of: mediaRow) == read)
}
#if os(iOS)
@Test
func cameraScannerView_previewAndCoordinatorSmoke() {
#if os(iOS) || os(macOS)
// Avoid constructing AVCaptureDeviceInput (and the TCC prompt it can
// trigger) unless the host process already has camera authorization
// same class of isolation as keeping tests off the login keychain.
let status = AVCaptureDevice.authorizationStatus(for: .video)
let preview = CameraScannerView.PreviewView(frame: .zero)
let coordinator = CameraScannerView.Coordinator()
let coordinator = CameraScannerCoordinator()
#if os(iOS)
_ = CameraScannerView.PreviewView.layerClass
#elseif os(macOS)
preview.layout()
#endif
_ = preview.videoPreviewLayer
coordinator.setup(sessionOwner: preview) { _ in }
coordinator.setActive(false)
if status == .authorized {
coordinator.setup(previewLayer: preview.videoPreviewLayer) { _ in }
coordinator.setActive(false)
}
#expect(preview.videoPreviewLayer.videoGravity == .resizeAspectFill)
#endif
}
#endif
}

View File

@ -10,10 +10,41 @@ import Foundation
import Testing
@testable import bitchat
/// One-shot event that bridges synchronous production seams to async tests
/// without blocking a shared dispatch worker while waiting for the seam.
private final class VoiceRecorderAsyncEvent: @unchecked Sendable {
private let lock = NSLock()
private var isSignaled = false
private var waiters: [CheckedContinuation<Void, Never>] = []
func wait() async {
await withCheckedContinuation { continuation in
let resumeImmediately = lock.withLock { () -> Bool in
guard !isSignaled else { return true }
waiters.append(continuation)
return false
}
if resumeImmediately {
continuation.resume()
}
}
}
func signal() {
let continuations = lock.withLock { () -> [CheckedContinuation<Void, Never>] in
guard !isSignaled else { return [] }
isSignaled = true
defer { waiters.removeAll() }
return waiters
}
continuations.forEach { $0.resume() }
}
}
private final class VoiceRecorderTestSession: SessionApplying, @unchecked Sendable {
private let lock = NSLock()
private let activationGate = DispatchSemaphore(value: 0)
private let activationBeganGate = DispatchSemaphore(value: 0)
private let activationBegan = VoiceRecorderAsyncEvent()
private let shouldGateFirstActivation: Bool
private var gatedFirstActivation = false
private var _activationCalls: [Bool] = []
@ -34,23 +65,13 @@ private final class VoiceRecorderTestSession: SessionApplying, @unchecked Sendab
return true
}
if shouldWait {
activationBeganGate.signal()
activationBegan.signal()
activationGate.wait()
}
}
func waitUntilActivationBegan(
timeout: DispatchTimeInterval = .seconds(5)
) async -> Bool {
await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
continuation.resume(
returning: self.activationBeganGate.wait(
timeout: DispatchTime.now() + timeout
) == .success
)
}
}
func waitUntilActivationBegan() async {
await activationBegan.wait()
}
func resumeActivation() {
@ -155,7 +176,7 @@ private final class TestVoiceAudioRecorderFactory: VoiceAudioRecorderCreating {
/// this remains deterministic when the full test suite saturates the executor.
private final class VoiceRecorderPaddingGate: @unchecked Sendable {
private let lock = NSLock()
private let enteredGate = DispatchSemaphore(value: 0)
private let entered = VoiceRecorderAsyncEvent()
private var isOpen = false
private var openWaiters: [CheckedContinuation<Void, Never>] = []
@ -166,25 +187,15 @@ private final class VoiceRecorderPaddingGate: @unchecked Sendable {
openWaiters.append(continuation)
return false
}
enteredGate.signal()
entered.signal()
if resumeImmediately {
continuation.resume()
}
}
}
func waitUntilEntered(
timeout: DispatchTimeInterval = .seconds(5)
) async -> Bool {
await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
continuation.resume(
returning: self.enteredGate.wait(
timeout: DispatchTime.now() + timeout
) == .success
)
}
}
func waitUntilEntered() async {
await entered.wait()
}
func open() {
@ -223,7 +234,7 @@ struct VoiceRecorderTests {
let owner = VoiceRecorder.RecordingOwner()
let startTask = Task { try await voiceRecorder.startRecording(owner: owner) }
#expect(await session.waitUntilActivationBegan())
await session.waitUntilActivationBegan()
await voiceRecorder.cancelRecording(owner: owner)
session.resumeActivation()
@ -321,7 +332,7 @@ struct VoiceRecorderTests {
try await finishingHold.start()
let firstURL = try #require(factory.urls.first)
let finishTask = Task { await finishingHold.finish() }
#expect(await paddingGate.waitUntilEntered())
await paddingGate.waitUntilEntered()
await #expect(throws: VoiceRecorder.RecorderError.recordingInProgress) {
try await rejectedHold.start()

342
docs/PEER-ID-ROTATION.md Normal file
View File

@ -0,0 +1,342 @@
# Peer ID Rotation Specification
**Status:** Draft for cross-platform review. The derivations and the wire format **are implemented and tested**; nothing is wired into the shipping mesh.
**Audience:** bitchat iOS and bitchat Android maintainers.
**Requires agreement before going further.** This changes the wire protocol, so neither platform can ship it alone.
**Where the code is:**
| Piece | File |
|---|---|
| Epochs, ID derivation, recognition tags, tag block, binding message | `localPackages/BitFoundation/Sources/BitFoundation/PeerIDRotation.swift` |
| `announceV2 = 0x2C` wire format | `localPackages/BitFoundation/Sources/BitFoundation/AnnounceV2Packet.swift` |
| Executable test vectors | `localPackages/BitFoundation/Tests/BitFoundationTests/PeerIDRotationTests.swift` |
| Wire-format tests | `localPackages/BitFoundation/Tests/BitFoundationTests/AnnounceV2PacketTests.swift` |
The code is deliberately an **opinionated working base, not a finished feature**. Every number and context string in it is a concrete proposal you can disagree with by changing one function and watching a test vector move. What is *not* implemented is the part that carries risk: nothing emits a v2 announce, and `BLEService` parses the type and explicitly ignores it, because consuming it needs both the replacement identity binding (§4.5) and a decision on how unverified presence appears in the peer list (O4).
Three policy decisions were forced by the compiler when the new message type was added, and are worth reviewing as part of this:
- **Not gossip-synced** (`SyncTypeFlags`). Syncing presence would defeat the purpose: a device never in radio range could collect tag blocks, turning a local beacon into a network-wide one.
- **Not padded** (`BLEOutboundPacketPolicy`). 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; making the capability and geohash fields fixed-width would be cheaper than padding. Open for argument.
- **Parsed but ignored** on receive (`BLEService`), as above.
---
## 1. The problem
Today a passive listener with a BLE dongle, standing in a crowd, can do the following with no cryptographic attack and no active participation:
1. **Detect that a phone is running bitchat.** The service UUID is a fixed constant.
2. **Assign that phone a permanent identifier.** The 8-byte sender ID in every packet header is `SHA-256(noiseStaticPublicKey)[0..8]`, and the Noise static key is generated once and kept in the keychain. It does not rotate. Same phone, same bytes, next week, next city.
3. **Learn the phone's long-term public keys and its self-chosen nickname.** The announce carries the 32-byte Noise static key, the 32-byte Ed25519 signing key, and the nickname, all in cleartext, re-broadcast every 430 seconds and on demand to anything that connects and subscribes.
4. **Reconstruct who was standing near whom.** The announce also carries up to ten neighbour IDs, so one receiver gets the local adjacency graph without needing several receivers or signal-strength trilateration.
For the people this app is explicitly built for, (2) and (4) are the dangerous ones. A protest attendee's phone announces a stable pseudonym and its social graph to anyone within radio range.
iOS BLE address randomization does not help. It randomizes the link-layer address underneath an application layer that publishes a stable identifier above it.
**The correction that matters most:** rotating the peer ID *alone* accomplishes nothing. As long as the announce carries the static keys in cleartext, a rotated ID is re-linked to the same device on its first announce. Rotation and announce confidentiality have to land together or not at all.
## 2. Goals and non-goals
**Goals**
- **G1.** A passive listener cannot link two observations of the same device across rotation periods.
- **G2.** A passive listener cannot learn a device's long-term identity keys or nickname.
- **G3.** Peers who already know each other (mutual favourites) still recognise each other automatically, without an interactive handshake, so existing UX does not regress.
- **G4.** Strangers can still discover and handshake, so the mesh still forms among people who have never met.
- **G5.** Old and new clients interoperate. A mixed mesh keeps working, in both directions, with no flag day.
- **G6.** Rotation does not make identity spoofing easier than it is today.
**Non-goals, explicitly out of scope here**
- Hiding *that* bitchat is in use. The service UUID is a separate problem; BLE requires something discoverable. Tracked separately.
- Traffic-analysis resistance in general: padding coverage, send-time jitter, TTL randomization, and the neighbour-list leak each need their own change. Rotation does not fix them and they do not fix rotation.
- Resistance to an active attacker who connects and completes a handshake. Anyone you handshake with learns your identity; that is what a handshake is for.
## 3. What currently binds an identity, and why rotation breaks it
This is the part most likely to be underestimated, so it is stated precisely.
`peerID == SHA-256(noiseStaticPublicKey)[0..8]` is not merely a convention. It is **the mechanism that makes peer IDs unforgeable**, and it is enforced in two places:
**Announce preflight** — `BLEAnnounceHandlingPolicy.swift:32-35`:
```swift
let derivedPeerID = PeerID(publicKey: announcement.noisePublicKey)
guard derivedPeerID == peerID else { return .reject(.senderMismatch(derivedPeerID: derivedPeerID)) }
```
**Handshake completion** — `NoiseSessionManager.swift:1106-1122`:
```swift
private func authenticatedRemoteKey(_ remoteKey: Curve25519.KeyAgreement.PublicKey,
matches claimedPeerID: PeerID) -> Bool {
let rawKey = remoteKey.rawRepresentation
if claimedPeerID.isShort { return PeerID(publicKey: rawKey) == claimedPeerID }
}
```
Failure throws `NoiseSessionError.peerIdentityMismatch`.
If the peer ID becomes independent of the key, **both checks fail for every peer** and there is nothing left proving that a sender ID belongs to the sender. Any rotation design must therefore ship a *replacement* binding in the same change. This is why the work is a protocol revision and not a patch.
Note also what the existing announce signature does and does not prove. The packet signature covers the sender ID (`BitchatPacket.toBinaryDataForSigning()` zeroes only TTL and the RSR flag), but it is verified against the Ed25519 key carried *inside the same announce* — a self-signature. The code says so plainly (`BLEAnnounceHandlingPolicy.swift:94-103`): an attacker can replay a victim's peer ID and Noise key with their own signing key and a valid self-signature, and only trust-on-first-use pinning of the signing key stops it. So today's binding is "derived ID + TOFU", and a replacement must be at least that strong.
## 4. Design
### 4.1 Epochs
Rotation is on a wall-clock schedule so that two devices that have never met agree on the current period without negotiation.
```
epoch = floor(unixTimeSeconds / ROTATION_PERIOD)
ROTATION_PERIOD = 3600 (1 hour, proposed — see open question O1)
```
`epoch` is a `UInt32`, big-endian wherever it is hashed. Implementations MUST accept `epoch-1`, `epoch`, and `epoch+1` when matching (the ±1 window absorbs clock skew and boundary crossings), following the precedent already set by courier recipient tags (`CourierEnvelope.candidateTags`).
### 4.2 The rotating peer ID
```
K_rot = HKDF-SHA256(ikm: noiseStaticPrivateKey,
salt: "",
info: "bitchat-peer-rotation-v1",
length: 32)
peerID_e = HMAC-SHA256(key: K_rot,
message: "bitchat-peer-id-v2" || uint32be(epoch))[0..8]
```
Properties:
- Derived from the **private** key, so no observer can compute or predict it, and two epochs' IDs are unlinkable.
- Deterministic, so the device recomputes the same ID after a restart within the same epoch.
- Still 8 bytes, so the packet header layout is unchanged.
**It must be derived from private key material.** Deriving from the *public* key would let anyone who has ever seen that key compute every past and future ID, which is worse than doing nothing because it would look like protection. This mistake already exists in the codebase: `CourierEnvelope.recipientTag` is `HMAC(key: recipient's **public** static key, epochDay)`, and since that public key is broadcast in cleartext today, any observer in radio range can compute a peer's courier tags for any day. The whitepaper's claim that couriers "cannot link it across days" does not currently hold. Fixing that is out of scope here but should be tracked; do not copy the pattern.
### 4.3 Recognising peers you already know
With the static keys off the air, mutual favourites need another way to spot each other. Each announce carries a set of **pairwise recognition tags**. For a device A announcing under `peerID_e` to mutual favourite B:
```
S_AB = X25519(A_noiseStaticPrivate, B_noiseStaticPublic) // == X25519(B_priv, A_pub)
K_AB = HKDF-SHA256(ikm: S_AB, salt: "", info: "bitchat-recognition-v1", length: 32)
tag_A→B = HMAC-SHA256(key: K_AB,
message: uint32be(epoch)
|| A_noiseStaticPublic (32)
|| B_noiseStaticPublic (32)
|| peerID_e (8))[0..8]
```
A includes `tag_A→B` in its announce. B computes the same value independently — it holds the same shared secret and both public keys — and matches it against inbound announces. Only A and B can compute it, because it needs one of the two private keys.
Two properties of that MAC input are load-bearing, and an earlier draft of this document got both wrong. They were caught in review of #1487, which is the argument for shipping the code alongside the prose.
**Ordered keys make the tag directional.** The earlier form was `HMAC(K_AB, epoch)`, which is symmetric: A and B would broadcast the *identical* 8 bytes. An observer who saw one value appear in two different announces would learn that those two devices are mutual favourites, and could link their two rotating IDs to each other — handing over precisely the social graph this design exists to hide, and providing a cross-epoch correlation handle. Ordering the keys yields distinct A→B and B→A values, and both parties can still compute both directions because both hold both public keys.
**`peerID_e` binds the tag to the announce carrying it.** Without it a tag depends only on (pair, epoch), so an attacker could lift A's tag out of a recorded announce and replay it in a fresh announce under an ID of their own choosing; B would match and treat that ID as A. Because `epoch-1` is also accepted, the spoof would stay usable into the following period. Binding to the ID reduces this from impersonation-as-any-ID to replaying A's own presence.
**Residual risk, unfixable while announces are unsigned:** an attacker can rebroadcast A's exact announce within the epoch window, making A appear present when absent. Recognition is therefore a **hint only**. A match may populate presence, but anything consequential — routing a DM, showing a verified badge — MUST wait for a completed handshake whose static key equals the favourite that produced the match. See O4.
Rules:
- Tags are **unordered**. Implementations MUST NOT infer anything from position.
- The tag list MUST be padded with uniform random 8-byte values to a fixed count `TAG_SLOTS = 8`, so the number of tags does not disclose how many mutual favourites a device has. Random padding is indistinguishable from a real tag to anyone who cannot compute it.
- With more than `TAG_SLOTS` mutual favourites, a device MUST rotate which favourites occupy the slots across successive announces so all of them eventually see a tag. (Selection strategy is an implementation detail; convergence is not — see O2.)
- A device MUST NOT include a tag for a one-directional favourite, since that would disclose interest to someone who has not reciprocated.
### 4.4 Strangers
Nothing identifying is broadcast for strangers. Discovery still works:
1. A hears an announce from unknown `peerID_e` advertising the rotation capability.
2. A initiates Noise **XX** to that ID.
3. In XX, the responder's static key is sent in message 2 *after* `ee`, and the initiator's in message 3 — both encrypted. A passive observer learns neither.
4. On completion, both sides learn the peer's real static key and fingerprint, exactly as they do today (`handleSessionEstablished`), and the existing `AuthenticatedPeerStatePacket` (Noise payload `0x21`) carries the Ed25519 signing key and capability claims *inside* the session, where they are proven rather than asserted.
So the model becomes **handshake first, identify second**, for anyone who is not already a mutual favourite.
### 4.5 The replacement binding
Inside the completed handshake, each side proves that the rotating ID it was using belongs to its static key:
```
proof = Ed25519-Sign(signingPrivateKey,
"bitchat-peerid-binding-v1"
|| uint32be(epoch)
|| peerID_e (8 bytes)
|| noiseStaticPublicKey (32 bytes))
```
Sent as a new TLV in `AuthenticatedPeerStatePacket`, whose existing structure already carries a version byte, a canonicality-checked capability TLV, and the 32-byte signing key. The receiver verifies:
- **that the `noiseStaticPublicKey` inside the proof is byte-equal to the remote static key the Noise session actually established** — see below, this one is load-bearing, and
- the signature against the signing key in the same packet, **and**
- that the signing key matches whatever it has already pinned for this fingerprint, using the existing trust ladder (authenticated key, then TOFU pin), and
- that `peerID_e` equals the ID the session was actually conducted under, and
- that `epoch` is within the ±1 window.
An earlier draft of this list omitted the first check, which left a hole worth spelling out because it is the kind that survives review. The proof is a self-contained signed blob: nothing in the signature ties it to *the session it arrives on*. So a peer M who has observed A's proof — it travels inside a session, but M can be a peer A legitimately talked to — could replay A's proof verbatim inside M's own session with B. Without the static-key check, B verifies A's signature successfully, sees a well-formed binding, and on **first contact** TOFU-pins A's signing key against M's fingerprint. From then on B attributes M's identity to A's key. Comparing the proof's static key against the key the handshake actually produced closes it: M cannot substitute A's key without also being A.
This replaces `authenticatedRemoteKey`'s derivation check with an explicit signed statement. With the static-key check present it is strictly stronger than today's self-signed announce, because the signing key is checked against a pin rather than taken from the same message. Without it, it is weaker — a reminder that "signed" and "bound to this conversation" are different properties.
Note the canonical-bytes helper for this already half-exists: `NoiseEncryptionService.buildAnnounceSignature` / `verifyAnnounceSignature` / `canonicalAnnounceBytes`, with context `"bitchat-announce-v1"`, are present but unreferenced in production (only tests call them). They sign `context‖peerID(8)‖noiseKey(32)‖ed25519Key(32)‖nickname‖timestampMs`. The binding above is deliberately a **different context string** and a different field set, so the two can never be confused; the dead code should be deleted or repurposed explicitly rather than silently reused.
### 4.6 The announce, before and after
**Today** (`AnnouncementPacket`, TLVs in `Packets.swift:33-40`), all cleartext:
| T | Field | Width |
|---|---|---|
| `0x01` | nickname | var |
| `0x02` | Noise static public key | 32 |
| `0x03` | Ed25519 signing public key | 32 |
| `0x04` | direct neighbours | N × 8, max 10 |
| `0x05` | capabilities | 18 |
| `0x06` | bridge geohash | var |
`0x01`, `0x02`, `0x03` are **required** by the decoder (`Packets.swift:147`).
**Proposed v2 announce.** Because the existing decoder hard-requires the three identity TLVs, a v2 announce cannot simply omit them — that is a parse failure, not a graceful degrade. It therefore needs a distinct message type: **`announceV2 = 0x2C`**.
An earlier draft proposed `0x05` on the grounds that it is unassigned today and sits next to `announce = 0x01`. That was wrong. `0x05` has already been recycled twice — `announce`, then `bulkTransferResponse`, then `fragmentStart` until #446 — so a sufficiently old peer may still map it to a fragment header and misparse presence as a partial message. Values above `voiceFrame = 0x29` have only ever been allocated forward, which is the safe direction; `0x2A`/`0x2B` are spoken for by the courier spray-ack work, leaving `0x2C`. Verified never used anywhere in this repository's history (see O3).
TLVs, all cleartext but none identifying:
| T | Field | Width | Notes |
|---|---|---|---|
| `0x01` | epoch | 4 | `uint32be`; lets a receiver match without guessing |
| `0x02` | recognition tags | `TAG_SLOTS` × 8 = 64 | unordered, random-padded |
| `0x03` | capabilities | 18 | same minimal-LE encoding as today |
| `0x04` | bridge geohash | ≤12 | unchanged semantics |
Deliberately absent: nickname, both public keys, neighbour list.
Worth noting because it is counter-intuitive: **the v2 announce is smaller than the v1 announce**, despite carrying 64 bytes of tags. A v1 announce with a 10-byte nickname and a full neighbour list is roughly 165 payload bytes plus a 64-byte signature; a v2 announce is roughly 75 bytes and unsigned. Dropping two 32-byte keys, the neighbour list, and the signature more than pays for the tag block, so this reduces airtime rather than adding to it.
- **Nickname** moves inside the session (`AuthenticatedPeerStatePacket`). A nickname is a self-chosen, often reused human label; broadcasting it in cleartext is a linkage vector on its own.
- **Neighbour list** is dropped entirely. It exists to seed source routing, and its documented fallback is flooding. Publishing the adjacency graph of a crowd is not a reasonable price for routing efficiency. (Dropping it is independently backward compatible — the TLV is optional on decode — and can ship ahead of this spec.)
**The v2 announce is unsigned.** This is a real trade-off and needs review (O4). There is no key to verify a signature against without disclosing one, so a v2 announce asserts nothing except "somebody is here, and here are some tags". Consequences:
- An attacker can emit v2 announces with arbitrary IDs and random tags — cheap peer-list noise. This is bounded by the existing announce rate limiting, per-central subscription limiting, and connection rate limits, but it is weaker than today.
- An attacker **cannot** impersonate a specific known peer, because it cannot compute that peer's recognition tags without one of the two private keys.
- An attacker cannot get a Noise session, so it cannot send messages, only occupy a peer-list slot.
Mitigation for review: treat a v2 announce as *unverified presence* only, and do not surface it in the peer list until either a recognition tag matches or a handshake completes. That preserves today's property that the peer list reflects authenticated peers.
## 5. Compatibility and rollout
The repo already has the two mechanisms this needs, both proven in production.
**Capability bit.** `PeerCapabilities` is a `UInt64` `OptionSet` with minimal little-endian wire encoding, at least one byte, so "no TLV" and "empty set" stay distinguishable. Crucially `BLEPeerRegistry.capabilitiesWereExplicitlyAdvertised(for:)` distinguishes *old client that sent no TLV* from *new client with the bit off*. Add `peerIDRotation` at the next free bit — **bit 14** at the time of writing: bit 10 is burned and MUST NOT be reused, bit 11 is claimed by the Nostr double-ratchet work (#1107), bit 12 by courier spray receipts (#1438), and bit 13 is reserved for stickers (#1544). Re-check the claim table in `PeerCapabilities.swift` before assigning; whichever platform implements first pins the number in a shared test vector.
**Observed-version gating.** `MeshTopologyTracker.recordObservedVersion(_:for:)` records the highest protocol version seen from each node, and `computeRoute(…, requiringVersion:)` refuses paths through nodes not observed at that version. `docs/SOURCE_ROUTING.md` records this as the shipped pattern for a compatible rollout. The same shape applies here.
**Phased plan.**
| Phase | Behaviour |
|---|---|
| 1 | Both platforms ship the ability to **parse** v2 announces and advertise the capability, while still sending v1. Purely additive; a v2 announce from a test build is understood rather than dropped. |
| 2 | Send v1 **and** v2 announces, alternating. New clients prefer v2 and ignore the v1 from a peer they have recognised via v2; old clients see only the v1. Costs airtime, buys a no-flag-day transition. |
| 3 | Once telemetry-free judgement says adoption is sufficient, a setting (default on) suppresses v1 announces. A device that suppresses v1 becomes invisible to old clients — that is the intended cost of unlinkability, and it must be stated in the UI, not buried. |
During phases 23 a device runs **both** a stable v1 ID and a rotating v2 ID. They must never appear as two peers; a peer recognised by both paths has to collapse to one entry. The repo has the beginnings of this in `MessageRouter.peerIDAliases` and `ChatPeerIdentityCoordinator.migrateChatState`, but they were built for panic-reset rotation, not steady-state rotation.
## 6. Impact inventory
This is what an implementer must handle. Every item below was verified against the iOS source; Android should expect its own equivalents.
### 6.1 Must be fixed or the feature is broken
| Area | Why | iOS reference |
|---|---|---|
| **Handshake identity check** | `authenticatedRemoteKey` re-derives the ID from the static key and fails for every peer once IDs are independent. Replace with §4.5. | `NoiseSessionManager.swift:1106-1122`, enforced `:714-718` |
| **Announce preflight** | Same derivation check rejects any announce whose ID is not the key's hash. | `BLEAnnounceHandlingPolicy.swift:32-35` |
| **Sealed message outbox** | Queued DM plaintext is keyed by peer ID on disk and survives app kill. A recipient's rotation orphans their queue. Needs re-keying by **fingerprint** (stable) with the peer ID as a lookup hint. This is the single worst offender. | `MessageOutboxStore.swift:66`, `:704-707`, `:746` |
| **Private-media durable IDs** | `stableID` hashes sender and recipient short IDs, and the durable receipt ledger keys accept/tombstone records on it. Rotation silently breaks dedup **and user deletion tombstones**, so deleted media could be re-accepted. | `BitchatFilePacket.swift:183-231`, `BLEPrivateMediaReceiptStore.swift` |
| **Initiator tie-break** | Crossed-initiation resolution compares `localPeerID < peerID`. Both sides must reach the same verdict; a rotation mid-negotiation flips it asymmetrically. Needs a rotation-stable comparison key (fingerprint). | `NoiseSessionManager.swift:83`, `:569`, `:582`, `:603` |
| **Fingerprint-prefix lookups** | Several paths recover a peer from `fingerprint.hasPrefix(peerID)`. These silently return empty, and one of them is what lets a public message from a not-yet-registered peer be accepted at all. | `SecureIdentityStateManager.swift:437-444`; `ChatGroupCoordinator.swift:98-102`, `:432`; `FavoritesPersistenceService.swift:188-195`; `BLEService.swift:2552`, `:2823` |
| **`PeerID.routingData`** | Falls back to `toShort()`, i.e. fingerprint-derived routing bytes. | `PeerID.swift:190-202` |
### 6.2 Degrades gracefully but needs handling
| Area | Effect | iOS reference |
|---|---|---|
| **Noise sessions** | A rotation mid-session leaves an established session under the old ID. Rotation should either be deferred while sessions are live or migrate them explicitly. | `NoiseEncryptionService.swift:1010-1019` |
| **Fragment reassembly** | The reassembly key mixes the 8-byte sender ID, so a rotation mid-transfer strands every in-flight assembly until the 30 s timeout. Defer rotation while fragments are in flight. | `BLEFragmentAssemblyBuffer.swift:4-47` |
| **Dedup LRU** | Keys embed the sender ID, so the same packet crossing a rotation boundary can be reprocessed once. Bounded and probably acceptable. | `BLEReceivePipeline.swift:21` |
| **Source routes / topology** | A remote rotation invalidates cached adjacency, and a rotated relay no longer finds itself in an in-flight v2 route, falling back to flooding. Already the documented fallback. | `MeshTopologyTracker.swift`, `BLERouteForwardingPolicy.swift:62` |
| **Gossip archive** | Archived raw packets keep the old sender ID forever, and packet IDs are sender-derived, so attribution and purge-by-peer break for pre-rotation history. | `GossipMessageArchive.swift`, `PacketIdUtil.swift:8-17` |
| **Read receipts** | The wire receipt carries an 8-byte `readerID`; one sent before and matched after a rotation will not correlate. | `ReadReceipt.swift:47-64` |
### 6.3 Already safe — no work needed
Keyed by fingerprint, Noise key, or Ed25519 key rather than peer ID: the identity cache and every map in it (social identities, verified fingerprints, vouches, blocks), favourites (keyed by Noise static key), courier envelopes and recipient tags, prekey bundles, board posts, bridge drop dedup, group rosters, vouch attestations, and all geohash/location state (keyed by Nostr pubkey). Peer registry, link state, and all Noise session maps are in-memory and session-scoped.
## 7. Test vectors
These live as assertions in `PeerIDRotationTests.swift`, so they run on every build rather than rotting in a table.
All three were **cross-checked against an independent implementation written from this document alone** — Python `hmac`/`hashlib`, HKDF as extract-then-expand with an empty salt — and matched byte for byte. That is the property that matters: the spec text is sufficient to reproduce the numbers without reading the Swift.
With `noiseStaticPrivateKey = 0102…20` (bytes 1 through 32):
```
rotationSecret = HKDF-SHA256(ikm: 0102…20, salt: <empty>,
info: "bitchat-peer-rotation-v1", len: 32)
= fb82dfec0c0a2a4677beca44e2f72c80e7c5de773dd5fce6ee47af83d3c25f09
peerID(epoch=100) = HMAC-SHA256(rotationSecret,
"bitchat-peer-id-v2" || uint32be(100))[0..8]
= f7c08c528506a374
```
With a recognition key derived from a shared secret of 32 × `0x42`, sender key
32 × `0x0A`, recipient key 32 × `0x0B`, and announced ID 8 × `0xA1`:
```
recognitionKey = HKDF-SHA256(ikm: 42×32, salt: <empty>,
info: "bitchat-recognition-v1", len: 32)
tag_A→B(epoch=100) = HMAC-SHA256(recognitionKey,
uint32be(100) || 0A×32 || 0B×32 || A1×8)[0..8]
= 4568f61d61d6cbfb
tag_B→A(epoch=100) (same key, keys swapped)
= 5313c7731f629959
```
Both directions are given because their *difference* is the security property: if
an implementation produces the same value for both, it has reintroduced the
symmetric-tag flaw.
Also asserted, and worth reproducing on Android because they are the properties rather than the numbers: both sides of a real X25519 pair derive the identical tag from opposite key halves; consecutive epochs produce unrelated IDs; the ±1 epoch window matches across a boundary but two epochs out does not; the tag block is always 64 bytes regardless of how many tags it carries; a match is found regardless of slot position; and the binding message is fixed-width so a short input cannot shift a later field into an earlier field's position.
Still to be written jointly: a full `announceV2` packet as a hex blob, and the §4.5 signature over a fixed key. Whichever platform writes a vector, the other MUST reproduce it from this document rather than from the first platform's code.
## 8. Open questions for review
- **O1 — Rotation period.** One hour is a guess balancing unlinkability against churn. Shorter means less linkable and more session/route disruption; longer the reverse. Is there a period that is clearly right, or should it be a build constant both platforms pin?
- **O2 — More than `TAG_SLOTS` favourites.** What is the required convergence guarantee — "every mutual favourite sees a tag within N announces"? Should the slot rotation be deterministic from the epoch so it is testable?
- **O3 — New message type vs. announce version byte.** A distinct `MessageType` is cleanest given the decoder's required TLVs, but it consumes a type value and means two announce paths. Would a version TLV inside the existing type, with the identity TLVs made optional on both platforms first, be preferable?
- **O4 — Unsigned v2 announces.** Binding tags to the announced peer ID removes impersonation-as-any-ID, but a recorded announce can still be rebroadcast verbatim within the epoch window, so a peer can be made to look present when absent. Is "presence is a hint; nothing consequential until a handshake whose static key matches the favourite that produced the match" acceptable? The alternatives are an ephemeral per-epoch signing key with a proof-of-continuity, or a freshness nonce echoed by the recipient — both more machinery and more bytes.
- **O5 — Rotation while a session is live.** Defer rotation until sessions are idle, or rotate and migrate? Deferring is simpler and safer, but a long-lived session pins the ID for its lifetime, which weakens G1 for exactly the people who talk most.
- **O6 — Nickname timing.** Moving the nickname into the session means a stranger's name appears only after a handshake. Is that acceptable UX on both platforms, or does the peer list need a "someone nearby" placeholder state?
- **O7 — Padding is a coordinated change, not a local one.** This started as a question about decoder tolerance and turned into something firmer. `BitchatPacket.toBinaryDataForSigning()` encodes with padding enabled, so **the padding bytes are inside the signed material for every signed packet**. Changing the padding algorithm therefore changes the signed byte stream, and signatures stop verifying against any peer that has not made the identical change. Both outstanding padding fixes are affected: extending coverage beyond `noiseEncrypted`/`noiseHandshake`, and closing the gap where a frame needing more than 255 bytes of padding is emitted unpadded (encoded *frames* of 241256, 497768 and 10091792 bytes ship at exact length today — the arithmetic is over the whole encoded packet that `pad` receives, not the payload alone). Two things to settle: whether Android's decoder also tolerates trailing bytes the way iOS's does (`guard offset <= buf.count`, plus an unpad retry), and whether padding changes ride this protocol revision or get their own capability-gated one.
- **O9 — A seized device recomputes every past peer ID.** `K_rot` is a long-lived secret, so `peerID_e = HMAC(K_rot, epoch)` is computable for *any* epoch by whoever holds it. Someone who seizes a phone, or extracts the Noise static key from a backup, can therefore take historical radio captures and identify which of them were this device — retroactively defeating the unlinkability for every past epoch. Rotation protects against the passive observer, not against later key compromise. A hash ratchet (`K_{e+1} = HKDF(K_e)`, discarding `K_e`) 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 — none of which is free, and all of which interacts with the ±1 window. Worth deciding deliberately rather than inheriting.
## 9. Relationship to other work
Rotation is the largest item in the radio-layer metadata cluster but not the only one, and the others are cheaper:
- **Drop the neighbour list** and **randomize origin TTL** — both landed separately, since neither needs agreement: see the radio-metadata PR.
- **Extend padding beyond Noise frames, and fix the length-marker gap** — only `noiseEncrypted` and `noiseHandshake` are padded, and `pad` silently declines when the required padding exceeds the single-byte marker, so frames well below their bucket ship unpadded. **Not unilateral**: padding is inside the signed bytes, so this needs both platforms. See O7.
None of these substitute for rotation, and rotation does not substitute for them: a device with a rotating ID that still publishes its neighbour list, or that still marks its own originated packets by TTL, remains linkable.

View File

@ -0,0 +1,158 @@
//
// AnnounceV2Packet.swift
// BitFoundation
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
/// Identity-free presence announcement for rotating peer IDs.
///
/// The v1 `AnnouncementPacket` broadcasts, in cleartext, every 430 seconds: the
/// nickname, the 32-byte Noise static public key, the 32-byte Ed25519 signing
/// key, and up to ten neighbour IDs. That is a permanent device fingerprint plus
/// the local social graph, free to anyone in radio range. This carries none of
/// it only an epoch, a fixed-size block of pairwise recognition tags, and
/// capability bits.
///
/// Deliberately absent, with reasons:
/// - **Public keys**: they are the linkage. Peers learn them inside the Noise XX
/// handshake, where they are already encrypted on the wire.
/// - **Nickname**: a self-chosen, frequently reused human label. It moves into
/// the session (`AuthenticatedPeerStatePacket`).
/// - **Neighbour list**: it seeds source routing, whose documented fallback is
/// flooding. Publishing a crowd's adjacency graph is not a reasonable price
/// for routing efficiency.
///
/// **Unsigned, on purpose and not without cost.** There is no key to verify a
/// signature against without disclosing one, so this asserts only "somebody is
/// here, and here are some tags". An attacker can therefore emit noise bounded
/// by existing announce and connection rate limits but cannot impersonate a
/// specific peer, because forging a recognition tag needs one of the two private
/// keys, and cannot send anything without completing a handshake. The intended
/// posture is to treat a v2 announce as *unverified presence* and not surface it
/// until a tag matches or a handshake completes. See open question O4 in
/// `docs/PEER-ID-ROTATION.md`.
///
/// Not emitted or consumed by the shipping mesh yet.
// periphery:ignore - intentionally unreferenced by production code; nothing
// emits or consumes this type yet, and BLEService parses it only to ignore it.
// Delete this annotation when the mesh starts using it.
public struct AnnounceV2Packet: Equatable, Sendable {
/// Rotation epoch this announce was built for. Carried explicitly so a
/// receiver matches against a stated epoch instead of guessing.
public let epoch: UInt32
/// Exactly `PeerIDRotation.tagSlots * PeerIDRotation.idLength` bytes.
public let tagBlock: Data
public let capabilities: PeerCapabilities?
/// Coarse rendezvous cell, when bridging. Same semantics as v1.
public let bridgeGeohash: String?
public init(
epoch: UInt32,
tagBlock: Data,
capabilities: PeerCapabilities? = nil,
bridgeGeohash: String? = nil
) {
self.epoch = epoch
self.tagBlock = tagBlock
self.capabilities = capabilities
self.bridgeGeohash = bridgeGeohash
}
private enum TLVType: UInt8 {
case epoch = 0x01
case tagBlock = 0x02
case capabilities = 0x03
case bridgeGeohash = 0x04
}
/// Expected tag-block width. A fixed size is load-bearing: it hides how many
/// mutual favourites a device has.
public static var tagBlockLength: Int {
PeerIDRotation.tagSlots * PeerIDRotation.idLength
}
public func encode() -> Data? {
guard tagBlock.count == Self.tagBlockLength else { return nil }
var data = Data()
data.append(TLVType.epoch.rawValue)
data.append(UInt8(4))
withUnsafeBytes(of: epoch.bigEndian) { data.append(contentsOf: $0) }
data.append(TLVType.tagBlock.rawValue)
data.append(UInt8(tagBlock.count))
data.append(tagBlock)
if let capabilities {
let bytes = capabilities.encoded()
guard bytes.count <= 255 else { return nil }
data.append(TLVType.capabilities.rawValue)
data.append(UInt8(bytes.count))
data.append(bytes)
}
if let bridgeGeohash, !bridgeGeohash.isEmpty {
let bytes = Data(bridgeGeohash.utf8)
guard bytes.count <= 12 else { return nil }
data.append(TLVType.bridgeGeohash.rawValue)
data.append(UInt8(bytes.count))
data.append(bytes)
}
return data
}
public static func decode(from data: Data) -> AnnounceV2Packet? {
var epoch: UInt32?
var tagBlock: Data?
var capabilities: PeerCapabilities?
var bridgeGeohash: String?
var offset = data.startIndex
while offset < data.endIndex {
guard data.distance(from: offset, to: data.endIndex) >= 2 else { return nil }
let rawType = data[offset]
let length = Int(data[data.index(after: offset)])
let valueStart = data.index(offset, offsetBy: 2)
guard data.distance(from: valueStart, to: data.endIndex) >= length else { return nil }
let value = data.subdata(in: valueStart..<data.index(valueStart, offsetBy: length))
switch TLVType(rawValue: rawType) {
case .epoch:
guard length == 4 else { return nil }
epoch = value.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
case .tagBlock:
guard length == tagBlockLength else { return nil }
tagBlock = value
case .capabilities:
let decoded = PeerCapabilities(encoded: value)
// Canonicality check, matching AuthenticatedPeerStatePacket: a
// non-minimal encoding would let the same capability set travel
// as different bytes.
guard decoded.encoded() == value else { return nil }
capabilities = decoded
case .bridgeGeohash:
guard length <= 12, let text = String(data: value, encoding: .utf8) else { return nil }
bridgeGeohash = text
case nil:
// Unknown TLV: skip, for forward compatibility.
break
}
offset = data.index(valueStart, offsetBy: length)
}
guard let epoch, let tagBlock else { return nil }
return AnnounceV2Packet(
epoch: epoch,
tagBlock: tagBlock,
capabilities: capabilities,
bridgeGeohash: bridgeGeohash
)
}
}

View File

@ -40,9 +40,27 @@ public enum MessageType: UInt8 {
// never gossip-synced). Private bursts ride noiseEncrypted instead.
case voiceFrame = 0x29
/// Identity-free presence for rotating peer IDs. Carries an epoch, a fixed
/// block of pairwise recognition tags, and capabilities no nickname, no
/// public keys, no neighbour list. A separate type rather than a version of
/// `announce` because that decoder hard-requires the identity TLVs, so
/// omitting them is a parse failure rather than a graceful degrade.
///
/// `0x2C`, not the seemingly-free `0x05`: that value has been recycled
/// twice already (`announce`, then `bulkTransferResponse`, then
/// `fragmentStart` until #446), and a very old peer that still maps it to a
/// fragment header would misparse presence as a partial message. Values
/// after `voiceFrame` have only ever been allocated forward. `0x2A`/`0x2B`
/// are spoken for by the courier spray-ack work, hence `0x2C`.
///
/// Not emitted or consumed by the shipping mesh yet; see
/// `docs/PEER-ID-ROTATION.md`.
case announceV2 = 0x2C
public var description: String {
switch self {
case .announce: return "announce"
case .announceV2: return "announceV2"
case .message: return "message"
case .leave: return "leave"
case .courierEnvelope: return "courierEnvelope"

View File

@ -0,0 +1,315 @@
//
// PeerIDRotation.swift
// BitFoundation
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
private import CryptoKit
/// Derivations for rotating peer IDs and pairwise recognition tags.
///
/// See `docs/PEER-ID-ROTATION.md` for the design, the threat model, and the
/// open questions. This type is the executable half of that document: it is
/// deliberately pure (no I/O, no clock of its own, no dependency on the BLE
/// stack) so both platforms can agree on the numbers before anyone wires it
/// into a transport.
///
/// Nothing here is used by the shipping mesh yet.
///
/// ## Why the derivations look like this
///
/// The rotating ID comes from **private** key material. Deriving it from the
/// public key would let anyone who has ever seen that key compute every past
/// and future ID, which is worse than not rotating because it would look like
/// protection. The same mistake is live in `CourierEnvelope.recipientTag`,
/// which is keyed on the recipient's *public* static key and since that key
/// is broadcast in cleartext in every announce today, any observer in radio
/// range can compute a peer's courier tags for any day.
///
/// Recognition tags come from the X25519 shared secret between two static
/// keys, so exactly two parties can compute a given tag and an observer can
/// compute none of them.
// periphery:ignore - intentionally unreferenced by production code. These are
// the reviewable primitives for a protocol change that cannot ship until both
// platforms agree on it; wiring them into the transport is the next step, not
// this one. Delete this annotation when the mesh starts using them.
public enum PeerIDRotation {
// MARK: - Parameters
/// Seconds per rotation epoch. One hour is a starting position, not a
/// settled one: shorter is less linkable but churns sessions, routes, and
/// in-flight fragment reassembly more often. See open question O1.
public static let rotationPeriod: TimeInterval = 3600
/// Bytes of an ID or tag placed on the wire. Matches the existing 8-byte
/// header sender ID, so the packet layout is unchanged.
public static let idLength = 8
/// Fixed number of tag slots in an announce. Padding to a constant hides
/// how many mutual favourites a device has, which is itself identifying.
public static let tagSlots = 8
// MARK: - Context strings
//
// Distinct per use so a value derived for one purpose can never be
// substituted for another. `bitchat-announce-v1` is deliberately NOT reused:
// it belongs to the production-dead announce-signature helpers in
// NoiseEncryptionService, and confusing the two would be a real bug.
private static let rotationInfo = Data("bitchat-peer-rotation-v1".utf8)
private static let peerIDContext = Data("bitchat-peer-id-v2".utf8)
private static let recognitionInfo = Data("bitchat-recognition-v1".utf8)
private static let bindingContext = Data("bitchat-peerid-binding-v1".utf8)
// MARK: - Epochs
/// Epoch number for a point in time. Wall-clock derived so two devices that
/// have never met agree on the current epoch without negotiating.
public static func epoch(at date: Date) -> UInt32 {
let seconds = max(0, date.timeIntervalSince1970)
return UInt32(truncatingIfNeeded: Int(seconds / rotationPeriod))
}
/// Epochs to test when matching, oldest first.
///
/// The ±1 window absorbs clock skew and the moment either side crosses a
/// boundary, mirroring `CourierEnvelope.candidateTags`. Without it, two
/// devices a few seconds apart across a boundary would fail to recognise
/// each other for no reason a person could understand.
public static func candidateEpochs(around date: Date) -> [UInt32] {
let current = epoch(at: date)
return current == 0 ? [0, 1] : [current - 1, current, current + 1]
}
// MARK: - Rotating peer ID
/// Long-lived rotation secret for this device. Derived from the Noise
/// static **private** key, so it never leaves the device and no observer
/// can predict any ID it produces.
public static func rotationSecret(noiseStaticPrivateKey: Data) -> Data {
let derived = HKDF<SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: noiseStaticPrivateKey),
info: rotationInfo,
outputByteCount: 32
)
return derived.withUnsafeBytes { Data($0) }
}
/// This device's peer ID for a given epoch.
public static func peerID(rotationSecret: Data, epoch: UInt32) -> Data {
var message = peerIDContext
message.append(bigEndianBytes(epoch))
let mac = HMAC<SHA256>.authenticationCode(
for: message,
using: SymmetricKey(data: rotationSecret)
)
return Data(mac).prefix(idLength)
}
/// Convenience: the ID this device should be using at `date`.
public static func currentPeerID(noiseStaticPrivateKey: Data, at date: Date) -> Data {
peerID(
rotationSecret: rotationSecret(noiseStaticPrivateKey: noiseStaticPrivateKey),
epoch: epoch(at: date)
)
}
// MARK: - Pairwise recognition tags
/// Symmetric recognition key for a pair, from their X25519 shared secret.
///
/// Both sides compute the identical value from opposite key halves, which
/// is the whole point: recognition needs no round trip, and no third party
/// can derive it.
public static func recognitionKey(sharedSecret: Data) -> Data {
let derived = HKDF<SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: sharedSecret),
info: recognitionInfo,
outputByteCount: 32
)
return derived.withUnsafeBytes { Data($0) }
}
/// The tag `sender` puts in its announce for `recipient` this epoch.
///
/// Three inputs beyond the epoch, each load-bearing:
///
/// - **Ordered keys make the tag directional.** An earlier draft used
/// `HMAC(K_AB, epoch)`, which is symmetric so A and B broadcast the
/// *same* 8 bytes, and an observer who spots one value in two different
/// announces learns those two devices are mutual favourites and can link
/// their rotating IDs to each other. That hands over exactly the social
/// graph this design exists to hide. Ordering the keys gives AB and BA
/// distinct values; both parties can still compute both directions,
/// because both hold both public keys.
/// - **`peerID` binds the tag to the announce carrying it.** Without it the
/// tag depends only on (pair, epoch), so an attacker could lift a tag out
/// of A's announce and replay it under an ID of their choosing; the
/// recipient would match and believe that ID is A. Binding means a lifted
/// tag is only valid alongside A's own ID, which reduces the attack from
/// impersonation-as-any-ID to replaying A's presence.
///
/// Replaying A's own announce within the epoch window remains possible
/// unsigned announces cannot prevent it. Recognition is therefore a hint
/// only, and anything consequential must wait for a completed handshake.
/// See open question O4.
public static func recognitionTag(
recognitionKey: Data,
epoch: UInt32,
senderStaticPublicKey: Data,
recipientStaticPublicKey: Data,
peerID: Data
) -> Data {
var message = bigEndianBytes(epoch)
message.append(fixedWidth(senderStaticPublicKey, 32))
message.append(fixedWidth(recipientStaticPublicKey, 32))
message.append(fixedWidth(peerID, idLength))
let mac = HMAC<SHA256>.authenticationCode(
for: message,
using: SymmetricKey(data: recognitionKey)
)
return Data(mac).prefix(idLength)
}
// MARK: - Tag block
/// Packs tags into the fixed-size announce block, padding with uniform
/// random bytes.
///
/// Random padding is indistinguishable from a real tag to anyone who cannot
/// compute the real ones, so the block discloses neither how many mutual
/// favourites a device has nor which slot belongs to whom. Tags beyond
/// `tagSlots` are dropped here; choosing *which* to carry across successive
/// announces is the caller's problem (open question O2).
public static func tagBlock(
tags: [Data],
randomBytes: (Int) -> Data = Self.secureRandomBytes
) -> Data {
var slots = tags.prefix(tagSlots).map { $0.prefix(idLength) }
// Order must carry no information, so shuffle rather than appending
// real tags at the front.
slots.shuffle()
var block = Data()
for slot in slots {
block.append(slot)
if slot.count < idLength {
block.append(Data(repeating: 0, count: idLength - slot.count))
}
}
let padding = (tagSlots - slots.count) * idLength
if padding > 0 {
block.append(randomBytes(padding))
}
return block
}
/// Splits a received block back into candidate tags.
///
/// Returns nil for a block that is not exactly `tagSlots * idLength`, so a
/// malformed announce is rejected rather than partially interpreted.
public static func tags(fromBlock block: Data) -> [Data]? {
guard block.count == tagSlots * idLength else { return nil }
return stride(from: 0, to: block.count, by: idLength).map {
block.subdata(in: (block.startIndex + $0)..<(block.startIndex + $0 + idLength))
}
}
/// Whether any slot in `block` holds the tag we expect a specific peer to
/// have put there, for an announce carrying `peerID`.
///
/// `senderStaticPublicKey` is the peer we hope sent this (so we compute the
/// direction they would use) and `recipientStaticPublicKey` is our own.
/// Passing them the other way round tests the opposite direction and will
/// not match, which is the point of making tags directional.
///
/// Comparison is constant-time per candidate, and every slot is examined
/// even after a match, so neither the presence of a match nor its slot
/// index is observable through timing.
public static func blockMatches(
_ block: Data,
recognitionKey: Data,
senderStaticPublicKey: Data,
recipientStaticPublicKey: Data,
peerID: Data,
at date: Date
) -> Bool {
guard let slots = tags(fromBlock: block) else { return false }
let expected = candidateEpochs(around: date).map {
recognitionTag(
recognitionKey: recognitionKey,
epoch: $0,
senderStaticPublicKey: senderStaticPublicKey,
recipientStaticPublicKey: recipientStaticPublicKey,
peerID: peerID
)
}
var matched = false
for slot in slots {
for candidate in expected where constantTimeEquals(slot, candidate) {
matched = true
}
}
return matched
}
// MARK: - Identity binding
/// Canonical bytes proving a rotating ID belongs to a static key.
///
/// Signed with the Ed25519 identity key and exchanged **inside** a
/// completed Noise session, this replaces the derivation check that today
/// makes peer IDs unforgeable (`peerID == SHA-256(staticKey)[0..8]`, checked
/// in the announce preflight and again at handshake completion). Once IDs
/// are independent of the key, those checks fail for every peer, so a
/// replacement has to exist before rotation can ship.
///
/// Fixed-width fields throughout: no length prefixes are needed and no two
/// distinct inputs can produce the same bytes.
public static func bindingMessage(
epoch: UInt32,
peerID: Data,
noiseStaticPublicKey: Data
) -> Data {
var out = bindingContext
out.append(bigEndianBytes(epoch))
out.append(fixedWidth(peerID, idLength))
out.append(fixedWidth(noiseStaticPublicKey, 32))
return out
}
// MARK: - Helpers
/// Padding must be indistinguishable from a real tag, so it comes from the
/// system CSPRNG via key generation rather than a general-purpose RNG.
public static func secureRandomBytes(_ count: Int) -> Data {
guard count > 0 else { return Data() }
let key = SymmetricKey(size: SymmetricKeySize(bitCount: count * 8))
return key.withUnsafeBytes { Data($0) }
}
private static func bigEndianBytes(_ value: UInt32) -> Data {
withUnsafeBytes(of: value.bigEndian) { Data($0) }
}
private static func fixedWidth(_ data: Data, _ width: Int) -> Data {
var out = data.prefix(width)
if out.count < width {
out.append(Data(repeating: 0, count: width - out.count))
}
return Data(out)
}
/// Length-independent comparison, so a match cannot be found byte by byte
/// through timing.
private static func constantTimeEquals(_ lhs: Data, _ rhs: Data) -> Bool {
guard lhs.count == rhs.count else { return false }
var difference: UInt8 = 0
for (left, right) in zip(lhs, rhs) {
difference |= left ^ right
}
return difference == 0
}
}

View File

@ -0,0 +1,159 @@
import Foundation
import Testing
@testable import BitFoundation
/// Wire-format tests for the identity-free announce. These are the second half
/// of the cross-platform contract: Android must encode and decode byte-identical
/// packets, so anything asserted here is a promise, not an implementation detail.
struct AnnounceV2PacketTests {
private var block: Data {
Data(repeating: 0xAB, count: AnnounceV2Packet.tagBlockLength)
}
@Test func typeValueIsStable() {
// Changing this breaks every deployed decoder.
//
// Deliberately NOT 0x05, which merely looks free: it has been recycled
// twice already (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; 0x2A/0x2B
// belong to the courier spray-ack work.
#expect(MessageType.announceV2.rawValue == 0x2C)
#expect(MessageType(rawValue: 0x2C) == .announceV2)
#expect(MessageType.announceV2.description == "announceV2")
}
@Test func tagBlockIsSixtyFourBytes() {
#expect(AnnounceV2Packet.tagBlockLength == 64)
}
@Test func roundTripsWithEveryField() throws {
let packet = AnnounceV2Packet(
epoch: 495_555,
tagBlock: block,
capabilities: [.bridge, .prekeys],
bridgeGeohash: "u4pruy"
)
let encoded = try #require(packet.encode())
let decoded = try #require(AnnounceV2Packet.decode(from: encoded))
#expect(decoded == packet)
}
@Test func roundTripsWithOnlyRequiredFields() throws {
let packet = AnnounceV2Packet(epoch: 0, tagBlock: block)
let encoded = try #require(packet.encode())
let decoded = try #require(AnnounceV2Packet.decode(from: encoded))
#expect(decoded == packet)
#expect(decoded.capabilities == nil)
#expect(decoded.bridgeGeohash == nil)
}
@Test func epochIsBigEndianOnTheWire() throws {
let encoded = try #require(AnnounceV2Packet(epoch: 0x0102_0304, tagBlock: block).encode())
// TLV 0x01, length 4, then the epoch most-significant byte first.
#expect(Array(encoded.prefix(6)) == [0x01, 0x04, 0x01, 0x02, 0x03, 0x04])
}
/// The whole point of the format: none of the identifying v1 fields appear.
@Test func encodingCarriesNoIdentity() throws {
let noiseKey = Data(repeating: 0x11, count: 32)
let signingKey = Data(repeating: 0x22, count: 32)
let nickname = Data("alice".utf8)
let encoded = try #require(
AnnounceV2Packet(
epoch: 100,
tagBlock: block,
capabilities: [.bridge],
bridgeGeohash: "u4pruy"
).encode()
)
#expect(!encoded.contains(noiseKey))
#expect(!encoded.contains(signingKey))
#expect(encoded.range(of: nickname) == nil)
}
@Test func encodingIsSmallerThanAV1Announce() throws {
let v2 = try #require(
AnnounceV2Packet(epoch: 100, tagBlock: block, capabilities: [.bridge]).encode()
)
// v1 with a 10-byte nickname and a full neighbour list, before its
// 64-byte signature: nickname 12 + noise 34 + signing 34 + neighbours 82
// + capabilities 3.
let v1PayloadEstimate = 12 + 34 + 34 + 82 + 3
#expect(v2.count < v1PayloadEstimate)
}
// MARK: - Rejection
@Test func encodeRejectsAWrongWidthTagBlock() {
// A short block would disclose the favourite count, so it must never go
// on the wire.
#expect(AnnounceV2Packet(epoch: 1, tagBlock: Data(repeating: 0, count: 63)).encode() == nil)
#expect(AnnounceV2Packet(epoch: 1, tagBlock: Data(repeating: 0, count: 65)).encode() == nil)
#expect(AnnounceV2Packet(epoch: 1, tagBlock: Data()).encode() == nil)
}
@Test func encodeRejectsAnOversizedGeohash() {
#expect(AnnounceV2Packet(
epoch: 1,
tagBlock: block,
bridgeGeohash: String(repeating: "u", count: 13)
).encode() == nil)
}
@Test func decodeRequiresEpochAndTagBlock() throws {
// Capabilities alone is not a valid announce.
var onlyCapabilities = Data([0x03, 0x01])
onlyCapabilities.append(PeerCapabilities([.bridge]).encoded())
#expect(AnnounceV2Packet.decode(from: onlyCapabilities) == nil)
// Epoch without a tag block is not either.
let onlyEpoch = Data([0x01, 0x04, 0x00, 0x00, 0x00, 0x64])
#expect(AnnounceV2Packet.decode(from: onlyEpoch) == nil)
}
@Test func decodeRejectsTruncatedAndMalformedInput() {
#expect(AnnounceV2Packet.decode(from: Data()) == nil)
// Declares 4 bytes, supplies 2.
#expect(AnnounceV2Packet.decode(from: Data([0x01, 0x04, 0x00, 0x00])) == nil)
// Dangling type byte with no length.
#expect(AnnounceV2Packet.decode(from: Data([0x01])) == nil)
// Wrong epoch width.
#expect(AnnounceV2Packet.decode(from: Data([0x01, 0x02, 0x00, 0x64])) == nil)
}
@Test func decodeRejectsAWrongWidthTagBlock() {
var data = Data([0x01, 0x04, 0x00, 0x00, 0x00, 0x64])
data.append(0x02)
data.append(UInt8(63))
data.append(Data(repeating: 0xAB, count: 63))
#expect(AnnounceV2Packet.decode(from: data) == nil)
}
@Test func decodeRejectsNonCanonicalCapabilities() throws {
// Same capability set, non-minimal encoding: it must not be accepted, or
// one set could travel as several distinct byte strings.
var data = Data([0x01, 0x04, 0x00, 0x00, 0x00, 0x64])
data.append(0x02)
data.append(UInt8(AnnounceV2Packet.tagBlockLength))
data.append(block)
data.append(0x03)
data.append(UInt8(3))
data.append(Data([0x80, 0x00, 0x00])) // trailing zero bytes are non-minimal
#expect(AnnounceV2Packet.decode(from: data) == nil)
}
@Test func unknownTLVsAreSkippedForForwardCompatibility() throws {
var data = try #require(AnnounceV2Packet(epoch: 100, tagBlock: block).encode())
data.append(0x7F) // a type this build has never heard of
data.append(UInt8(3))
data.append(Data([0x01, 0x02, 0x03]))
let decoded = try #require(AnnounceV2Packet.decode(from: data))
#expect(decoded.epoch == 100)
#expect(decoded.tagBlock == block)
}
}

View File

@ -0,0 +1,408 @@
import Foundation
import Testing
import CryptoKit
@testable import BitFoundation
/// Executable test vectors for peer ID rotation.
///
/// These are the numbers the Android implementation must reproduce. Two rules
/// for keeping them useful:
///
/// 1. **Reproduce them from `docs/PEER-ID-ROTATION.md`, not from this code.**
/// Deriving the expected values by reading the other platform's
/// implementation proves only that both share a bug.
/// 2. **If a derivation changes, the hex here changes too, deliberately.** A
/// vector that gets "fixed" to match new behavior has stopped being a vector.
///
/// The three `VECTOR:` values below were cross-checked against an independent
/// HKDF/HMAC implementation written from the specification alone (Python
/// `hmac`/`hashlib`, empty salt, extract-then-expand) and matched byte for byte.
/// So the spec text is sufficient to reproduce them without reading this code
/// which is the property Android needs.
struct PeerIDRotationTests {
// A fixed, obviously-fake private key so the vectors are stable.
private let staticPrivateA = Data((0..<32).map { UInt8($0 + 1) }) // 01..20
private let staticPrivateB = Data((0..<32).map { UInt8(0xA0 &+ $0) }) // a0..bf
private func hex(_ data: Data) -> String {
data.map { String(format: "%02x", $0) }.joined()
}
// MARK: - Epochs
@Test func epochIsWallClockDivision() {
#expect(PeerIDRotation.rotationPeriod == 3600)
#expect(PeerIDRotation.epoch(at: Date(timeIntervalSince1970: 0)) == 0)
#expect(PeerIDRotation.epoch(at: Date(timeIntervalSince1970: 3599)) == 0)
#expect(PeerIDRotation.epoch(at: Date(timeIntervalSince1970: 3600)) == 1)
// 2026-07-26T00:00:00Z
#expect(PeerIDRotation.epoch(at: Date(timeIntervalSince1970: 1_784_000_000)) == 495_555)
}
@Test func candidateEpochsCoverTheBoundaryBothWays() {
// Two devices seconds apart across a boundary must still recognise each
// other, so the window spans the neighbouring epochs.
let date = Date(timeIntervalSince1970: 3600 * 100)
#expect(PeerIDRotation.candidateEpochs(around: date) == [99, 100, 101])
}
@Test func candidateEpochsDoNotUnderflowAtTheOrigin() {
// UInt32 underflow here would produce 4294967295 and break matching.
#expect(PeerIDRotation.candidateEpochs(around: Date(timeIntervalSince1970: 0)) == [0, 1])
}
// MARK: - Rotating peer ID
@Test func rotationSecretIsStableForAKey() {
let first = PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateA)
let second = PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateA)
#expect(first == second)
#expect(first.count == 32)
// VECTOR: HKDF-SHA256(ikm: 01..20, salt: empty, info: "bitchat-peer-rotation-v1", 32)
#expect(hex(first) == "fb82dfec0c0a2a4677beca44e2f72c80e7c5de773dd5fce6ee47af83d3c25f09")
}
@Test func peerIDIsEightBytesAndEpochDependent() {
let secret = PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateA)
let a = PeerIDRotation.peerID(rotationSecret: secret, epoch: 100)
let b = PeerIDRotation.peerID(rotationSecret: secret, epoch: 101)
#expect(a.count == PeerIDRotation.idLength)
#expect(b.count == PeerIDRotation.idLength)
// VECTOR: HMAC-SHA256(rotationSecret, "bitchat-peer-id-v2" || uint32be(100))[0..8]
#expect(hex(a) == "f7c08c528506a374")
// The whole point: consecutive epochs are unrelated to an observer.
#expect(a != b)
// Deterministic within an epoch, so a restart keeps the same ID.
#expect(a == PeerIDRotation.peerID(rotationSecret: secret, epoch: 100))
}
@Test func peerIDDiffersBetweenDevices() {
let secretA = PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateA)
let secretB = PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateB)
#expect(PeerIDRotation.peerID(rotationSecret: secretA, epoch: 100)
!= PeerIDRotation.peerID(rotationSecret: secretB, epoch: 100))
}
@Test func currentPeerIDMatchesTheExplicitEpochForm() {
let date = Date(timeIntervalSince1970: 3600 * 100 + 17)
let viaConvenience = PeerIDRotation.currentPeerID(
noiseStaticPrivateKey: staticPrivateA,
at: date
)
let viaParts = PeerIDRotation.peerID(
rotationSecret: PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateA),
epoch: 100
)
#expect(viaConvenience == viaParts)
}
// MARK: - Recognition tags
private var pubA: Data { Data(repeating: 0x0A, count: 32) }
private var pubB: Data { Data(repeating: 0x0B, count: 32) }
private var idA: Data { Data(repeating: 0xA1, count: 8) }
/// The property that makes handshake-free recognition possible: both sides
/// reach the same tag from opposite halves of the key pair.
@Test func bothSidesDeriveTheSameRecognitionTag() throws {
let privA = try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: staticPrivateA)
let privB = try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: staticPrivateB)
let sharedFromA = try privA.sharedSecretFromKeyAgreement(with: privB.publicKey)
let sharedFromB = try privB.sharedSecretFromKeyAgreement(with: privA.publicKey)
let rawA = sharedFromA.withUnsafeBytes { Data($0) }
let rawB = sharedFromB.withUnsafeBytes { Data($0) }
#expect(rawA == rawB)
let keyA = PeerIDRotation.recognitionKey(sharedSecret: rawA)
let keyB = PeerIDRotation.recognitionKey(sharedSecret: rawB)
#expect(keyA == keyB)
// A emits its A->B tag; B computes the same value to look for it.
let emitted = PeerIDRotation.recognitionTag(
recognitionKey: keyA, epoch: 100,
senderStaticPublicKey: privA.publicKey.rawRepresentation,
recipientStaticPublicKey: privB.publicKey.rawRepresentation,
peerID: idA
)
let expected = PeerIDRotation.recognitionTag(
recognitionKey: keyB, epoch: 100,
senderStaticPublicKey: privA.publicKey.rawRepresentation,
recipientStaticPublicKey: privB.publicKey.rawRepresentation,
peerID: idA
)
#expect(emitted == expected)
#expect(emitted.count == PeerIDRotation.idLength)
}
/// Regression, Codex #1487 P1: a symmetric tag means A and B broadcast the
/// identical 8 bytes, so an observer who sees one value in two announces
/// learns those two are mutual favourites and can link their rotating IDs.
/// Tags must therefore differ by direction.
@Test func recognitionTagsAreDirectional() {
let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x42, count: 32))
let aToB = PeerIDRotation.recognitionTag(
recognitionKey: key, epoch: 100,
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA
)
let bToA = PeerIDRotation.recognitionTag(
recognitionKey: key, epoch: 100,
senderStaticPublicKey: pubB, recipientStaticPublicKey: pubA, peerID: idA
)
#expect(aToB != bToA)
}
/// Regression, Codex #1487 P1: without the peer ID in the MAC, a tag lifted
/// from someone's announce could be replayed under an attacker-chosen ID and
/// the recipient would accept that ID as the favourite.
@Test func recognitionTagIsBoundToTheAnnouncedPeerID() {
let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x42, count: 32))
let real = PeerIDRotation.recognitionTag(
recognitionKey: key, epoch: 100,
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA
)
let underAttackerID = PeerIDRotation.recognitionTag(
recognitionKey: key, epoch: 100,
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB,
peerID: Data(repeating: 0xFF, count: 8)
)
#expect(real != underAttackerID)
// And the lifted tag must not verify against the attacker's ID.
let block = PeerIDRotation.tagBlock(tags: [real])
#expect(!PeerIDRotation.blockMatches(
block, recognitionKey: key,
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB,
peerID: Data(repeating: 0xFF, count: 8),
at: Date(timeIntervalSince1970: 3600 * 100)
))
}
@Test func recognitionTagRotatesWithTheEpoch() {
let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x42, count: 32))
let now = PeerIDRotation.recognitionTag(
recognitionKey: key, epoch: 100,
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA
)
let next = PeerIDRotation.recognitionTag(
recognitionKey: key, epoch: 101,
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA
)
#expect(now != next)
// VECTOR: HMAC-SHA256(HKDF(ikm: 0x42*32, info: "bitchat-recognition-v1"),
// uint32be(100) || 0x0A*32 || 0x0B*32 || 0xA1*8)[0..8]
#expect(hex(now) == "4568f61d61d6cbfb")
}
@Test func aThirdPartyCannotDeriveAPairsTag() {
// An observer holding a *different* shared secret gets a different tag,
// which is what stops it from tracking the pair.
let pair = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x01, count: 32))
let other = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x02, count: 32))
#expect(PeerIDRotation.recognitionTag(
recognitionKey: pair, epoch: 7,
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA
) != PeerIDRotation.recognitionTag(
recognitionKey: other, epoch: 7,
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA
))
}
// MARK: - Tag block
@Test func tagBlockIsAlwaysFullWidth() {
let expected = PeerIDRotation.tagSlots * PeerIDRotation.idLength
for count in 0...PeerIDRotation.tagSlots {
let tags = (0..<count).map { Data(repeating: UInt8($0 + 1), count: 8) }
#expect(PeerIDRotation.tagBlock(tags: tags).count == expected)
}
}
/// A device with one favourite and a device with six must be
/// indistinguishable from the block, or the block leaks social-graph size.
@Test func tagBlockHidesHowManyFavouritesThereAre() {
let one = PeerIDRotation.tagBlock(tags: [Data(repeating: 0xAA, count: 8)])
let six = PeerIDRotation.tagBlock(
tags: (1...6).map { Data(repeating: UInt8($0), count: 8) }
)
#expect(one.count == six.count)
}
@Test func tagBlockDropsOverflowRatherThanGrowing() {
let tags = (1...(PeerIDRotation.tagSlots + 5)).map { Data(repeating: UInt8($0), count: 8) }
#expect(PeerIDRotation.tagBlock(tags: tags).count == PeerIDRotation.tagSlots * 8)
}
@Test func padOnlyBlockUsesFreshRandomnessEachTime() {
// Repeated identical padding would make an empty block recognisable.
let first = PeerIDRotation.tagBlock(tags: [])
let second = PeerIDRotation.tagBlock(tags: [])
#expect(first != second)
}
@Test func tagsRoundTripThroughTheBlock() throws {
let real = Data(repeating: 0xC3, count: 8)
let block = PeerIDRotation.tagBlock(
tags: [real],
randomBytes: { Data(repeating: 0x00, count: $0) }
)
let slots = try #require(PeerIDRotation.tags(fromBlock: block))
#expect(slots.count == PeerIDRotation.tagSlots)
#expect(slots.contains(real))
}
@Test func malformedBlockIsRejectedRatherThanPartiallyRead() {
#expect(PeerIDRotation.tags(fromBlock: Data()) == nil)
#expect(PeerIDRotation.tags(fromBlock: Data(repeating: 0, count: 7)) == nil)
#expect(PeerIDRotation.tags(fromBlock: Data(repeating: 0, count: 65)) == nil)
}
// MARK: - Matching
private func matchFixture() -> (key: Data, tag: Data, date: Date) {
let date = Date(timeIntervalSince1970: 3600 * 100)
let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x77, count: 32))
let tag = PeerIDRotation.recognitionTag(
recognitionKey: key,
epoch: PeerIDRotation.epoch(at: date),
senderStaticPublicKey: pubA,
recipientStaticPublicKey: pubB,
peerID: idA
)
return (key, tag, date)
}
@Test func blockMatchesRecogniseAPeerAnywhereInTheBlock() {
let (key, tag, date) = matchFixture()
// Slot order must not matter, so assert across many shuffles.
for _ in 0..<20 {
let block = PeerIDRotation.tagBlock(tags: [tag])
#expect(PeerIDRotation.blockMatches(
block, recognitionKey: key,
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB,
peerID: idA, at: date
))
}
}
/// Testing the wrong direction must fail, or the directional fix would be
/// cosmetic.
@Test func blockDoesNotMatchTheOppositeDirection() {
let (key, tag, date) = matchFixture()
let block = PeerIDRotation.tagBlock(tags: [tag])
#expect(!PeerIDRotation.blockMatches(
block, recognitionKey: key,
senderStaticPublicKey: pubB, recipientStaticPublicKey: pubA,
peerID: idA, at: date
))
}
@Test func blockMatchesToleratesTheEpochBoundary() {
let date = Date(timeIntervalSince1970: 3600 * 100)
let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x11, count: 32))
func tag(epoch: UInt32) -> Data {
PeerIDRotation.recognitionTag(
recognitionKey: key, epoch: epoch,
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA
)
}
func matches(_ candidate: Data) -> Bool {
PeerIDRotation.blockMatches(
PeerIDRotation.tagBlock(tags: [candidate]), recognitionKey: key,
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB,
peerID: idA, at: date
)
}
// A peer whose clock has already ticked over still matches.
#expect(matches(tag(epoch: 101)))
// Two epochs out is outside the window and must not.
#expect(!matches(tag(epoch: 98)))
}
@Test func randomBlockDoesNotMatch() {
let (key, _, date) = matchFixture()
#expect(!PeerIDRotation.blockMatches(
PeerIDRotation.tagBlock(tags: []), recognitionKey: key,
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB,
peerID: idA, at: date
))
}
// MARK: - Identity binding
@Test func bindingMessageIsFixedWidthAndContextSeparated() {
let message = PeerIDRotation.bindingMessage(
epoch: 100,
peerID: Data(repeating: 0xAB, count: 8),
noiseStaticPublicKey: Data(repeating: 0xCD, count: 32)
)
let context = Data("bitchat-peerid-binding-v1".utf8)
#expect(message.count == context.count + 4 + 8 + 32)
#expect(message.starts(with: context))
// Must not collide with the production-dead announce-signature helpers,
// which use "bitchat-announce-v1".
#expect(!message.starts(with: Data("bitchat-announce-v1".utf8)))
}
@Test func bindingMessagePadsShortInputsRatherThanShifting() {
// Fixed-width fields mean a short ID cannot shift the key into the ID's
// position and produce a message that verifies for the wrong pairing.
let short = PeerIDRotation.bindingMessage(
epoch: 1,
peerID: Data([0x01]),
noiseStaticPublicKey: Data([0x02])
)
let padded = PeerIDRotation.bindingMessage(
epoch: 1,
peerID: Data([0x01]) + Data(repeating: 0, count: 7),
noiseStaticPublicKey: Data([0x02]) + Data(repeating: 0, count: 31)
)
#expect(short == padded)
}
@Test func bindingMessageChangesWithEveryField() {
let base = PeerIDRotation.bindingMessage(
epoch: 1,
peerID: Data(repeating: 0x01, count: 8),
noiseStaticPublicKey: Data(repeating: 0x02, count: 32)
)
#expect(base != PeerIDRotation.bindingMessage(
epoch: 2,
peerID: Data(repeating: 0x01, count: 8),
noiseStaticPublicKey: Data(repeating: 0x02, count: 32)
))
#expect(base != PeerIDRotation.bindingMessage(
epoch: 1,
peerID: Data(repeating: 0x03, count: 8),
noiseStaticPublicKey: Data(repeating: 0x02, count: 32)
))
#expect(base != PeerIDRotation.bindingMessage(
epoch: 1,
peerID: Data(repeating: 0x01, count: 8),
noiseStaticPublicKey: Data(repeating: 0x04, count: 32)
))
}
@Test func bindingMessageVerifiesUnderTheIdentityKey() throws {
let signing = Curve25519.Signing.PrivateKey()
let message = PeerIDRotation.bindingMessage(
epoch: 100,
peerID: Data(repeating: 0xAB, count: 8),
noiseStaticPublicKey: Data(repeating: 0xCD, count: 32)
)
let signature = try signing.signature(for: message)
#expect(signing.publicKey.isValidSignature(signature, for: message))
// A different epoch must not verify: replaying a binding into a later
// epoch is exactly what this prevents.
let other = PeerIDRotation.bindingMessage(
epoch: 101,
peerID: Data(repeating: 0xAB, count: 8),
noiseStaticPublicKey: Data(repeating: 0xCD, count: 32)
)
#expect(!signing.publicKey.isValidSignature(signature, for: other))
}
}