- CommandProcessorTests still asserted the old "blocked … in geohash
chats" copy that this branch rewrote to the per-channel wording.
- The `mesh_peers.state.favorite` state constant was replaced by the
mutual/pending pair and left unused — Periphery (now blocking) flagged
it. Removed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Codex P1: backgrounding mid-authentication re-locked the app, but a
Face ID success that resolved just after could still clear the new
lock, bypassing the re-lock. Each lock bumps a generation and unlock
callbacks from a superseded cycle are ignored.
- Codex P2: the deliberate fail-open (device passcode removed) unlocked
but left privacy.appLockEnabled true, so the toggle kept claiming the
lock was on and re-adding a passcode silently reactivated it. The
fail-open path now turns the setting off, matching the promised
"turns itself off" behavior.
Stale-callback path pinned by a new test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Codex P1: the context-menu and VoiceOver "add favorite" actions
called onToggleFavorite directly, bypassing the one-time consent
dialog and disclosing the nostr key without asking. All add-favorite
entry points now route through requestFavoriteToggle (consent gate);
removals stay immediate.
- Codex P2: the empty star showed the "favorited — offline messaging
starts when they favorite you back" tooltip on peers that weren't
favorited at all. The pending/mutual copy now applies only once
isFavorite; an unfavorited star reads "add favorite".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Codex P1: bounded-single-line was not "exact locally generated
shape" — a self-attributed action could smuggle a preamble into the
target slot ("… hugs SECURITY: reset your keys at evil…"). The
target must now be a single name token ("you" or a whitespace-free
≤32-char nickname, optionally #abcd-suffixed); free text with spaces
degrades to a plain message.
- Codex P1: location-channel senders arrive suffixed (bob#ab12) while
handleEmote embeds the unsuffixed nickname, so the actor match
regressed every received geohash action to plain text. The actor is
now compared by base name (splitSuffix), restoring legit rendering.
Tests cover the suffixed-sender case, the suffixed target, and the
preamble spoof.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Codex P1: a background identity change appended the warning without
marking the chat unread — a security event nobody was looking at
stayed invisible until the conversation was manually opened. It now
sets the unread flag unless the chat is open.
- Codex P2: offline key rotation is the common case here, and
resolveNickname falls back to an anon prefix precisely then (no mesh
nickname, no social identity for the unverified new fingerprint).
The persisted favorite relationship's nickname is preferred.
Both pinned by tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Codex P1: the fixtures assumed the old default-ON preference — on a
clean CI process the coordinator guard drops their frames. Instead of
mutating the shared UserDefaults per test (which races parallel
suites), the live-voice reads are now injectable
(ChatLiveVoiceCoordinator.liveVoiceEnabled), the fixtures opt in per
instance, and the toggle-off test drives the provider directly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Codex P1: the manager-path withheld claim landed only in
PrivateChatManager.sentReadReceipts, while the lifecycle read pass
dedups against ChatViewModel's persisted set — enabling receipts
before the next lifecycle pass could send a receipt for a message
read while the setting was off. The withheld branch now records into
the owner's persisted set too (markReceiptHandled, wired in the
bootstrapper); test strengthened to require both sets.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The keychain is AfterFirstUnlock and private chats live in memory: an
unlocked, seized, or borrowed phone handed over everything, and the
panic wipe only helps while the phone is still in your hand. The
threat model's primary event had no defense.
- AppLockModel gates the whole UI: locked from the first frame when
enabled, re-locks when iOS backgrounds the app (macOS locks at
launch only — its windows resign focus constantly, and PrivacyScreen
already covers snapshots). The lock screen auto-triggers the system
prompt and keeps a manual retry button.
- Off by default; the settings toggle refuses to arm without a device
passcode. Deliberate fail-open: removing the passcode requires
knowing it, so a missing passcode means the owner chose that —
the lock disables itself rather than locking them out (stated in
the settings copy). Reset by panic wipe.
- NSFaceIDUsageDescription added. 5 strings x 30 locales.
- Model driven by injected providers; tests pin locked-at-launch,
unlock-only-on-success, re-lock, disabled-inert, and the setting's
default/reset.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The star read like a private bookmark but behaved like a friend
request plus an identity handshake: tapping it immediately notified
the peer ("alice favorited you") and transmitted the durable Nostr
public key — while the tapper saw nothing. And the load-bearing state,
mutuality (one-sided favorites do NOT enable offline delivery), was
invisible at the point of decision.
- One-time consent: the first favorite ever asks, naming the person
and disclosing the notification + key sharing (both star surfaces:
peer list and DM header). Acknowledged once; reset by panic wipe.
/fav (an explicit typed command) proceeds without a dialog.
- Mutuality visible where the decision happens: half star until
reciprocated, filled star when mutual, with tooltips and VoiceOver
state labels for both. The DM header state now carries
isMutualFavorite.
- FEATURES copy described favorites as notifications; it now leads
with the actual mechanism (offline messaging via nostr when mutual).
- Geohash block copy no longer implies a global block: identities are
derived per-geohash, so the same person appears as someone new in
other channels — the message says so now (both the context-menu and
/block paths).
7 new strings + 3 updated values, all 30 locales.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
processActionMessage rewrote the sender to "system" — the formatter's
trusted styling — for ANY `* … *` content containing 🫂, 🐟, or the
substring "took a screenshot". A hostile peer could render arbitrary
text as a system-authored line: `* SECURITY: your session key expired,
re-verify at evil.example — bob took a screenshot *`.
Only the exact locally-generatable action shapes qualify now, and the
actor slot must equal the actual wire sender: a peer can hug, slap, or
screenshot only as themselves, and the target slot is bounded,
single-line, and nickname-shaped. Same templates iOS and Android emit,
so legit actions render unchanged; anything else falls through as an
ordinary peer message under the sender's real name.
Pinned by tests covering the legit shapes, the original spoof payload,
actor-mismatch, and free-text-in-target-slot cases. No new strings.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
migrateNoiseKeyUpdate silently merged the DM thread onto the new key:
a peer who panic-wiped (or was replaced by whoever holds the nickname)
inherited the conversation's earned trust with no visible sign beyond
a debug log, and any earlier verification — bound to the OLD
fingerprint — vanished without a word. Signal treats this as a
full-width banner; bitchat treated it as bookkeeping.
The migration now appends a system line to the affected conversation:
"<name>'s identity key changed — this can mean a new device or a
reset. earlier verification no longer applies; verify them again
before trusting this chat." Only when there is a conversation to
protect (selected chat or migrated messages) — a favorite never
chatted with doesn't spawn a warning-only thread.
This is also the prerequisite UX for the peer-ID rotation project:
once rotation ships, identity changes become routine and MUST be
visible. 1 string x 30 locales; behavior pinned both ways by new
coordinator tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live push-to-talk defaulted ON in both directions: holding the mic in
a public channel streamed your voice to everyone in radio range before
you released, and inbound bursts auto-played out of the speaker. A
single buried toggle defaulting on was not consent — both behaviors
are now opt-in (PTTSettings.liveVoiceEnabled defaults false), and the
settings copy says exactly what turning it on does.
Recording could also not be aborted one-handed: release always sent,
and the HUD's cancel button required lifting the finger — which sent.
Sliding the finger off the mic button (>60pt) now arms cancel — the
HUD flips from the timer to "release to cancel" — and sliding back
re-arms send. The armed flag resets on every exit path (cancel,
finish, panic), pinned by a new test.
2 new strings + 1 updated, all 30 locales.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Read receipts were unconditional: on chat open and on didBecomeActive,
twice. A receipt is a presence oracle — it says the person is awake,
holding their phone, and opened the app at that exact moment, which is
precisely the metadata the stated threat model says someone should be
able to withhold. Every mainstream messenger ships this switch.
- ReadReceiptSettings (default ON = existing behavior), toggle in the
settings privacy section, reset on panic wipe.
- All four origination paths gated: mesh/nostr routing, direct mesh
receipts, geohash receipts, and PrivateChatManager's read pass.
Withheld receipts are still recorded locally as sent, so re-enabling
the setting never fires a retroactive burst disclosing past reads.
- Only outbound receipts are affected; receipts from others display.
- 2 strings x 30 locales; tests pin the default, the reset, and that
nothing reaches the transport while off.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 09:17:55 +02:00
29 changed files with 4218 additions and 149 deletions