Compare commits

...
Sign in to create a new pull request.

19 commits

Author SHA1 Message Date
jack
3c0a2bbaab Merge branch 'feat/favorites-consent' into feat/app-lock 2026-08-11 11:09:53 +02:00
jack
a4a0ec0fbc Fix CI: update stale geo-block assertion; drop unused favorite string
- 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>
2026-08-11 11:09:51 +02:00
jack
cc23827792 Review fixes: guard stale auth callbacks; clear the setting on fail-open
- 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>
2026-08-11 10:19:28 +02:00
jack
c8dd2180dc Merge branch 'feat/favorites-consent' into feat/app-lock 2026-08-11 10:16:56 +02:00
jack
9b72f84966 Review fixes: consent on every add-favorite path; correct empty-star tooltip
- 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>
2026-08-11 10:16:53 +02:00
jack
eb6223122a Merge branch 'fix/system-message-spoofing' into feat/favorites-consent 2026-08-11 10:14:12 +02:00
jack
d530921919 Review fixes: suffix-tolerant actor + name-token target
- 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>
2026-08-11 10:14:08 +02:00
jack
e729c155f7 Merge branch 'feat/identity-changed-warning' into fix/system-message-spoofing 2026-08-11 10:10:01 +02:00
jack
c58ad9af10 Review fixes: unread flag + favorites-aware naming for the warning
- 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>
2026-08-11 10:09:57 +02:00
jack
0a288869d6 Merge branch 'feat/ptt-consent' into feat/identity-changed-warning 2026-08-11 10:06:48 +02:00
jack
6dd14961c6 Review fix: opt live-voice tests in via injectable provider
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>
2026-08-11 10:06:45 +02:00
jack
daf9738c8b Merge branch 'feat/read-receipt-toggle' into feat/ptt-consent 2026-08-11 10:03:05 +02:00
jack
cfa875459d Review fix: record withheld receipts in both tracking sets
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>
2026-08-11 10:03:01 +02:00
jack
0ade22c7ed Add an optional app lock (Face ID / Touch ID / passcode)
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>
2026-08-11 09:47:48 +02:00
jack
d4d78e85eb Favorites consent, visible mutuality, and honest copy
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>
2026-08-11 09:41:50 +02:00
jack
a05c67252f Close the system-message spoofing hole in the action parser
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>
2026-08-11 09:31:39 +02:00
jack
359139fc6b Warn in the chat when a peer's identity key changes
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>
2026-08-11 09:28:11 +02:00
jack
0dacb3629c PTT consent: live voice off by default; slide-away-to-cancel
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>
2026-08-11 09:23:19 +02:00
jack
3e46829f32 Add a read-receipt toggle
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

File diff suppressed because it is too large Load diff