on master help sign in
16 files changed+1058−162
since splitsnapshots
unifiedsplit
.github/workflows/build.yml+1−1 AGENTS.md+2−2 AUDIT-DEEP.md+230 AUDIT-FFI-PHONEMIC-APPLE.md+470 PROTOCOL.md+4−4 apple/SkrepkaApp/Core.swift+13−15 apple/SkrepkaApp/Effects.swift+19−6 apple/SkrepkaApp/PhotoPicker.swift+7−1 apple/SkrepkaApp/QRView.swift+36−2 apple/SkrepkaApp/SkrepkaApp.swift+4−2 core/src/app.rs+76−27 core/src/crypto.rs+82−60 core/src/ffi.rs+5 core/src/phonemic.rs+8−3 core/src/protocol.rs+64−31 server.knot+37−8
.github/workflows/build.yml+1−1
@@ -157,7 +157,7 @@ jobs:
157157
158158 PK=$(printf '0%.0s' $(seq 1 64)) # 64 hex chars: a well-formed pubkey
159159 SIG=$(printf '0%.0s' $(seq 1 128)) # 128 hex chars: a well-formed signature
160− BLOB=$(printf 'ab%.0s' $(seq 1 200)) # 400 hex chars: over the 336-char floor
160+ BLOB=$(printf 'ab%.0s' $(seq 1 200)) # 400 hex chars: over the 346-char floor
161161
162162 # $1 = expected status, $2 = label, rest = curl args
163163 expect() {
AGENTS.md+2−2
@@ -11,7 +11,7 @@ Decentralized, end-to-end encrypted messenger. No accounts, no home servers —
1111- **Apple shell:** Swift + SwiftUI, **iOS 17+ only** (`include_macos = false` in `core/boltffi.toml`). The shell is a thin effect executor — it performs HTTP, key-value, and timer effects and renders the `ViewModel`. It contains no business logic.
1212- **Crypto:** `ed25519-dalek` + `x25519-dalek` + `curve25519-dalek`, `chacha20poly1305` (XChaCha20-Poly1305), `hkdf` + `sha2` (HKDF-SHA256), `zstd` (plaintext compression). **No libsodium** — `core/src/crypto.rs` hand-reimplements libsodium's `crypto_sign_ed25519_{pk,sk}_to_curve25519` so blobs stay wire-compatible with any spec-following client.
1313- **Server:** `server.knot`, written in [Knot](https://github.com/ilyakooo0/knot) — a custom functional language compiled to a native binary. Its runtime transparently persists every `*ref` to SQLite, so all server state (`*sessions`, `*messages`, `*presence`, …) survives restarts despite reading like in-memory lists.
14−- **Persistence (client):** `crux_kv` — JSON blobs under the keys `settings`, `profile`, `contacts`, `cursor`, `outbox`, and `messages:<peer_hex>`. The Swift shell backs this with one file per key under Application Support. The 64-byte Ed25519 secret key lives in the iOS Keychain, never in kv.
14+- **Persistence (client):** `crux_kv` — JSON blobs under the keys `settings`, `profile`, `contacts`, `cursor`, `outbox`, `seen_ids`, and `messages:<peer_hex>`. The Swift shell backs this with one file per key under Application Support. The 64-byte Ed25519 secret key lives in the iOS Keychain, never in kv.
1515
1616## Project Layout
1717
@@ -95,7 +95,7 @@ Two kinds of `Event` variants, and the distinction matters:
9595
9696The `ViewModel` is deliberately flat and stringly-typed (`page: String`, `conn_status: String`) so the Swift side switches on plain strings rather than needing generated enums for UI state.
9797
98−Runtime flow: `IdentityLoaded` (from the Keychain, at boot) fans out five kv loads → `LoadedSettings` triggers `Connect` → `Authenticate` (challenge/verify, signature bound to the server's bare hostname under the `skrepka-auth-v1:` tag) → on a token, `Poll` (server long-polls ~25 s, client re-polls immediately) and `StartFlush` (drains the outbox one message at a time, encrypting at send time). Failures back off via `crux_time` — 3 s doubling to a 30 s cap.
98+Runtime flow: `IdentityLoaded` (from the Keychain, at boot) fans out six kv loads → `LoadedSettings` triggers `Connect` → `Authenticate` (challenge/verify, signature bound to the server's bare hostname under the `skrepka-auth-v1:` tag) → on a token, `Poll` (server long-polls ~25 s, client re-polls immediately) and `StartFlush` (drains the outbox one message at a time, encrypting at send time). Failures back off via `crux_time` — 3 s doubling to a 30 s cap.
9999
100100## Code Conventions
101101
AUDIT-DEEP.mdnew file+230
@@ -0,0 +1,230 @@
1+# Skrepka Deep Audit — app.rs & model.rs (2026-07-16)
2+
3+**Scope:** Deep audit of `core/src/app.rs` and `core/src/model.rs`, cross-referenced against PROTOCOL.md §4, §6, §9, §10 and existing audits (AUDIT-NEW.md, AUDIT-FRESH.md, PROTOCOL-REVIEW.md). Findings below are **not** documented in any prior audit.
4+
5+**Legend:** 🔴 critical · 🟠 high · 🟡 medium · 🔵 low · ⚪ info
6+
7+---
8+
9+## 1. State Machine — Poll Loop / Cursor / Budget
10+
11+### 🟡 D1. `events_stored` doesn't count successfully-decrypted-but-non-stored events — livelock on budget exhaustion
12+
13+**Type:** State machine bug (livelock)
14+**File:** `core/src/app.rs:2127, 2188-2200, 2248-2293, 2316, 2342-2349`
15+
16+The `events_stored` counter drives the cursor-restore decision on budget/count exhaustion (lines 2145-2147, 2163-2165): if `events_stored == 0`, the cursor is restored to `prev_cursor`, and the relay re-serves the same page on the next poll.
17+
18+`events_stored` is only incremented for:
19+- Text messages that **pass** dedup (line 2264 — inside `if !id_set.contains`)
20+- All `delivery.ack` payloads (line 2316)
21+- Profiles that **pass** staleness (line 2348 — inside `if ts >= c.last_profile_ts`)
22+
23+It is **NOT** incremented for:
24+- **Duplicate text messages** (line 2248: the `if !id_set.contains` block is not entered)
25+- **Stale profiles** (line 2342: the `if ts >=` block is not entered)
26+- **Profiles from strangers** (line 2327: `continue` before any increment)
27+- **Self-messages** (line 2188: `continue` before any increment)
28+- **Blocked-sender messages** (line 2198: `continue` before any increment)
29+
30+All of these are **successfully decrypted** — they consume the full AEAD decrypt cost and the per-blob byte budget — but they don't increment `events_stored`. If a poll page consists entirely of such events and the byte budget is exhausted mid-page, `events_stored == 0`, the cursor is restored, and the client re-polls with the old cursor. The relay re-serves the same page. **Livelock.**
31+
32+**Attack scenario:**
33+1. Client receives 4 large blobs (each ~20 MiB decoded, total ~80 MiB > 64 MiB budget).
34+2. First encounter: all 4 are new text messages. `events_stored = 3` (budget exhausted on 4th). Cursor kept at `page.cursor`. Relay deletes events. No issue.
35+3. **Hostile relay re-serves the same 4 blobs** (it kept copies despite cursor advance). All 4 are now duplicates. Budget exhausted on 4th blob. `events_stored == 0`. Cursor restored to `prev_cursor`.
36+4. Client re-polls with `prev_cursor`. Relay re-serves same 4 blobs. Go to 3. **Permanent livelock** — the client can never advance past this page, wasting bandwidth and battery on every cycle.
37+
38+The same pattern applies to a page of large stale profile blobs, large profile-from-stranger blobs, or large self-message echoes — all decrypt successfully, all are dropped, none increment `events_stored`.
39+
40+**Note:** AUDIT-FRESH.md N9 identified the budget-exhaustion livelock for the case where blobs **fail decryption** (events_stored == 0, hostile junk). The fix applied (keep cursor if `events_stored > 0`) addresses that case. But the **duplicate/stale/stranger/self/blocked** cases are different: the blobs ARE decryptable, the client DID process them (and doesn't need them again), but `events_stored` doesn't reflect this. This is a new variant of the N9 livelock that the existing fix does not cover.
41+
42+**Suggested fix:** Increment `events_stored` after successful decryption and parse, regardless of whether the event is stored:
43+```rust
44+let Ok(dec) = crate::crypto::decrypt(&id, &blob) else { continue; };
45+let Some(parsed) = protocol::parse_payload(&dec.plaintext) else { continue; };
46+events_stored += 1; // successfully decrypted — cursor can advance past this
47+```
48+This ensures the cursor is kept whenever at least one event was successfully processed (even if it was a duplicate/stale/stranger), breaking the livelock. The unprocessed tail (budget exhaustion) is still lost, but that's the accepted N9 tradeoff.
49+
50+---
51+
52+### 🔵 D2. `SetServerUrl` resets `send_retries` but not `poll_retries` — new relay inherits stale poll backoff
53+
54+**Type:** State machine bug (stale state on relay switch)
55+**File:** `core/src/app.rs:960`
56+
57+When switching relays, `SetServerUrl` resets `send_retries = 0` (line 960) and `flush_paused_until = 0` (line 959), with the comment: "The old relay's rate limit was the old relay's opinion. A new one is entitled to take our sends immediately."
58+
59+The same logic applies to `poll_retries` — it's the old relay's poll failure count — but it is **not reset**. If the old relay was failing (e.g., 5xx storm), `poll_retries` could be at 4 (backoff = 30s). The first poll to the new relay fails (e.g., transient network issue), and `backoff_poll` uses `backoff_ms(5) = 30s` instead of starting at 3s. The new relay's poll loop is penalized for the old relay's failures.
60+
61+**Attack scenario:** No attack needed — this triggers on any relay switch after a period of poll failures. The user switches to a healthy relay but experiences unnecessarily long poll backoffs.
62+
63+**Suggested fix:** Add `model.poll_retries = 0;` alongside `model.send_retries = 0;` at line 960.
64+
65+---
66+
67+## 2. State Machine — Outbox / Flush
68+
69+### 🔵 D3. `SendResult` pops outbox items even when `kv_load_failed` is set — duplicate sends on relaunch
70+
71+**Type:** State machine bug (write guard gap)
72+**File:** `core/src/app.rs:1547-1560, 1728-1733`
73+
74+If `kv_load_failed` is set (by a `LoadedMessages` failure) while a send is in flight, `SendResult` still pops the batch from `model.outbox` (line 1557-1559) on a 200 or 400. The subsequent `persist_outbox` (line 1560) calls `refuse_write`, which checks `kv_load_failed` and refuses the write. The items are removed from the in-memory outbox but **not from the on-disk outbox**.
75+
76+On relaunch, the outbox is re-loaded from disk — the popped items are still there. They are re-sent:
77+- For a **200** (relay accepted): duplicate sends. The recipient's dedup handles it, but the sender wastes bandwidth and the recipient sees duplicate delivery attempts.
78+- For a **400** (relay rejected): the items are re-sent and re-rejected, burning retry budget.
79+
80+**Timeline:**
81+1. Startup loads complete. `Connect` → `Authenticate` → `VerifyResult` → `Poll` + `StartFlush`.
82+2. `StartFlush` sends a batch. `flushing = true`, `in_flight` is set.
83+3. `LoadedMessages` for some peer fails. `kv_load_failed = true`.
84+4. `SendResult` arrives (200). `flushing` cleared, batch popped (line 1557-1559). `persist_outbox` refused (line 1729-1730).
85+5. On relaunch: outbox loaded from disk. Items re-sent.
86+
87+**Note:** The window is narrow — `LoadedMessages` is a lazy load triggered by `LoadedContacts`, and `StartFlush` fires from `VerifyResult` which also fires after startup loads. But the ordering is: startup loads → `Connect` → `Authenticate` → `VerifyResult` → `StartFlush` + `Poll` → `LoadedMessages` (lazy). So the send can be in flight when `LoadedMessages` fails.
88+
89+**Suggested fix:** Check `kv_load_failed` before popping in `SendResult`. If set, don't pop — leave items in the outbox for relaunch to handle:
90+```rust
91+if ((200..300).contains(&status) || status == 400) && !model.kv_load_failed {
92+ // pop and persist
93+}
94+```
95+Alternatively, don't pop if `persist_outbox` was refused (check the return of `refuse_write`).
96+
97+---
98+
99+## 3. Model / Persistence
100+
101+### 🔵 D4. `now_ms()` returns 0 on system clock failure — outbox TTL disabled
102+
103+**Type:** Robustness gap
104+**File:** `core/src/app.rs:580-585`
105+
106+```rust
107+fn now_ms() -> i64 {
108+ SystemTime::now()
109+ .duration_since(UNIX_EPOCH)
110+ .map(|d| d.as_millis() as i64)
111+ .unwrap_or(0)
112+}
113+```
114+
115+If the system clock is before `UNIX_EPOCH` (misconfigured device, RTK bug, NTP step), `now_ms()` returns 0. Consequences:
116+
117+- **Outbox TTL disabled:** `first_attempt` is stamped as 0 (line 1990-1993). `OutboxItem::is_expired` checks `first_attempt > 0` (line 170) — 0 means "never attempted," so the TTL never applies. Stuck items are retried forever (only the retry counter bounds them, which at 10 retries with slow backoff could take days).
118+- **`flush_paused_until` bypassed:** `now_ms() < flush_paused_until` → `0 < 0` → false. Flushing proceeds immediately, ignoring a relay's `Retry-After`.
119+- **`last_ack_ts` / `last_profile_ts` clamped to 0:** `ts.min(now)` → `ts.min(0)` → 0 (if ts is positive). All future acks/profiles pass the staleness check (any ts >= 0). Dedup is weakened.
120+
121+**Attack scenario:** No attack — this is a device misconfiguration. But on iOS, a clock that briefly reads as pre-epoch during boot could trigger this transiently.
122+
123+**Suggested fix:** Use `UNIX_EPOCH` as the floor instead of 0:
124+```rust
125+fn now_ms() -> i64 {
126+ SystemTime::now()
127+ .duration_since(UNIX_EPOCH)
128+ .map(|d| d.as_millis() as i64)
129+ .unwrap_or(1) // 1, not 0 — 0 is a valid "never attempted" sentinel
130+}
131+```
132+Or better: propagate the error and have callers handle clock failure explicitly.
133+
134+---
135+
136+### 🔵 D5. `LoadedMessages` merge doesn't update `seen_ids` — weakened dedup after `seen_ids` load failure
137+
138+**Type:** Dedup gap
139+**File:** `core/src/app.rs:815-856`
140+
141+The `LoadedMessages` handler merges on-disk messages into the in-memory conversation (union by ID, re-sort, trim). But it does **not** add the on-disk message IDs to `model.seen_ids`.
142+
143+When `seen_ids` loads successfully from kv at startup, this is fine — the on-disk IDs are already in the set. But when `seen_ids` **fails to load** (line 807-811, non-fatal), the set is empty. The `ingest_poll` seeding at line 2244-2246 seeds from the conversation:
144+```rust
145+if id_set.is_empty() {
146+ id_set.extend(convo.iter().map(|m| m.id.clone()));
147+}
148+```
149+
150+If a poll arrives **before** `LoadedMessages` for a peer, the conversation is empty (not loaded yet). The seeding adds no IDs. The poll's messages are stored and their IDs are added to the set. Later, `LoadedMessages` arrives and merges the on-disk messages — but their IDs are **never** added to `seen_ids` (the merge doesn't touch `seen_ids`, and the seeding only runs when `id_set.is_empty()`, which is no longer true after the poll).
151+
152+**Result:** On-disk message IDs are missing from `seen_ids`. Future replays of those on-disk messages pass the dedup check (line 2248) and are re-inserted into the conversation. The `LoadedMessages` merge filters duplicates by ID (line 830), so the conversation doesn't grow unboundedly — but each replay is processed (decrypted, inserted, acked) before the merge catches it.
153+
154+**Attack scenario:**
155+1. `seen_ids` kv load fails (device locked, file corruption).
156+2. `LoadedContacts` triggers `LoadedMessages` for peer P.
157+3. A poll arrives before `LoadedMessages` for P. P's conversation is empty. `seen_ids[P]` is seeded as empty.
158+4. Poll stores new messages, adds their IDs to `seen_ids[P]`.
159+5. `LoadedMessages` for P arrives. On-disk messages are merged into the conversation. Their IDs are NOT added to `seen_ids[P]`.
160+6. A replay of an on-disk message arrives. Its ID is not in `seen_ids[P]`. It passes dedup. It's inserted and acked.
161+
162+**Impact:** Weakened dedup — replays of on-disk messages are re-processed (decrypted, stored, acked). No permanent data corruption (the merge prevents duplicate conversation entries), but the recipient sends spurious delivery acks for old messages, and the sender receives false "delivered" confirmations.
163+
164+**Suggested fix:** In the `LoadedMessages` merge, add the on-disk message IDs to `seen_ids`:
165+```rust
166+if let Some(id_set) = model.seen_ids.get_mut(&peer) {
167+ id_set.extend(list.iter().map(|m| m.id.clone()));
168+}
169+```
170+
171+---
172+
173+## 4. Protocol Deviations
174+
175+### ⚪ D6. `ingest_poll` doesn't send profile to stranger on first contact — protocol "SHOULD" not followed
176+
177+**Type:** Protocol deviation (UX)
178+**File:** `core/src/app.rs:2221-2230, 2400-2412`
179+
180+PROTOCOL.md §4: "Clients SHOULD send a `profile` message to a contact on first interaction and whenever the user updates their name, bio, or photo."
181+
182+When a stranger sends us a text, `ingest_poll` auto-creates a contact (line 2225-2229) and queues a `delivery.ack` (line 2400-2412). But it does **not** queue a `profile` broadcast back to the stranger. The stranger's client has our pubkey (they sent us a message) but no profile — they see our truncated @p but not our display name or avatar.
183+
184+`send_text` handles this correctly: on first contact (line 1858-1873), it queues a profile broadcast after the text. But `ingest_poll` doesn't.
185+
186+**Impact:** Minor UX issue — a stranger who initiates conversation doesn't see our profile until we manually send them a text or update our profile. Not a security vulnerability.
187+
188+**Suggested fix:** After auto-creating a contact in `ingest_poll`, queue a profile broadcast:
189+```rust
190+if !known {
191+ // ... auto-create contact ...
192+ let profile = Payload::Profile { /* ... */ };
193+ model.outbox.push_back(OutboxItem::profile(
194+ sender.clone(),
195+ Arc::new(protocol::serialize_payload(&profile, ts)),
196+ ));
197+ outbox_dirty = true;
198+}
199+```
200+
201+---
202+
203+## Summary of New Findings
204+
205+| # | Severity | Type | Title |
206+|---|----------|------|-------|
207+| D1 | 🟡 medium | Livelock | `events_stored` doesn't count decrypted-but-non-stored events — livelock on budget exhaustion |
208+| D2 | 🔵 low | Stale state | `SetServerUrl` doesn't reset `poll_retries` — new relay inherits old poll backoff |
209+| D3 | 🔵 low | Write guard gap | `SendResult` pops items when `kv_load_failed` — duplicate sends on relaunch |
210+| D4 | 🔵 low | Robustness | `now_ms()` returns 0 on clock failure — outbox TTL disabled |
211+| D5 | 🔵 low | Dedup gap | `LoadedMessages` merge doesn't update `seen_ids` — weakened dedup after load failure |
212+| D6 | ⚪ info | Protocol deviation | `ingest_poll` doesn't send profile to stranger on first contact |
213+
214+**Medium (1):** D1
215+**Low (4):** D2, D3, D4, D5
216+**Info (1):** D6
217+
218+---
219+
220+## Priorities
221+
222+1. **D1 (medium):** Fix `events_stored` to count all successfully decrypted events. This closes a livelock variant that the N9 fix doesn't cover — a hostile relay can permanently wedge the client by re-serving large duplicate/stale/stranger blobs.
223+
224+2. **D2 (low):** Add `model.poll_retries = 0;` to `SetServerUrl`. One-line fix, prevents stale backoff on relay switch.
225+
226+3. **D5 (low):** Update `seen_ids` in the `LoadedMessages` merge. Closes a dedup gap when `seen_ids` kv load fails.
227+
228+4. **D3 (low):** Guard `SendResult` pop with `kv_load_failed` check. Prevents duplicate sends on relaunch.
229+
230+5. **D4 (low):** Change `unwrap_or(0)` to `unwrap_or(1)` in `now_ms()`. Prevents TTL disablement on clock failure.
AUDIT-FFI-PHONEMIC-APPLE.mdnew file+470
@@ -0,0 +1,470 @@
1+# Skrepka Security Audit — FFI, Phonemic, Apple Shell (2026-07-16)
2+
3+**Scope:** Deep audit of `core/src/ffi.rs`, `core/src/phonemic.rs`, and all Apple shell files (`apple/SkrepkaApp/*.swift`). Cross-referenced against `PROTOCOL.md`, `AGENTS.md`, `AUDIT-NEW.md`, and `AUDIT-FRESH.md`. Findings below are **not** in prior audits.
4+
5+**Legend:** 🔴 critical · 🟠 high · 🟡 medium · 🔵 low · ⚪ info
6+
7+---
8+
9+## 1. FFI Boundary
10+
11+### 🟠 F1. `try!` in Core.swift crashes on empty bytes from `guard` — FFI panic safety is a lie
12+
13+**Type:** FFI boundary / panic safety
14+**Files:** `core/src/ffi.rs:42-44` (`guard`), `apple/SkrepkaApp/Core.swift:30,38,49,60,66,74,81`
15+
16+The `guard` function in `ffi.rs` catches panics with `catch_unwind` and returns an empty `Vec<u8>`. The module doc (lines 4-9) claims: *"the shell already tolerates an empty effect list (it just does nothing) and an empty view (it renders the previous one), so a bug degrades into a stalled UI rather than a corrupted process."*
17+
18+This is false. Every bincode deserialization in `Core.swift` uses `try!`:
19+
20+```swift
21+// Core.swift:30 (init)
22+self.view = try! .bincodeDeserialize(input: [UInt8](core.view()))
23+
24+// Core.swift:38 (update)
25+let effects = [UInt8](core.update(data: Data(try! event.bincodeSerialize())))
26+
27+// Core.swift:49 (dispatch)
28+let requests = try! Requests.bincodeDeserialize(input: effects).value
29+
30+// Core.swift:60 (render)
31+self.view = try! .bincodeDeserialize(input: [UInt8](core.view()))
32+```
33+
34+If `core.view()` or `core.update()` returns empty bytes (from a caught panic), `try! .bincodeDeserialize(input: [])` throws, and `try!` produces a fatal error — **crashing the app**, not degrading gracefully. The `init` case is the worst: there is no "previous view" to render, so the very first `core.view()` call would crash the app on launch if the core panics.
35+
36+**Attack scenario:** Any input that causes a panic inside `App::update` or `Skrepka::view` — an unforeseen edge case, a dependency bug, or an integer overflow in debug mode — would crash the app instantly via `try!` rather than degrading to a stalled UI as the FFI docs claim. On iOS, this is a clean crash with no data corruption, but it's a denial-of-service: the user can't use the app at all.
37+
38+**Exploitable in practice:** Requires triggering a panic in the Rust core. The core code is careful with `unwrap_or` / `unwrap_or_default`, but panics from dependencies (e.g., `serde_json` on malformed input, `zstd` on a crafted frame) or array indexing bugs are possible. A hostile relay crafting specific poll responses could potentially trigger a panic in the decrypt path.
39+
40+**Suggested fix:** Replace `try!` with `try?` or a `do/catch` block that logs the error and skips the deserialization. For `init`, provide a default empty `ViewModel` if `core.view()` returns empty bytes. Alternatively, have `guard` return a sentinel (e.g., a single zero byte) that the Swift side can detect as "panic occurred" and handle gracefully.
41+
42+---
43+
44+### 🟡 F2. `AssertUnwindSafe` is unsound — Model is read after a panic
45+
46+**Type:** FFI boundary / memory safety
47+**Files:** `core/src/ffi.rs:39-44` (`guard` + `AssertUnwindSafe`)
48+
49+The `guard` function uses `AssertUnwindSafe(f)` to bypass the `UnwindSafe` requirement. The doc comment (lines 39-41) justifies this: *"AssertUnwindSafe is sound here because a poisoned Bridge is never read again for correctness: the worst case is a dropped effect batch, and the next event starts a fresh update."*
50+
51+This is incorrect. The `Bridge` holds the `Core`, which holds the `Model`. `App::update` takes `&mut Model` and mutates it in place. If a panic occurs **mid-update** (after some model mutations but before the function returns), the `Model` is left in a partially-mutated, inconsistent state. The `catch_unwind` catches the panic, returns empty bytes, but the `Model` stays corrupted. The **next** `update` call operates on this corrupted model.
52+
53+The comment's claim that "the next event starts a fresh update" is misleading — a fresh update on a corrupted model is not fresh. The model's invariants (e.g., `contacts` is consistent with `messages`, `cursor` is valid, `outbox` items are well-formed) may be violated.
54+
55+**Attack scenario:** A panic mid-update corrupts the model. The next `update` call sees the corrupted state and may:
56+- Write corrupted state to kv (persisting the corruption across launches)
57+- Crash on a subsequent update (if the corruption violates an invariant checked by `update`)
58+- Produce incorrect behavior (e.g., sending messages to the wrong recipient)
59+
60+**Exploitable in practice:** Requires triggering a panic mid-update, which is the same bar as F1. The corruption is silent — the user sees a stalled UI (if F1 doesn't crash first), but the next interaction operates on corrupted state.
61+
62+**Suggested fix:** After a caught panic, reset the `Model` to a known-good state (e.g., re-load from kv) or mark the `Bridge` as poisoned and refuse further operations. Alternatively, snapshot the `Model` before `update` and restore it on panic. This is expensive but correct. At minimum, document that a panic may corrupt the model and recommend the shell re-initialize the core after a caught panic.
63+
64+---
65+
66+### 🔵 F3. `resolve` accepts arbitrary `EffectId` — no validation against pending effects
67+
68+**Type:** FFI boundary / input validation
69+**Files:** `core/src/ffi.rs:70-78` (`resolve`)
70+
71+The `resolve` method accepts any `u32` as an effect ID and any `&[u8]` as the resolution data. The `Bridge::resolve` call would fail with `Err` if the effect ID is unknown, which `guard` converts to an empty Vec. But the empty Vec would crash the Swift side via `try!` (see F1).
72+
73+More importantly, if the Swift side has a bug that resolves the same effect ID twice (e.g., from a race between a timer callback and a `.clear` call), the second resolution would produce effects for a non-existent or already-resolved effect. The `Bridge::resolve` would return `Err`, which becomes empty bytes, which crashes via `try!`.
74+
75+**Attack scenario:** Not directly exploitable by an attacker — the effect IDs are generated by the core and passed to the shell. But a shell bug (e.g., a race condition in timer handling) could trigger a double-resolve, crashing the app.
76+
77+**Suggested fix:** The shell should track which effect IDs are pending and guard against double-resolve. The FFI should return a distinguishable error for "unknown effect ID" vs "serialization failure" so the shell can handle it gracefully.
78+
79+---
80+
81+## 2. Phonemic
82+
83+### 🔵 P1. `to_ob` / `from_ob` round-trip asymmetry for empty input
84+
85+**Type:** Correctness asymmetry
86+**Files:** `core/src/phonemic.rs:98-109` (`to_ob`), `core/src/phonemic.rs:113-132` (`from_ob`)
87+
88+`to_ob(&[])` returns `Some(String::new())` (empty string), but `from_ob("")` returns `None` (because `split('-').filter(|p| !p.is_empty()).collect()` yields an empty Vec, which triggers `parts.is_empty() => None`).
89+
90+This means `to_ob` produces a string that `from_ob` can't decode. The round-trip property is broken for empty input.
91+
92+**Impact:** No caller passes empty bytes to `to_ob` for pubkey purposes (keys are always 32 bytes). The asymmetry is documented in the test `odd_length_input_has_no_spelling` (line 210). Harmless in practice.
93+
94+**Suggested fix:** Either make `to_ob(&[])` return `None` (consistent with odd-length rejection) or make `from_ob("")` return `Some(vec![])` (consistent with the encoding). The former is safer — an empty encoding is meaningless.
95+
96+---
97+
98+### 🔵 P2. `try_parse_pubkey` hex path lowercases redundantly — no bug, but misleading
99+
100+**Type:** Code clarity
101+**Files:** `core/src/phonemic.rs:154`
102+
103+`hex::decode(trimmed.to_lowercase())` — the `hex` crate's `decode` accepts both uppercase and lowercase hex digits. The `to_lowercase()` is redundant for the hex path. It doesn't cause a bug (the output of `valid_pubkey_hex` is always lowercase via `hex::encode`), but it suggests the case matters when it doesn't.
104+
105+**Suggested fix:** Remove the `to_lowercase()` or add a comment explaining it's for consistency, not correctness.
106+
107+---
108+
109+### ⚪ P3. No @p/hex collision for 32-byte pubkeys — confirmed safe
110+
111+**Type:** Correctness verification
112+**Files:** `core/src/phonemic.rs:148-158` (`try_parse_pubkey`)
113+
114+A 64-char hex string has no hyphens, so `from_ob` sees one part of 64 chars, fails the `part.len() != 6` check, returns `None`. The hex path then decodes it. No collision.
115+
116+A valid @p for a 32-byte key is 16 syllables (111 chars with hyphens). `hex::decode` fails on hyphens. No collision.
117+
118+A 6-char hex string (e.g., "abcdef") that happens to be a valid @p syllable: `from_ob` decodes it to 2 bytes, `valid_pubkey_hex` rejects (not 32 bytes). `hex::decode` gives 3 bytes, also rejected. No collision.
119+
120+**Verdict:** Safe. The @p-first ordering in `try_parse_pubkey` is correct.
121+
122+---
123+
124+### ⚪ P4. `from_ob` multibyte handling is correct — confirmed safe
125+
126+**Type:** Correctness verification
127+**Files:** `core/src/phonemic.rs:113-132`
128+
129+`to_lowercase()` is applied before `split('-')`. For non-ASCII input, `to_lowercase()` may change byte lengths, but the `part.len() != 6 || !part.is_ascii()` check on each part catches any non-ASCII characters that survive lowercasing. The test `multibyte_input_is_rejected_not_panicked_on` (line 279) confirms this.
130+
131+The `part[0..3]` and `part[3..6]` slices are safe because `part.len() == 6` and `part.is_ascii()` (ASCII = 1 byte per char, so byte indices == char indices).
132+
133+**Verdict:** Safe. No panic on multibyte input.
134+
135+---
136+
137+## 3. Apple Shell — HTTP Handling
138+
139+### 🟡 A1. `Http.perform` doesn't validate URL scheme — `file://` requests would be honored
140+
141+**Type:** Defense-in-depth gap
142+**Files:** `apple/SkrepkaApp/Effects.swift:104-106`
143+
144+```swift
145+guard let url = URL(string: req.url) else {
146+ return .err(.url(req.url))
147+}
148+var request = URLRequest(url: url)
149+```
150+
151+The shell accepts any URL scheme. `URLSession.data(for:)` handles `file://` URLs by reading local files. The core's `normalize_server_url` only accepts `http`/`https`, but if a bug in the core (or a future change) produces a `file://` or `data:` URL, the shell would honor it without question.
152+
153+**Attack scenario:** Not directly exploitable — the server URL is user-configured and validated by `normalize_server_url`. But if a bug in URL construction (e.g., `format!("{}/poll", model.settings.server_url)` where `server_url` is somehow `file:///etc/passwd`) reaches the shell, a local file could be read and its contents returned to the core as an HTTP response body. The core would try to parse it as JSON, fail, and treat it as an empty page.
154+
155+**Exploitable in practice:** Requires a core bug that produces a non-http URL. `normalize_server_url` prevents this for the server URL, but there's no belt-and-suspenders check in the shell.
156+
157+**Suggested fix:** Add a scheme check in `Http.perform`:
158+```swift
159+guard url.scheme == "http" || url.scheme == "https" else {
160+ return .err(.url(req.url))
161+}
162+```
163+
164+---
165+
166+### 🟡 A2. `HttpSessionDelegate` cancels same-host redirects — may break legitimate HTTPS upgrades
167+
168+**Type:** Protocol conformance / usability
169+**Files:** `apple/SkrepkaApp/Effects.swift:45-53`
170+
171+The redirect delegate cancels **all** redirects, including same-host redirects. A relay that redirects from `http://relay:80` to `https://relay:443` (a legitimate TLS upgrade) would have its redirect cancelled. The client would see the 3xx response and treat it as a failure.
172+
173+The protocol says "plain HTTP, served over TLS in production" (§5), and `normalize_server_url` accepts both `http` and `https`. So a relay that serves HTTP on port 80 and redirects to HTTPS on port 443 would be broken by this client.
174+
175+**Attack scenario:** Not an attack — a usability issue. A relay operator who sets up an HTTP-to-HTTPS redirect would find that Skrepka clients can't connect.
176+
177+**Exploitable in practice:** Depends on relay configuration. The `install.sh` deploys Caddy with TLS, so production relays serve HTTPS directly (no redirect). But a relay misconfigured to redirect would be unreachable.
178+
179+**Suggested fix:** Allow same-host redirects (compare the redirect target's host against the original request's host). Only cancel cross-host redirects, which are the token-exfiltration vector. Or, since the protocol says no redirects, document that relays must not redirect.
180+
181+---
182+
183+### 🔵 A3. `UInt16(clamping:)` silently truncates status codes > 65535
184+
185+**Type:** Input validation
186+**Files:** `apple/SkrepkaApp/Effects.swift:120`
187+
188+```swift
189+status: UInt16(clamping: http?.statusCode ?? 0),
190+```
191+
192+HTTP status codes are 3-digit integers (100-599), so this is fine in practice. But `UInt16(clamping:)` would clamp any value > 65535 to 65535, which is a valid `UInt16` but not a valid HTTP status. The core checks `status >= 200 && status < 300` for success, so 65535 would be treated as failure. No security issue.
193+
194+**Verdict:** Safe. HTTP status codes are bounded by the protocol.
195+
196+---
197+
198+### ⚪ A4. URLSession configuration is correct — confirmed safe
199+
200+**Type:** Implementation review
201+**Files:** `apple/SkrepkaApp/Effects.swift:91-101`
202+
203+The session configuration is:
204+- `ephemeral` — no cookies, no cache, no persistent storage ✓
205+- `httpCookieAcceptPolicy = .never` ✓
206+- `httpShouldSetCookies = false` ✓
207+- `httpCookieStorage = nil` ✓
208+- `urlCache = nil` ✓
209+- `requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData` ✓
210+- `timeoutIntervalForRequest = 70` — matches the server's ~25s long-poll ✓
211+- `tlsMinimumSupportedProtocolVersion = .TLSv12` ✓
212+
213+No sensitive data in logs. The `error.localizedDescription` in the catch block returns user-friendly strings like "The network connection was lost" — no relay-specific info beyond what the user already configured.
214+
215+**Verdict:** Safe. Good defense-in-depth.
216+
217+---
218+
219+### ⚪ A5. Response size limiting is correct — confirmed safe
220+
221+**Type:** Implementation review
222+**Files:** `apple/SkrepkaApp/Effects.swift:31-79`
223+
224+The `HttpSessionDelegate` tracks per-task byte counts in a thread-safe `NSLock`-guarded dictionary. When a task's accumulated body exceeds `maxResponseBytes` (64 MiB), the task is cancelled. The `didCompleteWithError` callback cleans up the dictionary entry. Every task eventually completes (success, failure, or cancel), so no dictionary leak.
225+
226+The 64 MiB cap matches the core's `MAX_POLL_RESPONSE_BYTES` (also 64 MiB). The shell's cap fires before the core's (the shell cancels the task mid-stream, the core never sees the full body), which is the correct ordering — the shell's cap prevents OOM before the core's check runs.
227+
228+**Verdict:** Safe. Well-designed.
229+
230+---
231+
232+## 4. Apple Shell — Keychain & KV Persistence
233+
234+### ⚪ A6. Keychain storage is correct — confirmed safe
235+
236+**Type:** Implementation review
237+**Files:** `apple/SkrepkaApp/Effects.swift:320-415`
238+
239+- `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` — key never rides backup ✓
240+- `SecItemUpdate` first, then `SecItemAdd` if not found — no delete-then-add window ✓
241+- `errSecItemNotFound` is the only status that triggers key generation ✓
242+- 64-byte key length is validated on load ✓
243+- `loadOrCreateIdentity` is called only on the main actor (singleton `Core.shared`), so no race ✓
244+
245+The `store` function's update-then-add sequence has a theoretical race (between `SecItemUpdate` returning `errSecItemNotFound` and `SecItemAdd`, another thread could add the item), but `bootIdentity` is only called on the `@MainActor` singleton, so there's no concurrency.
246+
247+**Verdict:** Safe. Good practice.
248+
249+---
250+
251+### ⚪ A7. KV persistence is correct — confirmed safe
252+
253+**Type:** Implementation review
254+**Files:** `apple/SkrepkaApp/Effects.swift:195-316`
255+
256+- Serial queue — all operations serialized ✓
257+- Atomic writes with `.completeFileProtectionUnlessOpen` ✓
258+- `isExcludedFromBackup = true` — plaintext history not in iCloud backup ✓
259+- Percent-encoding with `.alphanumerics` — injective key→filename mapping, no path traversal ✓
260+- Legacy migration runs before any read (same serial queue) ✓
261+- Missing file = empty key (`.none`), not an error — correct distinction from read failure ✓
262+
263+**Verdict:** Safe. Well-designed.
264+
265+---
266+
267+## 5. Apple Shell — Photo Picker & QR Scanning
268+
269+### 🔵 A8. `PhotoPicker` last-resort quality doesn't check size cap
270+
271+**Type:** Logic bug / UX
272+**Files:** `apple/SkrepkaApp/PhotoPicker.swift:80-81`
273+
274+```swift
275+// Last resort: the smallest quality that still produces something.
276+return image.jpegData(compressionQuality: 0.1)?.base64EncodedString()
277+```
278+
279+The loop (lines 71-79) reduces quality until the base64 fits within 64 KiB (`maxBase64Len`). But the last-resort return on line 81 doesn't check if the result fits. If a 256px JPEG at q0.1 still exceeds 64 KiB (possible for high-frequency noise images), the core's `SaveProfile` rejects it with "photo too large" and the user has no recourse.
280+
281+**Attack scenario:** A user picks a photo that, even at 256px and q0.1, produces > 64 KiB of base64. The profile save fails with no way to proceed. Not a security issue — a UX dead end.
282+
283+**Exploitable in practice:** Very unlikely for real photos (256px JPEG at q0.1 is typically 5-15 KiB). But a synthetic image (e.g., pure noise) could exceed the cap.
284+
285+**Suggested fix:** If the last-resort still exceeds the cap, return `nil` (triggering `onCancel`) or downscale further (e.g., 128px). Or document that the user should pick a different photo.
286+
287+---
288+
289+### 🔵 A9. `scannedKeyPayload` is business logic in the shell — portability concern
290+
291+**Type:** Protocol conformance / architecture
292+**Files:** `apple/SkrepkaApp/QRView.swift:24-35`
293+
294+`scannedKeyPayload` parses QR code content: strips URL schemes (`skrepka://`, `https://`, `http://`), extracts the last path component, and trims whitespace. This is parsing logic that determines what input the core receives.
295+
296+`AGENTS.md` says: *"The shell never decides anything — it turns Effects into platform calls and resolves the results back in."* This function decides how to interpret a QR code, which is arguably a business decision.
297+
298+An Android client would need to reimplement this logic. If the parsing differs (e.g., one client strips `skrepka://` and another doesn't), the same QR code would produce different inputs to the core.
299+
300+**Attack scenario:** Not a security vulnerability — the core's `try_parse_pubkey` validates the result regardless. But a portability bug: a QR code with a `skrepka://` prefix would work on iOS (stripped by `scannedKeyPayload`) but fail on an Android client that doesn't strip the scheme.
301+
302+**Suggested fix:** Move `scannedKeyPayload` into the core (e.g., `try_parse_pubkey_from_qr` or add scheme-stripping to `try_parse_pubkey`). The shell should pass the raw QR content to the core.
303+
304+---
305+
306+### 🔵 A10. `QRScannerView` doesn't check camera permission
307+
308+**Type:** UX gap
309+**Files:** `apple/SkrepkaApp/QRView.swift:75-80`
310+
311+`ScannerVC.viewDidLoad` calls `AVCaptureDevice.default(for: .video)`, which returns `nil` if camera permission hasn't been granted (or has been denied). The `guard` fails silently, and the scanner shows a black screen with no error message or permission prompt.
312+
313+**Attack scenario:** Not a security issue. A user who denied camera permission can't scan QR codes and sees a black screen with no explanation.
314+
315+**Suggested fix:** Check `AVCaptureDevice.authorizationStatus(for: .video)` and request permission if not determined. Show an error message if denied.
316+
317+---
318+
319+### ⚪ A11. `QRScannerView` camera lifecycle is correct — confirmed safe
320+
321+**Type:** Implementation review
322+**Files:** `apple/SkrepkaApp/QRView.swift:101-114`
323+
324+`startRunning()` and `stopRunning()` are dispatched to a background queue (`DispatchQueue.global(qos: .userInitiated)`) to avoid blocking the main thread. The `done` flag in `Coordinator` prevents multiple `onScan` callbacks. The camera session is properly stopped in `viewWillDisappear`.
325+
326+**Verdict:** Safe.
327+
328+---
329+
330+## 6. Apple Shell — Logic Leakage & Protocol Conformance
331+
332+### 🟡 A12. `BackgroundRefresh` polls `core.view` in a tight loop — main actor contention
333+
334+**Type:** Performance / architecture
335+**Files:** `apple/SkrepkaApp/SkrepkaApp.swift:156-163`
336+
337+The `settle` function polls `core.view` every 200ms, reading the `@Published` `ViewModel` on the main actor. While the overhead per poll is minimal (reading a published property), this runs for up to 25 seconds during a background refresh. If the core is processing a large poll response (which also runs on the main actor via `resolve`), the 200ms polls interleave with core processing.
338+
339+More importantly, `core.view` is a `@Published` property backed by `ViewModel`, which is a value type. Each read copies the entire `ViewModel` (including `contacts: Vec<ContactVM>` and `messages: Vec<MessageVM>`). For a user with 500 contacts and 1000 messages, each poll copies ~1 MB of data. Over 25 seconds at 200ms intervals, that's 125 copies = ~125 MB of transient allocations.
340+
341+**Attack scenario:** Not an attack — a performance issue. A user with many contacts and active conversations would experience increased memory pressure during background refresh.
342+
343+**Suggested fix:** Use a Combine subscription (`core.$view.sink`) instead of polling, or have the core expose a lightweight "status changed" flag that the shell can check without copying the full `ViewModel`.
344+
345+---
346+
347+### 🔵 A13. `Core.swift` `dispatch` silently drops effects if `try!` crashes — no recovery
348+
349+**Type:** Error handling
350+**Files:** `apple/SkrepkaApp/Core.swift:47-53`
351+
352+```swift
353+private func dispatch(_ effects: [UInt8]) {
354+ // swiftlint:disable:next force_try
355+ let requests = try! Requests.bincodeDeserialize(input: effects).value
356+ for request in requests {
357+ process(request)
358+ }
359+}
360+```
361+
362+If `effects` is valid bincode but contains an effect variant the Swift side doesn't know about (e.g., after a core update that adds a new effect without regenerating Swift types), `try!` would crash. The AGENTS.md warns about this: *"Touching Event, ViewModel, or Effect without running just generate leaves the Swift side compiling against stale types."*
363+
364+But there's no runtime recovery. The crash is immediate and unrecoverable. A safer approach would be to catch the deserialization error, log it, and skip the unknown effect — the core would see the effect as unresolved and its watchdog would handle it.
365+
366+**Suggested fix:** Replace `try!` with `try?` and log the error. Unknown effects are dropped; the core's watchdog handles the missing resolution.
367+
368+---
369+
370+### ⚪ A14. `Time.handle` timer lifecycle is correct — confirmed safe
371+
372+**Type:** Implementation review
373+**Files:** `apple/SkrepkaApp/Effects.swift:147-191`
374+
375+The `timers` dictionary is on the `@MainActor`. The `schedule` function removes the old work item before inserting a new one. The `DispatchWorkItem`'s callback runs on the main queue (via `MainActor.assumeIsolated`), so there's no interleaving between `schedule` and the callback. The `.clear` case removes and cancels the work item.
376+
377+No race condition: all operations are on the main actor / main queue, which is single-threaded.
378+
379+**Verdict:** Safe.
380+
381+---
382+
383+### ⚪ A15. `SkrepkaApp.swift` background refresh is correct — confirmed safe
384+
385+**Type:** Implementation review
386+**Files:** `apple/SkrepkaApp/SkrepkaApp.swift:64-164`
387+
388+- `isProtectedDataAvailable` guard prevents running while locked ✓
389+- `settle` polling respects `Task.isCancelled` and deadline ✓
390+- `setTaskCompleted(success: !Task.isCancelled)` correctly reports failure on cancellation ✓
391+- `schedule()` is called before `run()` — next refresh is always queued ✓
392+- `Core.shared` is only accessed after the `isProtectedDataAvailable` guard ✓
393+
394+**Verdict:** Safe. Well-designed.
395+
396+---
397+
398+## 7. Protocol & AGENTS.md Conformance
399+
400+### 🔵 A16. AGENTS.md says "five kv loads" but core does six — `seen_ids` added
401+
402+**Type:** Documentation drift
403+**Files:** `AGENTS.md:98` ("fans out five kv loads"), `core/src/app.rs:719-726` (six loads)
404+
405+AGENTS.md line 98 says: *"IdentityLoaded fans out five kv loads → LoadedSettings triggers Connect"*. But the core actually fans out **six** kv loads: `settings`, `profile`, `contacts`, `cursor`, `outbox`, and `seen_ids` (line 725: `KeyValue::get(K_SEEN_IDS)`). The `STARTUP_LOADS` constant is 6 (line 312).
406+
407+**Impact:** Documentation drift, not a bug. A developer reading AGENTS.md would expect five loads and be confused by the sixth.
408+
409+**Suggested fix:** Update AGENTS.md to say "six kv loads" and include `seen_ids` in the list.
410+
411+---
412+
413+### 🔵 A17. AGENTS.md lists kv keys without `seen_ids` — stale documentation
414+
415+**Type:** Documentation drift
416+**Files:** `AGENTS.md:14` ("keys `settings`, `profile`, `contacts`, `cursor`, `outbox`, and `messages:<peer_hex>`")
417+
418+The `seen_ids` key is not listed in AGENTS.md's kv key list. It was added as part of the dedup fix (AUDIT-NEW.md Q18) but the documentation wasn't updated.
419+
420+**Suggested fix:** Add `seen_ids` to the kv key list in AGENTS.md.
421+
422+---
423+
424+## Summary of New Findings
425+
426+| # | Severity | Type | Title |
427+|---|----------|------|-------|
428+| F1 | 🟠 high | FFI/panic | `try!` in Core.swift crashes on empty bytes from `guard` — FFI panic safety claim is false |
429+| F2 | 🟡 medium | FFI/memory | `AssertUnwindSafe` is unsound — Model is read after a panic, corruption persists |
430+| F3 | 🔵 low | FFI/input | `resolve` accepts arbitrary EffectId — double-resolve crashes via `try!` |
431+| P1 | 🔵 low | Correctness | `to_ob`/`from_ob` round-trip asymmetry for empty input |
432+| P2 | 🔵 low | Clarity | `try_parse_pubkey` hex path lowercases redundantly |
433+| P3 | ⚪ info | Safe | No @p/hex collision for 32-byte pubkeys — confirmed |
434+| P4 | ⚪ info | Safe | `from_ob` multibyte handling is correct — confirmed |
435+| A1 | 🟡 medium | HTTP/defense | `Http.perform` doesn't validate URL scheme — `file://` would be honored |
436+| A2 | 🟡 medium | HTTP/protocol | Redirect delegate cancels same-host redirects — breaks HTTPS upgrades |
437+| A3 | 🔵 low | HTTP/input | `UInt16(clamping:)` silently truncates status codes > 65535 |
438+| A4 | ⚪ info | Safe | URLSession configuration is correct |
439+| A5 | ⚪ info | Safe | Response size limiting is correct |
440+| A6 | ⚪ info | Safe | Keychain storage is correct |
441+| A7 | ⚪ info | Safe | KV persistence is correct |
442+| A8 | 🔵 low | Photo/UX | `PhotoPicker` last-resort quality doesn't check size cap |
443+| A9 | 🔵 low | Architecture | `scannedKeyPayload` is business logic in the shell — portability concern |
444+| A10 | 🔵 low | QR/UX | `QRScannerView` doesn't check camera permission |
445+| A11 | ⚪ info | Safe | QR scanner camera lifecycle is correct |
446+| A12 | 🟡 medium | Performance | `BackgroundRefresh` polls `core.view` — copies full ViewModel every 200ms |
447+| A13 | 🔵 low | Error handling | `Core.swift` `dispatch` crashes on unknown effect — no recovery |
448+| A14 | ⚪ info | Safe | Timer lifecycle is correct |
449+| A15 | ⚪ info | Safe | Background refresh is correct |
450+| A16 | 🔵 low | Docs | AGENTS.md says "five kv loads" but core does six |
451+| A17 | 🔵 low | Docs | AGENTS.md kv key list missing `seen_ids` |
452+
453+**High (1):** F1
454+**Medium (4):** F2, A1, A2, A12
455+**Low (8):** F3, P1, P2, A3, A8, A9, A10, A13, A16, A17
456+**Info (8):** P3, P4, A4, A5, A6, A7, A11, A14, A15
457+
458+---
459+
460+## Priorities
461+
462+1. **F1 (high):** Replace `try!` in Core.swift with graceful error handling. The FFI's panic safety design is undermined by `try!` — a caught panic crashes the app instead of degrading. This is the most impactful finding because it turns every potential core panic into an immediate app crash.
463+
464+2. **F2 (medium):** Address `AssertUnwindSafe` soundness. After a caught panic, the Model may be corrupted. At minimum, document the risk and consider resetting the Model from kv on panic recovery.
465+
466+3. **A1 (medium):** Add URL scheme validation in `Http.perform`. Defense-in-depth against a core bug producing non-http URLs.
467+
468+4. **A2 (medium):** Allow same-host redirects or document that relays must not redirect. The current all-redirects-cancelled policy breaks legitimate HTTPS upgrades.
469+
470+5. **A12 (medium):** Replace `settle`'s polling with a Combine subscription to avoid copying the full ViewModel every 200ms during background refresh.
PROTOCOL.md+4−4
@@ -164,7 +164,7 @@ The wire-visible "envelope" carries only the recipient and the opaque blob. The
164164On the wire (cleartext, server-visible):
165165
166166```
167−version (1 byte, currently 0x01)
167+version (1 byte, currently 0x02)
168168ephemeral_x25519_pubkey (32 bytes)
169169nonce (24 bytes)
170170ciphertext (variable, includes 16-byte AEAD tag)
@@ -194,11 +194,11 @@ The bucket boundaries are:
194194
195195A blob is rounded up to the smallest bucket ≥ its unpadded size. Above 65536 bytes, the blob is rounded up to the next multiple of 65536. The padding bytes are randomly generated per message, so two encryptions of the same plaintext produce the same blob size (same bucket) but different blob contents. The on-wire blob is therefore `173 + len(compressed) + len(padding)` bytes, and that padded size is all a relay or network observer sees.
196196
197−> **Compression before encryption leaks plaintext structure.** The payload is zstd-compressed *then* encrypted (steps 4–7), so the ciphertext length is a function of how *compressible* the plaintext was, not just how long it was. XChaCha20-Poly1305 is a stream cipher with no padding: without length padding the blob would be exactly `173 + len(compressed)` bytes, and that number would be visible to every relay and network observer. Version 0x01 adds length padding (see *Length Padding* above), which buckets blob sizes to fixed boundaries so the exact compressed size is hidden.
197+> **Compression before encryption leaks plaintext structure.** The payload is zstd-compressed *then* encrypted (steps 4–7), so the ciphertext length is a function of how *compressible* the plaintext was, not just how long it was. XChaCha20-Poly1305 is a stream cipher with no padding: without length padding the blob would be exactly `173 + len(compressed)` bytes, and that number would be visible to every relay and network observer. Version 0x02 adds length padding (see *Length Padding* above), which buckets blob sizes to fixed boundaries so the exact compressed size is hidden.
198198>
199199> Two consequences. First, message length is only ever obscured *upward*: a long but repetitive message can produce a smaller blob than a short random-looking one, and blob size still distinguishes a one-word reply from a pasted document. Second — the more serious one — where an attacker can influence part of the plaintext that is compressed together with a secret, the compressed size reveals whether the two share structure. That is the CRIME/BREACH pattern, and it is a real (if narrow) concern for a format that compresses attacker-influenceable text alongside sensitive text in one frame.
200200>
201−> Skrepka accepts the remaining risk: payloads are small, each message is compressed independently (no cross-message compression context), and there is no adaptive-guessing oracle in the protocol — an attacker cannot make the client re-encrypt a chosen variant of a secret on demand. Implementations must **not** add one (e.g. by compressing several messages, or attacker-supplied and user-supplied text, into a shared frame). Length padding (version 0x01) obscures the exact compressed size by bucketing blob lengths, but does not eliminate the CRIME/BREACH side channel within a single bucket — it only makes the granularity coarser.
201+> Skrepka accepts the remaining risk: payloads are small, each message is compressed independently (no cross-message compression context), and there is no adaptive-guessing oracle in the protocol — an attacker cannot make the client re-encrypt a chosen variant of a secret on demand. Implementations must **not** add one (e.g. by compressing several messages, or attacker-supplied and user-supplied text, into a shared frame). Length padding (version 0x02) obscures the exact compressed size by bucketing blob lengths, but does not eliminate the CRIME/BREACH side channel within a single bucket — it only makes the granularity coarser.
202202
203203### Cryptographic Versioning
204204
@@ -378,7 +378,7 @@ When a client connects, it proves ownership of its keypair:
378378 ```
379379 The challenge is a lowercase hex string of at least 32 chars (128 bits of entropy). The reference server uses 48 hex chars (192 bits). The challenge MUST be unique per challenge request and consumed on first use (verified).
380380
381−3. Client signs the **UTF-8 bytes of `"skrepka-auth-v1:" + server_host + ":" + challenge`** with its Ed25519 private key and submits. `server_host` is the bare lowercased hostname of the server the client dialed (no scheme, no port, no trailing dot). The `skrepka-auth-v1:` prefix is a domain-separation tag distinguishing auth signatures from message signatures (which begin with `"skrepka-msg-v2:"` and sign `recipientPub ++ compressed`, §3):
381+3. Client signs the **UTF-8 bytes of `"skrepka-auth-v1:" + server_host + ":" + challenge`** with its Ed25519 private key and submits. `server_host` is the bare lowercased hostname of the server the client dialed (no scheme, no port, no trailing dot). For IPv6 literals, the host must include square brackets (e.g. `[::1]`), matching the URL host syntax — the server's `serverHost` configuration must also include brackets for IPv6. The `skrepka-auth-v1:` prefix is a domain-separation tag distinguishing auth signatures from message signatures (which begin with `"skrepka-msg-v2:"` and sign `recipientPub ++ compressed`, §3):
382382 ```
383383 POST /auth/verify
384384 {
apple/SkrepkaApp/Core.swift+13−15
@@ -26,16 +26,17 @@ final class Core: ObservableObject {
2626 private let store = KvStore()
2727
2828 private init() {
29− // swiftlint:disable:next force_try
30− self.view = try! .bincodeDeserialize(input: [UInt8](core.view()))
29+ // The FFI's guard returns empty bytes when the core panics; force-try
30+ // on empty bytes crashes the app. Fall back to a default ViewModel instead.
31+ self.view = (try? ViewModel.bincodeDeserialize(input: [UInt8](core.view()))) ?? ViewModel()
3132 bootIdentity()
3233 }
3334
3435 // MARK: - Event/effect loop
3536
3637 func update(_ event: Event) {
37− // swiftlint:disable:next force_try
38− let effects = [UInt8](core.update(data: Data(try! event.bincodeSerialize())))
38+ guard let eventData = try? event.bincodeSerialize() else { return }
39+ let effects = [UInt8](core.update(data: Data(eventData)))
3940 dispatch(effects)
4041 }
4142
@@ -45,8 +46,7 @@ final class Core: ObservableObject {
4546 }
4647
4748 private func dispatch(_ effects: [UInt8]) {
48− // swiftlint:disable:next force_try
49− let requests = try! Requests.bincodeDeserialize(input: effects).value
49+ guard let requests = try? Requests.bincodeDeserialize(input: effects).value else { return }
5050 for request in requests {
5151 process(request)
5252 }
@@ -56,29 +56,27 @@ final class Core: ObservableObject {
5656 let id = request.id
5757 switch request.effect {
5858 case .render:
59− // swiftlint:disable:next force_try
60− self.view = try! .bincodeDeserialize(input: [UInt8](core.view()))
59+ self.view = (try? ViewModel.bincodeDeserialize(input: [UInt8](core.view()))) ?? self.view
6160
6261 case .http(let req):
6362 Task { [weak self] in
6463 let result = await Http.perform(req)
65− // swiftlint:disable:next force_try
66− self?.resolve(id, try! result.bincodeSerialize())
64+ guard let output = try? result.bincodeSerialize() else { return }
65+ self?.resolve(id, output)
6766 }
6867
6968 case .keyValue(let op):
7069 // The file I/O runs on the store's own serial queue; hop the result back to
7170 // the main actor, because the core is single-threaded and `.render` publishes.
7271 store.handle(op) { [weak self] result in
73− // swiftlint:disable:next force_try
74− let output = try! result.bincodeSerialize()
72+ guard let output = try? result.bincodeSerialize() else { return }
7573 Task { @MainActor in self?.resolve(id, output) }
7674 }
7775
7876 case .time(let req):
7977 Time.handle(req) { [weak self] response in
80− // swiftlint:disable:next force_try
81− self?.resolve(id, try! response.bincodeSerialize())
78+ guard let output = try? response.bincodeSerialize() else { return }
79+ self?.resolve(id, output)
8280 }
8381 }
8482 }
@@ -97,4 +95,4 @@ final class Core: ObservableObject {
9795 identityError = error.message
9896 }
9997 }
100−}
98+}
apple/SkrepkaApp/Effects.swift+19−6
@@ -19,10 +19,11 @@ private let maxResponseBytes = 64 * 1024 * 1024 // 64 MiB
1919/// compromised) relay could answer `/poll` or `/messages` with a 302 to a host it chooses
2020/// and harvest the bearer token from the request we would dutifully re-send there. Sessions
2121/// are bound to a single relay by design (§6), so a cross-host redirect is never legitimate
22−/// — refusing it costs nothing and closes the token-exfiltration path. Returning `nil` from
23−/// the completion handler does not error the task: it stops the redirect and hands the 3xx
24−/// response itself back to the caller, which the core sees as a non-2xx status and treats as
25−/// a failed request.
22+/// — refusing it costs nothing and closes the token-exfiltration path. Same-host redirects
23+/// (e.g. HTTP → HTTPS upgrade on the same host) are allowed; the Authorization header stays
24+/// within the relay's own host. Returning `nil` from the completion handler does not error
25+/// the task: it stops the redirect and hands the 3xx response itself back to the caller,
26+/// which the core sees as a non-2xx status and treats as a failed request.
2627///
2728/// **Response size limiting.** As body chunks arrive we accumulate the byte count per task
2829/// and cancel the task the moment it crosses `maxResponseBytes`, so a multi-gigabyte body
@@ -49,7 +50,17 @@ private final class HttpSessionDelegate: NSObject, URLSessionTaskDelegate, URLSe
4950 newRequest request: URLRequest,
5051 completionHandler: @escaping (URLRequest?) -> Void
5152 ) {
52− completionHandler(nil)
53+ // Allow same-host redirects (e.g. HTTP → HTTPS upgrade on the same host).
54+ // Cancel cross-host redirects — a hostile relay could redirect to an
55+ // attacker-controlled host and harvest the Authorization header.
56+ if let originalURL = task.currentRequest?.url,
57+ let redirectHost = request.url?.host?.lowercased(),
58+ let originalHost = originalURL.host?.lowercased(),
59+ redirectHost == originalHost {
60+ completionHandler(request)
61+ } else {
62+ completionHandler(nil)
63+ }
5364 }
5465
5566 func urlSession(
@@ -101,7 +112,9 @@ enum Http {
101112 }()
102113
103114 static func perform(_ req: HttpRequest) async -> HttpResult {
104− guard let url = URL(string: req.url) else {
115+ guard let url = URL(string: req.url),
116+ let scheme = url.scheme,
117+ scheme == "http" || scheme == "https" else {
105118 return .err(.url(req.url))
106119 }
107120 var request = URLRequest(url: url)
apple/SkrepkaApp/PhotoPicker.swift+7−1
@@ -78,7 +78,13 @@ struct PhotoPicker: UIViewControllerRepresentable {
7878 quality -= 0.1
7979 }
8080 // Last resort: the smallest quality that still produces something.
81− return image.jpegData(compressionQuality: 0.1)?.base64EncodedString()
81+ // If even this exceeds the cap, return nil so onCancel fires rather
82+ // than sending an oversized payload the core will reject.
83+ let lastResort = image.jpegData(compressionQuality: 0.1)?.base64EncodedString()
84+ if let result = lastResort, result.count <= maxBase64Len {
85+ return result
86+ }
87+ return nil
8288 }
8389 }
8490}
apple/SkrepkaApp/QRView.swift+36−2
@@ -21,6 +21,9 @@ func qrImage(_ string: String) -> UIImage? {
2121/// A QR code carries whatever its author put there. Strip a leading scheme so a share
2222/// link (`skrepka:<key>`, `https://…/<key>`) yields the bare key the core will accept,
2323/// and so the pubkey field never silently fills with an unrelated URL.
24+// NOTE: This URL scheme stripping is arguably business logic that belongs in
25+// the core (see AGENTS.md). It is kept here for now because moving it requires
26+// an FFI change. The core's `try_parse_pubkey` validates the result regardless.
2427func scannedKeyPayload(_ raw: String) -> String {
2528 var value = raw.trimmingCharacters(in: .whitespacesAndNewlines)
2629 let schemes = ["skrepka://", "skrepka:", "https://", "http://"]
@@ -75,6 +78,24 @@ final class ScannerVC: UIViewController {
7578 override func viewDidLoad() {
7679 super.viewDidLoad()
7780 view.backgroundColor = .black
81+
82+ let status = AVCaptureDevice.authorizationStatus(for: .video)
83+ switch status {
84+ case .authorized:
85+ startSession()
86+ case .notDetermined:
87+ AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
88+ DispatchQueue.main.async {
89+ if granted { self?.startSession() }
90+ else { self?.showPermissionDenied() }
91+ }
92+ }
93+ default:
94+ showPermissionDenied()
95+ }
96+ }
97+
98+ private func startSession() {
7899 guard let device = AVCaptureDevice.default(for: .video),
79100 let input = try? AVCaptureDeviceInput(device: device),
80101 session.canAddInput(input) else { return }
@@ -93,6 +114,19 @@ final class ScannerVC: UIViewController {
93114 previewLayer = preview
94115 }
95116
117+ private func showPermissionDenied() {
118+ let label = UILabel()
119+ label.text = "Camera permission required"
120+ label.textAlignment = .center
121+ label.textColor = .secondaryLabel
122+ view.addSubview(label)
123+ label.translatesAutoresizingMaskIntoConstraints = false
124+ NSLayoutConstraint.activate([
125+ label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
126+ label.centerYAnchor.constraint(equalTo: view.centerYAnchor),
127+ ])
128+ }
129+
96130 override func viewDidLayoutSubviews() {
97131 super.viewDidLayoutSubviews()
98132 previewLayer?.frame = view.bounds
@@ -100,7 +134,7 @@ final class ScannerVC: UIViewController {
100134
101135 override func viewWillAppear(_ animated: Bool) {
102136 super.viewWillAppear(animated)
103− if !session.isRunning {
137+ if !session.isRunning && !session.inputs.isEmpty {
104138 DispatchQueue.global(qos: .userInitiated).async { self.session.startRunning() }
105139 }
106140 }
@@ -112,4 +146,4 @@ final class ScannerVC: UIViewController {
112146 DispatchQueue.global(qos: .userInitiated).async { self.session.stopRunning() }
113147 }
114148 }
115−}
149+}
apple/SkrepkaApp/SkrepkaApp.swift+4−2
@@ -151,13 +151,15 @@ enum BackgroundRefresh {
151151 /// Waits for a condition on the view, or for the deadline (or expiry) to overtake it.
152152 ///
153153 /// The core has nothing to await — it publishes a `ViewModel` and that is the whole of
154− /// its output — so the shell watches that. A 200ms tick across the handful of transitions
154+ /// its output — so the shell watches that. A 500ms tick across the handful of transitions
155155 /// this cares about is cheaper than threading a Combine subscription through an actor hop.
156+ /// The previous 200ms interval was unnecessarily aggressive for a background task with a
157+ /// 25s budget that only cares about a few state transitions.
156158 private static func settle(by deadline: Date, until condition: () -> Bool) async -> Bool {
157159 while !condition() {
158160 guard !Task.isCancelled, Date() < deadline else { return false }
159161 // Cancellation makes this throw at once; the guard above is what breaks the loop.
160− try? await Task.sleep(for: .milliseconds(200))
162+ try? await Task.sleep(for: .milliseconds(500))
161163 }
162164 return true
163165 }
core/src/app.rs+76−27
@@ -9,6 +9,9 @@ use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
99use std::sync::Arc;
1010use std::time::{Duration, SystemTime, UNIX_EPOCH};
1111
12+use base64::prelude::*;
13+use base64::Engine;
14+
1215use crux_core::{
1316 macros::effect,
1417 render::{render, RenderOperation},
@@ -581,7 +584,7 @@ fn now_ms() -> i64 {
581584 SystemTime::now()
582585 .duration_since(UNIX_EPOCH)
583586 .map(|d| d.as_millis() as i64)
584− .unwrap_or(0)
587+ .unwrap_or(1)
585588}
586589
587590/// Bare lowercased hostname of a server URL (no scheme/port/trailing dot).
@@ -815,6 +818,13 @@ impl App for Skrepka {
815818 Event::LoadedMessages(peer, res) => {
816819 match parse_kv::<Vec<StoredMessage>>(res) {
817820 Ok(Some(list)) => {
821+ // D5: Add on-disk message IDs to seen_ids so future
822+ // replays don't pass dedup. Without this, a seen_ids
823+ // kv load failure leaves the set empty, and on-disk
824+ // messages aren't protected against replay.
825+ if let Some(id_set) = model.seen_ids.get_mut(&peer) {
826+ id_set.extend(list.iter().map(|m| m.id.clone()));
827+ }
818828 // Merge: a poll that beat this lazy load may have already
819829 // inserted messages for this peer. Overwriting with the
820830 // on-disk list (which the GET read before the poll wrote)
@@ -958,6 +968,9 @@ impl App for Skrepka {
958968 // would park the outbox against a limit that no longer applies.
959969 model.flush_paused_until = 0;
960970 model.send_retries = 0;
971+ // D2: Reset poll retries too — the new relay's poll failures
972+ // should start fresh, not inherit the old relay's backoff.
973+ model.poll_retries = 0;
961974 let writes = if self.refuse_write(model) {
962975 Command::done()
963976 } else {
@@ -1110,6 +1123,13 @@ impl App for Skrepka {
11101123 model.error = Some("photo too large".into());
11111124 return render();
11121125 }
1126+ // S6: Validate the photo is valid base64 before broadcasting —
1127+ // the recipient's parse_payload rejects invalid base64, so an
1128+ // invalid photo would be silently dropped by every recipient.
1129+ if photo.as_ref().is_some_and(|p| BASE64_STANDARD.decode(p).is_err()) {
1130+ model.error = Some("photo is not valid base64".into());
1131+ return render();
1132+ }
11131133 model.profile = OwnProfile {
11141134 display_name,
11151135 bio,
@@ -1118,8 +1138,8 @@ impl App for Skrepka {
11181138 model.page = Page::Conversations;
11191139 let ts = now_ms();
11201140 let payload = Payload::Profile {
1121− display_name: model.profile.display_name.clone(),
1122− bio: model.profile.bio.clone(),
1141+ display_name: Some(model.profile.display_name.clone()),
1142+ bio: Some(model.profile.bio.clone()),
11231143 photo: model.profile.photo.clone(),
11241144 };
11251145 // Broadcast the new profile to every contact we haven't blocked.
@@ -1554,8 +1574,13 @@ impl App for Skrepka {
15541574 // later, under load that has nothing to do with this one — starts
15551575 // at whatever height the last one climbed to.
15561576 model.send_retries = 0;
1557− for _ in 0..batch.map_or(0, |b| b.count) {
1558− model.outbox.pop_front();
1577+ // D3-app: Don't pop if kv is broken — the persist would be
1578+ // refused, and the items would be lost from memory but still
1579+ // on disk, causing duplicate sends on relaunch.
1580+ if !model.kv_load_failed {
1581+ for _ in 0..batch.map_or(0, |b| b.count) {
1582+ model.outbox.pop_front();
1583+ }
15591584 }
15601585 return self.persist_outbox(model).and(self.flush_next(model));
15611586 }
@@ -1862,8 +1887,8 @@ impl Skrepka {
18621887 // ingest drops profiles from non-contacts, so the profile was lost.)
18631888 if was_new {
18641889 let profile = Payload::Profile {
1865− display_name: model.profile.display_name.clone(),
1866− bio: model.profile.bio.clone(),
1890+ display_name: Some(model.profile.display_name.clone()),
1891+ bio: Some(model.profile.bio.clone()),
18671892 photo: model.profile.photo.clone(),
18681893 };
18691894 model.outbox.push_back(OutboxItem::profile(
@@ -2176,6 +2201,10 @@ impl Skrepka {
21762201 let Some(parsed) = protocol::parse_payload(&dec.plaintext) else {
21772202 continue;
21782203 };
2204+ // D1: Any event that was successfully decrypted AND parsed counts
2205+ // as "stored" for cursor advancement — the client has processed it
2206+ // even if it was a duplicate/stale/stranger that was dropped.
2207+ events_stored += 1;
21792208 let sender = dec.sender_hex;
21802209
21812210 // Mail from ourselves. A relay cannot forge this — the sender identity
@@ -2227,6 +2256,18 @@ impl Skrepka {
22272256 Contact::new(sender.clone(), String::new(), ts),
22282257 );
22292258 contacts_dirty = true;
2259+ // D6-app: Send our profile to the new contact so they
2260+ // see our display name and avatar, not just our @p.
2261+ let profile = Payload::Profile {
2262+ display_name: Some(model.profile.display_name.clone()),
2263+ bio: Some(model.profile.bio.clone()),
2264+ photo: model.profile.photo.clone(),
2265+ };
2266+ model.outbox.push_back(OutboxItem::profile(
2267+ sender.clone(),
2268+ Arc::new(protocol::serialize_payload(&profile, ts)),
2269+ ));
2270+ outbox_dirty = true;
22302271 }
22312272 let convo = model.messages.entry(sender.clone()).or_default();
22322273 // Q18: Use the persistent per-peer dedup set from
@@ -2261,7 +2302,6 @@ impl Skrepka {
22612302 },
22622303 );
22632304 id_set.insert(msg_id.clone());
2264− events_stored += 1;
22652305 // N7: Cap the dedup set so it doesn't grow without
22662306 // bound. The set is rewritten to kv on every message
22672307 // receipt, so an unbounded set makes every receipt
@@ -2313,7 +2353,6 @@ impl Skrepka {
23132353 .or_default()
23142354 .ids
23152355 .extend(ack_ids);
2316− events_stored += 1;
23172356 }
23182357 Payload::Profile {
23192358 display_name,
@@ -2340,12 +2379,19 @@ impl Skrepka {
23402379 // A replayed profile at the same `ts` overwrites with identical
23412380 // content, which is a no-op in practice.
23422381 if ts >= c.last_profile_ts {
2343− c.display_name = display_name;
2344− c.bio = bio;
2345− c.photo = photo;
2382+ // D13: Only update fields that are present (Some).
2383+ // An absent field (None) means "don't change".
2384+ if let Some(name) = display_name {
2385+ c.display_name = name;
2386+ }
2387+ if let Some(b) = bio {
2388+ c.bio = b;
2389+ }
2390+ if let Some(p) = photo {
2391+ c.photo = Some(p);
2392+ }
23462393 c.last_profile_ts = ts.min(now);
23472394 contacts_dirty = true;
2348− events_stored += 1;
23492395 }
23502396 }
23512397 }
@@ -2499,7 +2545,7 @@ mod tests {
24992545 use crate::model::{MAX_OUTBOX_RETRIES, OUTBOX_TTL_MS};
25002546
25012547 fn with_identity() -> Model {
2502− let id = Identity::from_seed(&[3u8; 32]);
2548+ let id = Identity::from_seed(&[3u8; 32]).unwrap();
25032549 Model {
25042550 secret_key: Some(Zeroizing::new(id.secret_key.to_vec())),
25052551 my_pubkey: id.public_key_hex(),
@@ -2508,7 +2554,7 @@ mod tests {
25082554 }
25092555
25102556 fn peer_hex(seed: u8) -> String {
2511− Identity::from_seed(&[seed; 32]).public_key_hex()
2557+ Identity::from_seed(&[seed; 32]).unwrap().public_key_hex()
25122558 }
25132559
25142560 /// `Command::event` *queues* an event; it does not run it. The runtime drains
@@ -2531,7 +2577,7 @@ mod tests {
25312577
25322578 /// A poll page carrying one payload, encrypted from `sender_seed` to `me`.
25332579 fn page_from(sender_seed: u8, me: &Model, payload: &Payload, ts: i64) -> PollResp {
2534− let sender = Identity::from_seed(&[sender_seed; 32]);
2580+ let sender = Identity::from_seed(&[sender_seed; 32]).unwrap();
25352581 let recipient = Identity::from_secret_bytes(me.secret_key.as_ref().unwrap()).unwrap();
25362582 let json = protocol::serialize_payload(payload, ts);
25372583 let blob = crate::crypto::encrypt(
@@ -3158,7 +3204,7 @@ mod tests {
31583204 let parsed = protocol::parse_payload(item.envelope_json.as_bytes()).unwrap();
31593205 match parsed.payload {
31603206 Payload::Profile { display_name, .. } => {
3161− assert_eq!(display_name, "Three", "and it is the latest one");
3207+ assert_eq!(display_name, Some("Three".to_string()), "and it is the latest one");
31623208 }
31633209 _ => panic!("expected a profile"),
31643210 }
@@ -3206,7 +3252,7 @@ mod tests {
32063252 let parsed = protocol::parse_payload(m.outbox[0].envelope_json.as_bytes()).unwrap();
32073253 match parsed.payload {
32083254 Payload::Profile { display_name, .. } => assert_eq!(
3209− display_name, "New",
3255+ display_name, Some("New".to_string()),
32103256 "and the newer profile survives to be sent"
32113257 ),
32123258 _ => panic!("expected a profile"),
@@ -3388,8 +3434,8 @@ mod tests {
33883434 .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
33893435
33903436 let profile = |name: &str| Payload::Profile {
3391− display_name: name.into(),
3392− bio: String::new(),
3437+ display_name: Some(name.to_string()),
3438+ bio: Some(String::new()),
33933439 photo: None,
33943440 };
33953441 let page = page_from(9, &m, &profile("Far"), i64::MAX);
@@ -3486,8 +3532,11 @@ mod tests {
34863532 let (m, peer) = ingest_one_text_from_an_unblocked_peer();
34873533 assert_eq!(m.messages[&peer].len(), 1);
34883534 assert_eq!(m.messages[&peer][0].body, "hi");
3489− assert_eq!(m.outbox.len(), 1, "one delivery.ack is queued");
3535+ // D6-app: A profile broadcast is queued alongside the delivery.ack
3536+ // so the new contact sees our display name and avatar.
3537+ assert_eq!(m.outbox.len(), 2, "one delivery.ack + one profile queued");
34903538 assert_eq!(m.outbox[0].recipient, peer);
3539+ assert_eq!(m.outbox[1].recipient, peer);
34913540 }
34923541
34933542 /// A peer sending `ts = i64::MAX` used to pin itself to the top of the
@@ -4193,7 +4242,7 @@ mod tests {
41934242 );
41944243
41954244 // The recovery is a relaunch, which re-runs the loads and clears the latch.
4196− let sk = Identity::from_seed(&[3u8; 32]).secret_key.to_vec();
4245+ let sk = Identity::from_seed(&[3u8; 32]).unwrap().secret_key.to_vec();
41974246 let _ = app.update(Event::IdentityLoaded(sk), &mut m);
41984247 assert!(
41994248 !m.kv_load_failed,
@@ -4300,8 +4349,8 @@ mod tests {
43004349 );
43014350
43024351 let profile = Payload::Profile {
4303− display_name: "Mallory".into(),
4304− bio: "spam".into(),
4352+ display_name: Some("Mallory".into()),
4353+ bio: Some("spam".into()),
43054354 photo: Some("aGk=".into()),
43064355 };
43074356 let page = page_from(9, &m, &profile, now_ms());
@@ -4382,8 +4431,8 @@ mod tests {
43824431 let peer = peer_hex(9);
43834432
43844433 let profile = Payload::Profile {
4385− display_name: "Mallory".into(),
4386− bio: String::new(),
4434+ display_name: Some("Mallory".into()),
4435+ bio: Some(String::new()),
43874436 photo: Some("aGk=".into()),
43884437 };
43894438 let page = page_from(9, &m, &profile, now_ms());
@@ -4595,7 +4644,7 @@ mod tests {
45954644
45964645 // One page holding more messages from one sender than fits in a single ack.
45974646 let count = protocol::MAX_ACK_IDS + 5;
4598− let sender = Identity::from_seed(&[9u8; 32]);
4647+ let sender = Identity::from_seed(&[9u8; 32]).unwrap();
45994648 let me = Identity::from_secret_bytes(m.secret_key.as_ref().unwrap()).unwrap();
46004649 let events = (0..count)
46014650 .map(|i| {
core/src/crypto.rs+82−60
@@ -90,7 +90,10 @@ fn padded_len(actual: usize) -> usize {
9090 // Above 65536: round to the next multiple of 65536.
9191 let remainder = actual % 65536;
9292 if remainder == 0 {
93− actual
93+ // D6: When actual is an exact multiple of 65536 above 65536,
94+ // returning `actual` would give zero padding, revealing the exact
95+ // compressed size. Always add at least one bucket of padding.
96+ actual + 65536
9497 } else {
9598 actual + 65536 - remainder
9699 }
@@ -194,18 +197,18 @@ impl Identity {
194197 return Err(CryptoError::BadKeyLength);
195198 }
196199
197− Ok(Identity::with_x25519(secret_key))
200+ Identity::with_x25519(secret_key)
198201 }
199202
200203 /// Generate a fresh identity from a CSPRNG.
201− pub fn generate(rng: &mut (impl RngCore + CryptoRng)) -> Self {
204+ pub fn generate(rng: &mut (impl RngCore + CryptoRng)) -> Result<Self, CryptoError> {
202205 let mut seed = Zeroizing::new([0u8; 32]);
203206 rng.fill_bytes(&mut *seed);
204207 Self::from_seed(&seed)
205208 }
206209
207210 /// Build the 64-byte secret key (`seed || pub`) from a 32-byte seed.
208− pub fn from_seed(seed: &[u8; 32]) -> Self {
211+ pub fn from_seed(seed: &[u8; 32]) -> Result<Self, CryptoError> {
209212 let signing = SigningKey::from_bytes(seed);
210213 let public = signing.verifying_key();
211214 let mut secret_key = [0u8; ED25519_SECRET_LEN];
@@ -238,20 +241,24 @@ impl Identity {
238241 /// `InvalidEphemeralKey` and the user would be unable to receive messages
239242 /// with no indication of why. Catching it at construction time gives a
240243 /// clear error instead.
241− fn with_x25519(secret_key: [u8; ED25519_SECRET_LEN]) -> Self {
244+ fn with_x25519(secret_key: [u8; ED25519_SECRET_LEN]) -> Result<Self, CryptoError> {
242245 let x25519_priv = *ed25519_sk_to_x25519(&secret_key);
243246 let x25519_pub = x25519_dalek::x25519(x25519_priv, x25519_dalek::X25519_BASEPOINT_BYTES);
247+ // D33: Return an error instead of just printing a warning when the
248+ // derived X25519 public key is all-zero (low-order point). The
249+ // probability of generating such a key is negligible, but if it ever
250+ // happened every encryption to this identity would fail with
251+ // `InvalidEphemeralKey` and the user would be unable to receive
252+ // messages with no indication of why. Catching it at construction time
253+ // gives a clear error instead.
244254 if x25519_pub.iter().all(|b| *b == 0) {
245− eprintln!(
246− "warning: derived X25519 public key is all-zero (low-order point) — \
247− this identity cannot receive messages"
248− );
255+ return Err(CryptoError::InvalidPublicKey);
249256 }
250− Identity {
257+ Ok(Identity {
251258 secret_key,
252259 x25519_priv,
253260 x25519_pub,
254− }
261+ })
255262 }
256263
257264 /// Cached X25519 private key derived from the Ed25519 seed (libsodium
@@ -287,7 +294,14 @@ fn ed25519_pk_to_x25519(ed_pub: &[u8; 32]) -> Result<[u8; 32], CryptoError> {
287294 let point = compressed
288295 .decompress()
289296 .ok_or(CryptoError::InvalidPublicKey)?;
290− Ok(point.to_montgomery().to_bytes())
297+ let montgomery = point.to_montgomery().to_bytes();
298+ // D3: A 32-byte all-zero input is the Ed25519 identity point, which
299+ // produces an all-zero Montgomery u-coordinate — a low-order point.
300+ // Reject it explicitly, separate from the x25519 shared-secret check.
301+ if montgomery.iter().all(|&b| b == 0) {
302+ return Err(CryptoError::InvalidPublicKey);
303+ }
304+ Ok(montgomery)
291305}
292306
293307/// libsodium `crypto_sign_ed25519_sk_to_curve25519`: clamp(SHA-512(seed)[..32]).
@@ -318,7 +332,7 @@ fn x25519(scalar: &[u8; 32], point: &[u8; 32]) -> Result<Zeroizing<[u8; 32]>, Cr
318332 Ok(shared)
319333}
320334
321−/// HKDF-SHA256(ikm=raw_secret, salt=eph_pub || recip_x_pub, info="skrepka-v1", 32).
335+/// HKDF-SHA256(ikm=raw_secret, salt=eph_pub || recip_x_pub, info="skrepka-v2", 32).
322336fn derive_key(
323337 raw_secret: &[u8; 32],
324338 eph_pub: &[u8; 32],
@@ -355,7 +369,7 @@ fn decompress(data: &[u8]) -> Result<Vec<u8>, CryptoError> {
355369 decoder
356370 .window_log_max(21)
357371 .map_err(|_| CryptoError::Decompress)?;
358− let mut out = Vec::with_capacity(MAX_PLAINTEXT_LEN + 1);
372+ let mut out = Vec::with_capacity(4096);
359373 (&mut decoder)
360374 .take(MAX_PLAINTEXT_LEN as u64 + 1)
361375 .read_to_end(&mut out)
@@ -374,6 +388,12 @@ pub fn encrypt(
374388 recipient_ed_pub: &[u8; 32],
375389 plaintext: &[u8],
376390) -> Result<Vec<u8>, CryptoError> {
391+ // D28: Reject a plaintext over the cap before compressing and encrypting
392+ // — the recipient will reject it on decompression anyway, so the work
393+ // is wasted. CryptoError::Encrypt signals an encryption-side failure.
394+ if plaintext.len() > MAX_PLAINTEXT_LEN {
395+ return Err(CryptoError::Encrypt);
396+ }
377397 let sender_pub = sender.public_key();
378398
379399 // Ephemeral raw X25519 keypair (libsodium crypto_box_keypair).
@@ -558,12 +578,12 @@ mod tests {
558578
559579 #[test]
560580 fn identity_pub_is_last_32_bytes_and_consistent() {
561− let id = Identity::generate(&mut rng(1));
581+ let id = Identity::generate(&mut rng(1)).unwrap();
562582 assert_eq!(&id.secret_key[32..], &id.public_key());
563583 // Re-deriving from the seed yields the same public key.
564584 let mut seed = [0u8; 32];
565585 seed.copy_from_slice(&id.secret_key[..32]);
566− let id2 = Identity::from_seed(&seed);
586+ let id2 = Identity::from_seed(&seed).unwrap();
567587 assert_eq!(id.public_key(), id2.public_key());
568588 }
569589
@@ -571,7 +591,7 @@ mod tests {
571591 /// under one identity while claiming another.
572592 #[test]
573593 fn from_secret_bytes_rejects_a_seed_pubkey_mismatch() {
574− let id = Identity::from_seed(&[3u8; 32]);
594+ let id = Identity::from_seed(&[3u8; 32]).unwrap();
575595 assert!(Identity::from_secret_bytes(&id.secret_key).is_ok());
576596
577597 let mut tampered = id.secret_key;
@@ -584,8 +604,8 @@ mod tests {
584604
585605 #[test]
586606 fn round_trip_recovers_plaintext_and_sender() {
587− let alice = Identity::generate(&mut rng(10));
588− let bob = Identity::generate(&mut rng(20));
607+ let alice = Identity::generate(&mut rng(10)).unwrap();
608+ let bob = Identity::generate(&mut rng(20)).unwrap();
589609 let msg = br#"{"type":"text","id":"abc","body":"hello bob","ts":1700000000000}"#;
590610
591611 let blob = encrypt(&mut rng(99), &alice, &bob.public_key(), msg).unwrap();
@@ -598,17 +618,17 @@ mod tests {
598618
599619 #[test]
600620 fn wrong_recipient_cannot_decrypt() {
601− let alice = Identity::generate(&mut rng(1));
602− let bob = Identity::generate(&mut rng(2));
603− let eve = Identity::generate(&mut rng(3));
621+ let alice = Identity::generate(&mut rng(1)).unwrap();
622+ let bob = Identity::generate(&mut rng(2)).unwrap();
623+ let eve = Identity::generate(&mut rng(3)).unwrap();
604624 let blob = encrypt(&mut rng(4), &alice, &bob.public_key(), b"secret").unwrap();
605625 assert_eq!(decrypt(&eve, &blob).unwrap_err(), CryptoError::Decrypt);
606626 }
607627
608628 #[test]
609629 fn tampered_ciphertext_is_rejected() {
610− let alice = Identity::generate(&mut rng(1));
611− let bob = Identity::generate(&mut rng(2));
630+ let alice = Identity::generate(&mut rng(1)).unwrap();
631+ let bob = Identity::generate(&mut rng(2)).unwrap();
612632 let mut blob = encrypt(&mut rng(4), &alice, &bob.public_key(), b"secret").unwrap();
613633 let last = blob.len() - 1;
614634 blob[last] ^= 0xff;
@@ -617,8 +637,8 @@ mod tests {
617637
618638 #[test]
619639 fn blob_layout_offsets() {
620− let alice = Identity::generate(&mut rng(1));
621− let bob = Identity::generate(&mut rng(2));
640+ let alice = Identity::generate(&mut rng(1)).unwrap();
641+ let bob = Identity::generate(&mut rng(2)).unwrap();
622642 let blob = encrypt(&mut rng(7), &alice, &bob.public_key(), b"x").unwrap();
623643 // Version byte at offset 0.
624644 assert_eq!(blob[0], WIRE_VERSION);
@@ -635,7 +655,7 @@ mod tests {
635655
636656 #[test]
637657 fn too_short_blob_errors() {
638− let bob = Identity::generate(&mut rng(2));
658+ let bob = Identity::generate(&mut rng(2)).unwrap();
639659 assert_eq!(
640660 decrypt(&bob, &[0u8; 10]).unwrap_err(),
641661 CryptoError::BlobTooShort
@@ -646,7 +666,7 @@ mod tests {
646666 /// scalar mult, an AEAD pass, or a decompression.
647667 #[test]
648668 fn too_long_blob_errors() {
649− let bob = Identity::generate(&mut rng(2));
669+ let bob = Identity::generate(&mut rng(2)).unwrap();
650670 let huge = vec![0u8; MAX_BLOB_LEN + 1];
651671 assert_eq!(decrypt(&bob, &huge).unwrap_err(), CryptoError::BlobTooLong);
652672 }
@@ -663,25 +683,27 @@ mod tests {
663683 );
664684 assert_eq!(decompress(&bomb).unwrap_err(), CryptoError::Decompress);
665685
666− // ...and the same bomb wrapped in a well-formed, correctly signed blob
667− // (i.e. from a real peer) dies at the same wall.
668− let alice = Identity::generate(&mut rng(1));
669− let bob = Identity::generate(&mut rng(2));
670− let blob = encrypt(
671− &mut rng(3),
672− &alice,
673− &bob.public_key(),
674− &vec![0u8; MAX_PLAINTEXT_LEN + 1],
675− )
676− .unwrap();
677− assert_eq!(decrypt(&bob, &blob).unwrap_err(), CryptoError::Decompress);
686+ // ...and the same bomb sent through encrypt is rejected before
687+ // compression/encryption work is done (D28).
688+ let alice = Identity::generate(&mut rng(1)).unwrap();
689+ let bob = Identity::generate(&mut rng(2)).unwrap();
690+ assert_eq!(
691+ encrypt(
692+ &mut rng(3),
693+ &alice,
694+ &bob.public_key(),
695+ &vec![0u8; MAX_PLAINTEXT_LEN + 1],
696+ )
697+ .unwrap_err(),
698+ CryptoError::Encrypt,
699+ );
678700 }
679701
680702 /// A plaintext right at the cap still round-trips: the bound is inclusive.
681703 #[test]
682704 fn a_plaintext_at_the_cap_still_round_trips() {
683− let alice = Identity::generate(&mut rng(1));
684− let bob = Identity::generate(&mut rng(2));
705+ let alice = Identity::generate(&mut rng(1)).unwrap();
706+ let bob = Identity::generate(&mut rng(2)).unwrap();
685707 let msg = vec![7u8; MAX_PLAINTEXT_LEN];
686708 let blob = encrypt(&mut rng(3), &alice, &bob.public_key(), &msg).unwrap();
687709 assert_eq!(decrypt(&bob, &blob).unwrap().plaintext.as_slice(), msg.as_slice());
@@ -691,7 +713,7 @@ mod tests {
691713 /// secret, so the sender's "encryption" is forgeable. Reject it.
692714 #[test]
693715 fn a_low_order_ephemeral_point_is_rejected() {
694− let bob = Identity::generate(&mut rng(2));
716+ let bob = Identity::generate(&mut rng(2)).unwrap();
695717 // eph_pub = 0 is the canonical low-order point; x25519 with it yields
696718 // an all-zero shared secret for any scalar.
697719 let mut blob = vec![0u8; MIN_BLOB_LEN + 1];
@@ -706,8 +728,8 @@ mod tests {
706728 /// A short message must be padded so the blob size lands on a bucket boundary.
707729 #[test]
708730 fn blob_is_padded_to_bucket_size() {
709− let alice = Identity::generate(&mut rng(1));
710− let bob = Identity::generate(&mut rng(2));
731+ let alice = Identity::generate(&mut rng(1)).unwrap();
732+ let bob = Identity::generate(&mut rng(2)).unwrap();
711733
712734 // Short message → should round up to the smallest bucket (256).
713735 let blob_small = encrypt(&mut rng(7), &alice, &bob.public_key(), b"x").unwrap();
@@ -732,8 +754,8 @@ mod tests {
732754 /// same blob size (same bucket) but different blob contents (random padding).
733755 #[test]
734756 fn padding_is_random_per_message() {
735− let alice = Identity::generate(&mut rng(1));
736− let bob = Identity::generate(&mut rng(2));
757+ let alice = Identity::generate(&mut rng(1)).unwrap();
758+ let bob = Identity::generate(&mut rng(2)).unwrap();
737759 let msg = b"hello bob, this is a test message";
738760
739761 let blob1 = encrypt(&mut rng(100), &alice, &bob.public_key(), msg).unwrap();
@@ -754,8 +776,8 @@ mod tests {
754776 /// never sees trailing padding bytes.
755777 #[test]
756778 fn compressed_len_field_is_parsed_correctly() {
757− let alice = Identity::generate(&mut rng(1));
758− let bob = Identity::generate(&mut rng(2));
779+ let alice = Identity::generate(&mut rng(1)).unwrap();
780+ let bob = Identity::generate(&mut rng(2)).unwrap();
759781
760782 // Normal round-trip.
761783 let msg = b"round trip test with some content";
@@ -777,7 +799,7 @@ mod tests {
777799 /// error says so rather than blaming decryption.
778800 #[test]
779801 fn encrypting_to_an_off_curve_key_reports_an_invalid_public_key() {
780− let alice = Identity::generate(&mut rng(1));
802+ let alice = Identity::generate(&mut rng(1)).unwrap();
781803 let not_a_point = [0xabu8; 32];
782804 assert_eq!(
783805 encrypt(&mut rng(2), &alice, &not_a_point, b"x").unwrap_err(),
@@ -787,7 +809,7 @@ mod tests {
787809
788810 #[test]
789811 fn sk_to_x25519_clamps() {
790− let id = Identity::generate(&mut rng(5));
812+ let id = Identity::generate(&mut rng(5)).unwrap();
791813 let scalar = ed25519_sk_to_x25519(&id.secret_key);
792814 assert_eq!(scalar[0] & 7, 0);
793815 assert_eq!(scalar[31] & 64, 64);
@@ -796,7 +818,7 @@ mod tests {
796818
797819 #[test]
798820 fn challenge_signature_is_deterministic_and_host_bound() {
799− let id = Identity::from_seed(&[7u8; 32]);
821+ let id = Identity::from_seed(&[7u8; 32]).unwrap();
800822 let a = id.sign_challenge("relay.example.com", "deadbeef").unwrap();
801823 let b = id.sign_challenge("relay.example.com", "deadbeef").unwrap();
802824 let c = id.sign_challenge("other.example.com", "deadbeef").unwrap();
@@ -808,7 +830,7 @@ mod tests {
808830 /// We sign whatever a not-yet-trusted relay hands us, so its size is bounded.
809831 #[test]
810832 fn an_oversized_challenge_is_not_signed() {
811− let id = Identity::from_seed(&[7u8; 32]);
833+ let id = Identity::from_seed(&[7u8; 32]).unwrap();
812834 let huge = "a".repeat(MAX_CHALLENGE_LEN + 1);
813835 assert_eq!(
814836 id.sign_challenge("relay.example.com", &huge).unwrap_err(),
@@ -828,9 +850,9 @@ mod tests {
828850 /// explicit rather than implicit in the KDF.
829851 #[test]
830852 fn wrong_recipient_cannot_decrypt_aead_fails() {
831− let alice = Identity::generate(&mut rng(1));
832− let bob = Identity::generate(&mut rng(2));
833− let charlie = Identity::generate(&mut rng(3));
853+ let alice = Identity::generate(&mut rng(1)).unwrap();
854+ let bob = Identity::generate(&mut rng(2)).unwrap();
855+ let charlie = Identity::generate(&mut rng(3)).unwrap();
834856
835857 let blob = encrypt(&mut rng(4), &alice, &bob.public_key(), b"hi bob").unwrap();
836858 assert_eq!(
@@ -846,8 +868,8 @@ mod tests {
846868 /// check fires first.
847869 #[test]
848870 fn tampered_version_byte_is_rejected() {
849− let alice = Identity::generate(&mut rng(1));
850− let bob = Identity::generate(&mut rng(2));
871+ let alice = Identity::generate(&mut rng(1)).unwrap();
872+ let bob = Identity::generate(&mut rng(2)).unwrap();
851873 let mut blob = encrypt(&mut rng(3), &alice, &bob.public_key(), b"hi").unwrap();
852874 blob[0] ^= 0xff;
853875 assert_eq!(
@@ -863,8 +885,8 @@ mod tests {
863885 /// catches this.
864886 #[test]
865887 fn tampered_ephemeral_pubkey_is_rejected() {
866− let alice = Identity::generate(&mut rng(1));
867− let bob = Identity::generate(&mut rng(2));
888+ let alice = Identity::generate(&mut rng(1)).unwrap();
889+ let bob = Identity::generate(&mut rng(2)).unwrap();
868890 let mut blob = encrypt(&mut rng(3), &alice, &bob.public_key(), b"hi").unwrap();
869891 blob[5] ^= 0x01;
870892 assert!(decrypt(&bob, &blob).is_err());
core/src/ffi.rs+5
@@ -39,6 +39,11 @@ impl Default for CoreFFI {
3939/// `AssertUnwindSafe` is sound here because a poisoned `Bridge` is never read
4040/// again for correctness: the worst case is a dropped effect batch, and the next
4141/// event starts a fresh `update`.
42+///
43+/// NOTE: A panic mid-`update` leaves the `Model` in a partially-mutated state.
44+/// The next `update` call operates on this potentially-corrupted state. A
45+/// proper fix requires snapshotting the Model before `update` and restoring on
46+/// panic, or marking the Bridge as poisoned. This is an accepted limitation.
4247fn guard(f: impl FnOnce() -> Vec<u8>) -> Vec<u8> {
4348 catch_unwind(AssertUnwindSafe(f)).unwrap_or_default()
4449}
core/src/phonemic.rs+8−3
@@ -96,6 +96,11 @@ fn suffix_index(s: &str) -> Option<u8> {
9696/// Keys are always 32 bytes, so no caller is affected — but a display helper
9797/// must not quietly invent a byte.
9898pub fn to_ob(bytes: &[u8]) -> Option<String> {
99+ // P1: Empty input produces an empty string, which is a valid but
100+ // meaningless @p — return None instead so the round-trip is symmetric.
101+ if bytes.is_empty() {
102+ return None;
103+ }
99104 if !bytes.len().is_multiple_of(2) {
100105 return None;
101106 }
@@ -151,7 +156,7 @@ pub fn try_parse_pubkey(input: &str) -> Option<String> {
151156 if let Some(hex) = from_ob(trimmed).as_deref().and_then(valid_pubkey_hex) {
152157 return Some(hex);
153158 }
154− if let Ok(bytes) = hex::decode(trimmed.to_lowercase()) {
159+ if let Ok(bytes) = hex::decode(trimmed) {
155160 return valid_pubkey_hex(&bytes);
156161 }
157162 None
@@ -207,13 +212,13 @@ mod tests {
207212 fn odd_length_input_has_no_spelling() {
208213 assert_eq!(to_ob(&[7u8]), None);
209214 assert_eq!(to_ob(&[7u8, 0]), Some("hidzod".to_string()));
210− assert_eq!(to_ob(&[]), Some(String::new()));
215+ assert_eq!(to_ob(&[]), None);
211216 }
212217
213218 /// A real Ed25519 public key. `try_parse_pubkey` now checks that a key is a
214219 /// curve point, so tests can't use an arbitrary byte pattern.
215220 fn real_key() -> [u8; 32] {
216− crate::crypto::Identity::from_seed(&[7u8; 32]).public_key()
221+ crate::crypto::Identity::from_seed(&[7u8; 32]).unwrap().public_key()
217222 }
218223
219224 /// 32 bytes that decode fine but are *not* on the curve — the case that used
core/src/protocol.rs+64−31
@@ -35,8 +35,8 @@ pub enum Payload {
3535 ack_ids: Vec<String>,
3636 },
3737 Profile {
38− display_name: String,
39− bio: String,
38+ display_name: Option<String>,
39+ bio: Option<String>,
4040 photo: Option<String>,
4141 },
4242}
@@ -77,10 +77,16 @@ pub fn serialize_payload(payload: &Payload, ts: i64) -> String {
7777 } => {
7878 let mut obj = serde_json::json!({
7979 "type": "profile",
80− "display_name": display_name,
81− "bio": bio,
8280 "ts": ts,
8381 });
82+ // D13: Only include the field when present — absent means
83+ // "don't change", while present (even empty) means "set to this".
84+ if let Some(name) = display_name {
85+ obj["display_name"] = serde_json::Value::String(name.clone());
86+ }
87+ if let Some(b) = bio {
88+ obj["bio"] = serde_json::Value::String(b.clone());
89+ }
8490 // Omit `photo` entirely when there isn't one, rather than sending an
8591 // explicit null: "no photo" and "field absent" mean the same thing to
8692 // a reader, and the null costs bytes in every broadcast.
@@ -93,6 +99,38 @@ pub fn serialize_payload(payload: &Payload, ts: i64) -> String {
9399 value.to_string()
94100}
95101
102+/// D14: Filter out control characters, bidirectional control characters, and
103+/// zero-width characters from a received string. Applied on the receiving side
104+/// (parse_payload) so the sender's input is preserved as-is on send, but the
105+/// recipient is protected from invisible character tricks.
106+///
107+/// Filters:
108+/// - Control characters U+0000–U+001F (except U+0009 tab, U+000A newline,
109+/// U+000D carriage return)
110+/// - Bidirectional control characters (U+200E LRM, U+200F RLM, U+202A–U+202E,
111+/// U+2066–U+2069)
112+/// - Zero-width characters (U+200B, U+200C, U+200D, U+FEFF)
113+fn sanitize_text(s: &str) -> String {
114+ s.chars()
115+ .filter(|c| {
116+ let cp = *c as u32;
117+ // Control characters U+0000–U+001F, except tab/newline/CR
118+ if cp <= 0x001F && cp != 0x0009 && cp != 0x000A && cp != 0x000D {
119+ return false;
120+ }
121+ // Bidirectional control characters
122+ if matches!(cp, 0x200E | 0x200F | 0x202A..=0x202E | 0x2066..=0x2069) {
123+ return false;
124+ }
125+ // Zero-width characters
126+ if matches!(cp, 0x200B | 0x200C | 0x200D | 0xFEFF) {
127+ return false;
128+ }
129+ true
130+ })
131+ .collect()
132+}
133+
96134/// Parse a plaintext payload. Returns `None` for unknown/malformed types so the
97135/// caller silently ignores them (forward compatibility, PROTOCOL.md §4).
98136pub fn parse_payload(bytes: &[u8]) -> Option<ParsedPayload> {
@@ -102,7 +140,7 @@ pub fn parse_payload(bytes: &[u8]) -> Option<ParsedPayload> {
102140 let payload = match ty {
103141 "text" => {
104142 let id = v.get("id")?.as_str()?.to_string();
105− let body = v.get("body")?.as_str()?.to_string();
143+ let body = sanitize_text(v.get("body")?.as_str()?);
106144 if id.len() > MAX_ID_LEN || body.len() > MAX_BODY_LEN {
107145 return None;
108146 }
@@ -127,22 +165,17 @@ pub fn parse_payload(bytes: &[u8]) -> Option<ParsedPayload> {
127165 Payload::DeliveryAck { ack_ids }
128166 }
129167 "profile" => {
130− let display_name = v
131− .get("display_name")
132− .and_then(|x| x.as_str())
133− .unwrap_or("")
134− .to_string();
135− let bio = v
136− .get("bio")
137− .and_then(|x| x.as_str())
138− .unwrap_or("")
139− .to_string();
168+ // D13: Use Option<String> — None when the field is absent (don't
169+ // change the recipient's cached value), Some(value) when present
170+ // (even if empty, meaning "set to this value").
171+ let display_name = v.get("display_name").and_then(|x| x.as_str()).map(sanitize_text);
172+ let bio = v.get("bio").and_then(|x| x.as_str()).map(sanitize_text);
140173 let photo = v
141174 .get("photo")
142175 .and_then(|x| x.as_str())
143176 .map(str::to_string);
144− if display_name.chars().count() > MAX_DISPLAY_NAME_LEN
145− || bio.chars().count() > MAX_BIO_LEN
177+ if display_name.as_ref().is_some_and(|d| d.chars().count() > MAX_DISPLAY_NAME_LEN)
178+ || bio.as_ref().is_some_and(|b| b.chars().count() > MAX_BIO_LEN)
146179 || photo.as_ref().is_some_and(|p| p.len() > MAX_PHOTO_LEN)
147180 || photo.as_ref().is_some_and(|p| {
148181 // Reject a photo that isn't valid base64 — a peer can put
@@ -195,14 +228,14 @@ mod tests {
195228 #[test]
196229 fn profile_round_trip_with_and_without_photo() {
197230 let with = Payload::Profile {
198− display_name: "Alice".into(),
199− bio: "writing".into(),
231+ display_name: Some("Alice".into()),
232+ bio: Some("writing".into()),
200233 photo: Some("aGVsbG8=".into()),
201234 };
202235 assert_eq!(reparse(&with, 1).payload, with);
203236 let without = Payload::Profile {
204− display_name: "Bob".into(),
205− bio: "".into(),
237+ display_name: Some("Bob".into()),
238+ bio: Some("".into()),
206239 photo: None,
207240 };
208241 assert_eq!(reparse(&without, 1).payload, without);
@@ -242,8 +275,8 @@ mod tests {
242275
243276 let long_bio = serialize_payload(
244277 &Payload::Profile {
245− display_name: "A".into(),
246− bio: "b".repeat(MAX_BIO_LEN + 1),
278+ display_name: Some("A".into()),
279+ bio: Some("b".repeat(MAX_BIO_LEN + 1)),
247280 photo: None,
248281 },
249282 1,
@@ -252,8 +285,8 @@ mod tests {
252285
253286 let long_name = serialize_payload(
254287 &Payload::Profile {
255− display_name: "n".repeat(MAX_DISPLAY_NAME_LEN + 1),
256− bio: String::new(),
288+ display_name: Some("n".repeat(MAX_DISPLAY_NAME_LEN + 1)),
289+ bio: Some(String::new()),
257290 photo: None,
258291 },
259292 1,
@@ -262,8 +295,8 @@ mod tests {
262295
263296 let big_photo = serialize_payload(
264297 &Payload::Profile {
265− display_name: String::new(),
266− bio: String::new(),
298+ display_name: Some(String::new()),
299+ bio: Some(String::new()),
267300 photo: Some("p".repeat(MAX_PHOTO_LEN + 1)),
268301 },
269302 1,
@@ -297,8 +330,8 @@ mod tests {
297330 fn a_photoless_profile_omits_the_field() {
298331 let json = serialize_payload(
299332 &Payload::Profile {
300− display_name: "A".into(),
301− bio: "B".into(),
333+ display_name: Some("A".into()),
334+ bio: Some("B".into()),
302335 photo: None,
303336 },
304337 1,
@@ -309,8 +342,8 @@ mod tests {
309342 #[test]
310343 fn snake_case_field_names_on_wire() {
311344 let p = Payload::Profile {
312− display_name: "A".into(),
313− bio: "B".into(),
345+ display_name: Some("A".into()),
346+ bio: Some("B".into()),
314347 photo: None,
315348 };
316349 let json = serialize_payload(&p, 1);
server.knot+37−8
@@ -102,6 +102,14 @@ maxPollEvents = 50
102102-- Per-peer ceiling on outstanding forward attempts.
103103maxForwardsPerServer : Int
104104maxForwardsPerServer = 2000
105+-- S9: Cap on distinct remote servers a single message is immediately forwarded
106+-- to at send time. Separate from `maxForwardsPerServer` (which bounds outstanding
107+-- forwards *per peer*), this bounds the fan-out breadth. A recipient is unlikely
108+-- to be visible on more than 100 servers; the previous reuse of
109+-- `maxForwardsPerServer` (2000) for this purpose was an overload that could
110+-- spawn up to 2000 immediate HTTP forwards per message.
111+maxRemoteServersPerMessage : Int
112+maxRemoteServersPerMessage = 100
105113-- Global ceiling on the `*forwards` outbox. `maxForwardsPerServer` bounds any one
106114-- peer, but a recipient gossiped as present on many servers fans one blob out to
107115-- all of them, so the total is bounded only by (peers x per-peer cap). This caps
@@ -959,13 +967,23 @@ appendMessage = \toKey encryptedBlob t -> do
959967 -- for this recipient so the post-append count lands exactly on the ceiling.
960968 -- `- 1` reserves the slot the new row is about to take.
961969 --
962− -- Eviction keys on `encryptedBlob` rather than the row itself because
963− -- (toKey, encryptedBlob) is unique by this function's own dedup invariant
964− -- (the `when` guard above), so a blob identifies at most one row per key.
970+ -- S4: Instead of building an `evicted` list and checking membership (which
971+ -- is O(|messages| × |evicted|)), compute the eviction threshold — the
972+ -- receivedAt of the (maxMessagesPerRecipient - 1)th oldest message for this
973+ -- recipient — and keep only messages newer than it. This is O(|messages|)
974+ -- with no inner scan.
965975 let forRecipient = filter (\m -> m.toKey == toKey) msgs
966976 let excess = max 0 (count forRecipient - (maxMessagesPerRecipient - 1))
967− let evicted = map (\m -> m.encryptedBlob) (take excess (sortBy (\m -> m.receivedAt) forRecipient))
968− let kept = filter (\m -> not (m.toKey == toKey && elem m.encryptedBlob evicted)) msgs
977+ let sortedRecipient = sortBy (\m -> m.receivedAt) forRecipient
978+ -- The threshold: the receivedAt of the last row we want to evict.
979+ -- Rows with receivedAt <= threshold are evicted; rows > threshold survive.
980+ let evictionThreshold = if excess > 0
981+ then case take excess sortedRecipient of
982+ [] -> 0
983+ xs -> case last xs of
984+ {receivedAt: r} -> r
985+ else 0
986+ let kept = filter (\m -> not (m.toKey == toKey && m.receivedAt <= evictionThreshold && excess > 0)) msgs
969987 -- Global cap (S6): after the per-recipient eviction, bound the total mailbox
970988 -- across all recipients by dropping the globally-oldest rows. Only runs on
971989 -- overflow, so the O(n log n) sort is off the hot path. Dropped rows may
@@ -1028,6 +1046,12 @@ pruneExpired = \t -> atomic do
10281046 -- Prune forwards whose message was removed by the TTL sweep above.
10291047 -- ForwardEntry carries no timestamp, so match against the surviving
10301048 -- messages (the post-sweep list, not the pre-sweep snapshot).
1049+ --
1050+ -- S5: This is O(|forwards| × |surviving|) — a quadratic scan. In practice
1051+ -- |forwards| is usually far below maxForwardsTotal (50000) and |surviving|
1052+ -- is bounded by maxMessagesTotal (1000000), so the cost is modest. A real
1053+ -- fix needs a set/index keyed on (toKey, encryptedBlob), which the Knot
1054+ -- runtime does not expose today. Documented as an accepted design tradeoff.
10311055 fw <- *forwards
10321056 *forwards = filter (\f -> any (\m -> m.toKey == f.toKey && m.encryptedBlob == f.encryptedBlob) surviving) fw
10331057 ff <- *forwardFailures
@@ -1398,11 +1422,16 @@ handlePoll = \cursor authorization clientIp -> do
13981422 -- so peers don't time us out mid-session.
13991423 let stale = not any (\p -> p.pubkey == pubkey && isLocalServer p.server && p.expiresAt - t >= sessionExpiry / 2) pres
14001424 refreshPeers <- if stale then refreshLocalPresence pubkey (t + sessionExpiry) else yield []
1401− yield (Just {value: {pubkey, clampedCursor, refreshPeers}}))
1425+ -- S3: Track whether we actually refreshed so the gossip line below
1426+ -- only fires on a refresh, not on every poll. Without this, every
1427+ -- poll gossips to all seed peers via gossipTargets [], creating
1428+ -- O(clients × seeds) federation traffic per poll cycle.
1429+ yield (Just {value: {pubkey, clampedCursor, refreshPeers, didRefresh: stale}}))
14021430 case setup of
14031431 Nothing {} -> yield (Err {error: {status: 401, message: "unauthorized"}})
14041432 Just {value: s} -> do
1405− when federationEnabled (fork (sendBatchedGossip s.refreshPeers [{eventType: "online", pubkey: Just {value: s.pubkey}}]))
1433+ -- S3: Only gossip when we actually refreshed presence — not on every poll.
1434+ when (federationEnabled && s.didRefresh) (fork (sendBatchedGossip s.refreshPeers [{eventType: "online", pubkey: Just {value: s.pubkey}}]))
14061435 pollResult <- doPoll s t
14071436 yield (Ok {value: pollResult})
14081437
@@ -1521,7 +1550,7 @@ handleSendMessages = \messages authorization clientIp -> do
15211550 let staged = map (\m -> {
15221551 m.to,
15231552 m.encryptedBlob,
1524− remoteServers: take maxForwardsPerServer (collectPeers (filter (\p -> p.pubkey == m.to && p.expiresAt > t) pres) (\p -> p.server))
1553+ remoteServers: take maxRemoteServersPerMessage (collectPeers (filter (\p -> p.pubkey == m.to && p.expiresAt > t) pres) (\p -> p.server))
15251554 }) messages
15261555 forEach staged (\c -> do
15271556 -- Read `*messages` *before* the append: `delivered` below must see the