on ee54351 help sign in
16 files changed+162−1058
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+15−13 apple/SkrepkaApp/Effects.swift+6−19 apple/SkrepkaApp/PhotoPicker.swift+1−7 apple/SkrepkaApp/QRView.swift+2−36 apple/SkrepkaApp/SkrepkaApp.swift+2−4 core/src/app.rs+27−76 core/src/crypto.rs+60−82 core/src/ffi.rs−5 core/src/phonemic.rs+3−8 core/src/protocol.rs+31−64 server.knot+8−37
.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 346-char floor
160+ BLOB=$(printf 'ab%.0s' $(seq 1 200)) # 400 hex chars: over the 336-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`, `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.
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.
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 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.
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.
9999
100100## Code Conventions
101101
AUDIT-DEEP.mddeleted−230
@@ -1,230 +0,0 @@
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.mddeleted−470
@@ -1,470 +0,0 @@
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 0x02)
167+version (1 byte, currently 0x01)
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 0x02 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 0x01 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 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.
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.
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). 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):
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):
382382 ```
383383 POST /auth/verify
384384 {
apple/SkrepkaApp/Core.swift+15−13
@@ -26,17 +26,16 @@ final class Core: ObservableObject {
2626 private let store = KvStore()
2727
2828 private init() {
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()
29+ // swiftlint:disable:next force_try
30+ self.view = try! .bincodeDeserialize(input: [UInt8](core.view()))
3231 bootIdentity()
3332 }
3433
3534 // MARK: - Event/effect loop
3635
3736 func update(_ event: Event) {
38− guard let eventData = try? event.bincodeSerialize() else { return }
39− let effects = [UInt8](core.update(data: Data(eventData)))
37+ // swiftlint:disable:next force_try
38+ let effects = [UInt8](core.update(data: Data(try! event.bincodeSerialize())))
4039 dispatch(effects)
4140 }
4241
@@ -46,7 +45,8 @@ final class Core: ObservableObject {
4645 }
4746
4847 private func dispatch(_ effects: [UInt8]) {
49− guard let requests = try? Requests.bincodeDeserialize(input: effects).value else { return }
48+ // swiftlint:disable:next force_try
49+ let requests = try! Requests.bincodeDeserialize(input: effects).value
5050 for request in requests {
5151 process(request)
5252 }
@@ -56,27 +56,29 @@ final class Core: ObservableObject {
5656 let id = request.id
5757 switch request.effect {
5858 case .render:
59− self.view = (try? ViewModel.bincodeDeserialize(input: [UInt8](core.view()))) ?? self.view
59+ // swiftlint:disable:next force_try
60+ self.view = try! .bincodeDeserialize(input: [UInt8](core.view()))
6061
6162 case .http(let req):
6263 Task { [weak self] in
6364 let result = await Http.perform(req)
64− guard let output = try? result.bincodeSerialize() else { return }
65− self?.resolve(id, output)
65+ // swiftlint:disable:next force_try
66+ self?.resolve(id, try! result.bincodeSerialize())
6667 }
6768
6869 case .keyValue(let op):
6970 // The file I/O runs on the store's own serial queue; hop the result back to
7071 // the main actor, because the core is single-threaded and `.render` publishes.
7172 store.handle(op) { [weak self] result in
72− guard let output = try? result.bincodeSerialize() else { return }
73+ // swiftlint:disable:next force_try
74+ let output = try! result.bincodeSerialize()
7375 Task { @MainActor in self?.resolve(id, output) }
7476 }
7577
7678 case .time(let req):
7779 Time.handle(req) { [weak self] response in
78− guard let output = try? response.bincodeSerialize() else { return }
79− self?.resolve(id, output)
80+ // swiftlint:disable:next force_try
81+ self?.resolve(id, try! response.bincodeSerialize())
8082 }
8183 }
8284 }
@@ -95,4 +97,4 @@ final class Core: ObservableObject {
9597 identityError = error.message
9698 }
9799 }
98−}
100+}
apple/SkrepkaApp/Effects.swift+6−19
@@ -19,11 +19,10 @@ 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. 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.
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.
2726///
2827/// **Response size limiting.** As body chunks arrive we accumulate the byte count per task
2928/// and cancel the task the moment it crosses `maxResponseBytes`, so a multi-gigabyte body
@@ -50,17 +49,7 @@ private final class HttpSessionDelegate: NSObject, URLSessionTaskDelegate, URLSe
5049 newRequest request: URLRequest,
5150 completionHandler: @escaping (URLRequest?) -> Void
5251 ) {
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− }
52+ completionHandler(nil)
6453 }
6554
6655 func urlSession(
@@ -112,9 +101,7 @@ enum Http {
112101 }()
113102
114103 static func perform(_ req: HttpRequest) async -> HttpResult {
115− guard let url = URL(string: req.url),
116− let scheme = url.scheme,
117− scheme == "http" || scheme == "https" else {
104+ guard let url = URL(string: req.url) else {
118105 return .err(.url(req.url))
119106 }
120107 var request = URLRequest(url: url)
apple/SkrepkaApp/PhotoPicker.swift+1−7
@@ -78,13 +78,7 @@ struct PhotoPicker: UIViewControllerRepresentable {
7878 quality -= 0.1
7979 }
8080 // Last resort: the smallest quality that still produces something.
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
81+ return image.jpegData(compressionQuality: 0.1)?.base64EncodedString()
8882 }
8983 }
9084}
apple/SkrepkaApp/QRView.swift+2−36
@@ -21,9 +21,6 @@ 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.
2724func scannedKeyPayload(_ raw: String) -> String {
2825 var value = raw.trimmingCharacters(in: .whitespacesAndNewlines)
2926 let schemes = ["skrepka://", "skrepka:", "https://", "http://"]
@@ -78,24 +75,6 @@ final class ScannerVC: UIViewController {
7875 override func viewDidLoad() {
7976 super.viewDidLoad()
8077 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() {
9978 guard let device = AVCaptureDevice.default(for: .video),
10079 let input = try? AVCaptureDeviceInput(device: device),
10180 session.canAddInput(input) else { return }
@@ -114,19 +93,6 @@ final class ScannerVC: UIViewController {
11493 previewLayer = preview
11594 }
11695
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−
13096 override func viewDidLayoutSubviews() {
13197 super.viewDidLayoutSubviews()
13298 previewLayer?.frame = view.bounds
@@ -134,7 +100,7 @@ final class ScannerVC: UIViewController {
134100
135101 override func viewWillAppear(_ animated: Bool) {
136102 super.viewWillAppear(animated)
137− if !session.isRunning && !session.inputs.isEmpty {
103+ if !session.isRunning {
138104 DispatchQueue.global(qos: .userInitiated).async { self.session.startRunning() }
139105 }
140106 }
@@ -146,4 +112,4 @@ final class ScannerVC: UIViewController {
146112 DispatchQueue.global(qos: .userInitiated).async { self.session.stopRunning() }
147113 }
148114 }
149−}
115+}
apple/SkrepkaApp/SkrepkaApp.swift+2−4
@@ -151,15 +151,13 @@ 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 500ms tick across the handful of transitions
154+ /// its output — so the shell watches that. A 200ms 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.
158156 private static func settle(by deadline: Date, until condition: () -> Bool) async -> Bool {
159157 while !condition() {
160158 guard !Task.isCancelled, Date() < deadline else { return false }
161159 // Cancellation makes this throw at once; the guard above is what breaks the loop.
162− try? await Task.sleep(for: .milliseconds(500))
160+ try? await Task.sleep(for: .milliseconds(200))
163161 }
164162 return true
165163 }
core/src/app.rs+27−76
@@ -9,9 +9,6 @@ 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−
1512use crux_core::{
1613 macros::effect,
1714 render::{render, RenderOperation},
@@ -584,7 +581,7 @@ fn now_ms() -> i64 {
584581 SystemTime::now()
585582 .duration_since(UNIX_EPOCH)
586583 .map(|d| d.as_millis() as i64)
587− .unwrap_or(1)
584+ .unwrap_or(0)
588585}
589586
590587/// Bare lowercased hostname of a server URL (no scheme/port/trailing dot).
@@ -818,13 +815,6 @@ impl App for Skrepka {
818815 Event::LoadedMessages(peer, res) => {
819816 match parse_kv::<Vec<StoredMessage>>(res) {
820817 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− }
828818 // Merge: a poll that beat this lazy load may have already
829819 // inserted messages for this peer. Overwriting with the
830820 // on-disk list (which the GET read before the poll wrote)
@@ -968,9 +958,6 @@ impl App for Skrepka {
968958 // would park the outbox against a limit that no longer applies.
969959 model.flush_paused_until = 0;
970960 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;
974961 let writes = if self.refuse_write(model) {
975962 Command::done()
976963 } else {
@@ -1123,13 +1110,6 @@ impl App for Skrepka {
11231110 model.error = Some("photo too large".into());
11241111 return render();
11251112 }
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− }
11331113 model.profile = OwnProfile {
11341114 display_name,
11351115 bio,
@@ -1138,8 +1118,8 @@ impl App for Skrepka {
11381118 model.page = Page::Conversations;
11391119 let ts = now_ms();
11401120 let payload = Payload::Profile {
1141− display_name: Some(model.profile.display_name.clone()),
1142− bio: Some(model.profile.bio.clone()),
1121+ display_name: model.profile.display_name.clone(),
1122+ bio: model.profile.bio.clone(),
11431123 photo: model.profile.photo.clone(),
11441124 };
11451125 // Broadcast the new profile to every contact we haven't blocked.
@@ -1574,13 +1554,8 @@ impl App for Skrepka {
15741554 // later, under load that has nothing to do with this one — starts
15751555 // at whatever height the last one climbed to.
15761556 model.send_retries = 0;
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− }
1557+ for _ in 0..batch.map_or(0, |b| b.count) {
1558+ model.outbox.pop_front();
15841559 }
15851560 return self.persist_outbox(model).and(self.flush_next(model));
15861561 }
@@ -1887,8 +1862,8 @@ impl Skrepka {
18871862 // ingest drops profiles from non-contacts, so the profile was lost.)
18881863 if was_new {
18891864 let profile = Payload::Profile {
1890− display_name: Some(model.profile.display_name.clone()),
1891− bio: Some(model.profile.bio.clone()),
1865+ display_name: model.profile.display_name.clone(),
1866+ bio: model.profile.bio.clone(),
18921867 photo: model.profile.photo.clone(),
18931868 };
18941869 model.outbox.push_back(OutboxItem::profile(
@@ -2201,10 +2176,6 @@ impl Skrepka {
22012176 let Some(parsed) = protocol::parse_payload(&dec.plaintext) else {
22022177 continue;
22032178 };
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;
22082179 let sender = dec.sender_hex;
22092180
22102181 // Mail from ourselves. A relay cannot forge this — the sender identity
@@ -2256,18 +2227,6 @@ impl Skrepka {
22562227 Contact::new(sender.clone(), String::new(), ts),
22572228 );
22582229 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;
22712230 }
22722231 let convo = model.messages.entry(sender.clone()).or_default();
22732232 // Q18: Use the persistent per-peer dedup set from
@@ -2302,6 +2261,7 @@ impl Skrepka {
23022261 },
23032262 );
23042263 id_set.insert(msg_id.clone());
2264+ events_stored += 1;
23052265 // N7: Cap the dedup set so it doesn't grow without
23062266 // bound. The set is rewritten to kv on every message
23072267 // receipt, so an unbounded set makes every receipt
@@ -2353,6 +2313,7 @@ impl Skrepka {
23532313 .or_default()
23542314 .ids
23552315 .extend(ack_ids);
2316+ events_stored += 1;
23562317 }
23572318 Payload::Profile {
23582319 display_name,
@@ -2379,19 +2340,12 @@ impl Skrepka {
23792340 // A replayed profile at the same `ts` overwrites with identical
23802341 // content, which is a no-op in practice.
23812342 if ts >= c.last_profile_ts {
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− }
2343+ c.display_name = display_name;
2344+ c.bio = bio;
2345+ c.photo = photo;
23932346 c.last_profile_ts = ts.min(now);
23942347 contacts_dirty = true;
2348+ events_stored += 1;
23952349 }
23962350 }
23972351 }
@@ -2545,7 +2499,7 @@ mod tests {
25452499 use crate::model::{MAX_OUTBOX_RETRIES, OUTBOX_TTL_MS};
25462500
25472501 fn with_identity() -> Model {
2548− let id = Identity::from_seed(&[3u8; 32]).unwrap();
2502+ let id = Identity::from_seed(&[3u8; 32]);
25492503 Model {
25502504 secret_key: Some(Zeroizing::new(id.secret_key.to_vec())),
25512505 my_pubkey: id.public_key_hex(),
@@ -2554,7 +2508,7 @@ mod tests {
25542508 }
25552509
25562510 fn peer_hex(seed: u8) -> String {
2557− Identity::from_seed(&[seed; 32]).unwrap().public_key_hex()
2511+ Identity::from_seed(&[seed; 32]).public_key_hex()
25582512 }
25592513
25602514 /// `Command::event` *queues* an event; it does not run it. The runtime drains
@@ -2577,7 +2531,7 @@ mod tests {
25772531
25782532 /// A poll page carrying one payload, encrypted from `sender_seed` to `me`.
25792533 fn page_from(sender_seed: u8, me: &Model, payload: &Payload, ts: i64) -> PollResp {
2580− let sender = Identity::from_seed(&[sender_seed; 32]).unwrap();
2534+ let sender = Identity::from_seed(&[sender_seed; 32]);
25812535 let recipient = Identity::from_secret_bytes(me.secret_key.as_ref().unwrap()).unwrap();
25822536 let json = protocol::serialize_payload(payload, ts);
25832537 let blob = crate::crypto::encrypt(
@@ -3204,7 +3158,7 @@ mod tests {
32043158 let parsed = protocol::parse_payload(item.envelope_json.as_bytes()).unwrap();
32053159 match parsed.payload {
32063160 Payload::Profile { display_name, .. } => {
3207− assert_eq!(display_name, Some("Three".to_string()), "and it is the latest one");
3161+ assert_eq!(display_name, "Three", "and it is the latest one");
32083162 }
32093163 _ => panic!("expected a profile"),
32103164 }
@@ -3252,7 +3206,7 @@ mod tests {
32523206 let parsed = protocol::parse_payload(m.outbox[0].envelope_json.as_bytes()).unwrap();
32533207 match parsed.payload {
32543208 Payload::Profile { display_name, .. } => assert_eq!(
3255− display_name, Some("New".to_string()),
3209+ display_name, "New",
32563210 "and the newer profile survives to be sent"
32573211 ),
32583212 _ => panic!("expected a profile"),
@@ -3434,8 +3388,8 @@ mod tests {
34343388 .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
34353389
34363390 let profile = |name: &str| Payload::Profile {
3437− display_name: Some(name.to_string()),
3438− bio: Some(String::new()),
3391+ display_name: name.into(),
3392+ bio: String::new(),
34393393 photo: None,
34403394 };
34413395 let page = page_from(9, &m, &profile("Far"), i64::MAX);
@@ -3532,11 +3486,8 @@ mod tests {
35323486 let (m, peer) = ingest_one_text_from_an_unblocked_peer();
35333487 assert_eq!(m.messages[&peer].len(), 1);
35343488 assert_eq!(m.messages[&peer][0].body, "hi");
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");
3489+ assert_eq!(m.outbox.len(), 1, "one delivery.ack is queued");
35383490 assert_eq!(m.outbox[0].recipient, peer);
3539− assert_eq!(m.outbox[1].recipient, peer);
35403491 }
35413492
35423493 /// A peer sending `ts = i64::MAX` used to pin itself to the top of the
@@ -4242,7 +4193,7 @@ mod tests {
42424193 );
42434194
42444195 // The recovery is a relaunch, which re-runs the loads and clears the latch.
4245− let sk = Identity::from_seed(&[3u8; 32]).unwrap().secret_key.to_vec();
4196+ let sk = Identity::from_seed(&[3u8; 32]).secret_key.to_vec();
42464197 let _ = app.update(Event::IdentityLoaded(sk), &mut m);
42474198 assert!(
42484199 !m.kv_load_failed,
@@ -4349,8 +4300,8 @@ mod tests {
43494300 );
43504301
43514302 let profile = Payload::Profile {
4352− display_name: Some("Mallory".into()),
4353− bio: Some("spam".into()),
4303+ display_name: "Mallory".into(),
4304+ bio: "spam".into(),
43544305 photo: Some("aGk=".into()),
43554306 };
43564307 let page = page_from(9, &m, &profile, now_ms());
@@ -4431,8 +4382,8 @@ mod tests {
44314382 let peer = peer_hex(9);
44324383
44334384 let profile = Payload::Profile {
4434− display_name: Some("Mallory".into()),
4435− bio: Some(String::new()),
4385+ display_name: "Mallory".into(),
4386+ bio: String::new(),
44364387 photo: Some("aGk=".into()),
44374388 };
44384389 let page = page_from(9, &m, &profile, now_ms());
@@ -4644,7 +4595,7 @@ mod tests {
46444595
46454596 // One page holding more messages from one sender than fits in a single ack.
46464597 let count = protocol::MAX_ACK_IDS + 5;
4647− let sender = Identity::from_seed(&[9u8; 32]).unwrap();
4598+ let sender = Identity::from_seed(&[9u8; 32]);
46484599 let me = Identity::from_secret_bytes(m.secret_key.as_ref().unwrap()).unwrap();
46494600 let events = (0..count)
46504601 .map(|i| {
core/src/crypto.rs+60−82
@@ -90,10 +90,7 @@ 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− // 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
93+ actual
9794 } else {
9895 actual + 65536 - remainder
9996 }
@@ -197,18 +194,18 @@ impl Identity {
197194 return Err(CryptoError::BadKeyLength);
198195 }
199196
200− Identity::with_x25519(secret_key)
197+ Ok(Identity::with_x25519(secret_key))
201198 }
202199
203200 /// Generate a fresh identity from a CSPRNG.
204− pub fn generate(rng: &mut (impl RngCore + CryptoRng)) -> Result<Self, CryptoError> {
201+ pub fn generate(rng: &mut (impl RngCore + CryptoRng)) -> Self {
205202 let mut seed = Zeroizing::new([0u8; 32]);
206203 rng.fill_bytes(&mut *seed);
207204 Self::from_seed(&seed)
208205 }
209206
210207 /// Build the 64-byte secret key (`seed || pub`) from a 32-byte seed.
211− pub fn from_seed(seed: &[u8; 32]) -> Result<Self, CryptoError> {
208+ pub fn from_seed(seed: &[u8; 32]) -> Self {
212209 let signing = SigningKey::from_bytes(seed);
213210 let public = signing.verifying_key();
214211 let mut secret_key = [0u8; ED25519_SECRET_LEN];
@@ -241,24 +238,20 @@ impl Identity {
241238 /// `InvalidEphemeralKey` and the user would be unable to receive messages
242239 /// with no indication of why. Catching it at construction time gives a
243240 /// clear error instead.
244− fn with_x25519(secret_key: [u8; ED25519_SECRET_LEN]) -> Result<Self, CryptoError> {
241+ fn with_x25519(secret_key: [u8; ED25519_SECRET_LEN]) -> Self {
245242 let x25519_priv = *ed25519_sk_to_x25519(&secret_key);
246243 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.
254244 if x25519_pub.iter().all(|b| *b == 0) {
255− return Err(CryptoError::InvalidPublicKey);
245+ eprintln!(
246+ "warning: derived X25519 public key is all-zero (low-order point) — \
247+ this identity cannot receive messages"
248+ );
256249 }
257− Ok(Identity {
250+ Identity {
258251 secret_key,
259252 x25519_priv,
260253 x25519_pub,
261− })
254+ }
262255 }
263256
264257 /// Cached X25519 private key derived from the Ed25519 seed (libsodium
@@ -294,14 +287,7 @@ fn ed25519_pk_to_x25519(ed_pub: &[u8; 32]) -> Result<[u8; 32], CryptoError> {
294287 let point = compressed
295288 .decompress()
296289 .ok_or(CryptoError::InvalidPublicKey)?;
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)
290+ Ok(point.to_montgomery().to_bytes())
305291}
306292
307293/// libsodium `crypto_sign_ed25519_sk_to_curve25519`: clamp(SHA-512(seed)[..32]).
@@ -332,7 +318,7 @@ fn x25519(scalar: &[u8; 32], point: &[u8; 32]) -> Result<Zeroizing<[u8; 32]>, Cr
332318 Ok(shared)
333319}
334320
335−/// HKDF-SHA256(ikm=raw_secret, salt=eph_pub || recip_x_pub, info="skrepka-v2", 32).
321+/// HKDF-SHA256(ikm=raw_secret, salt=eph_pub || recip_x_pub, info="skrepka-v1", 32).
336322fn derive_key(
337323 raw_secret: &[u8; 32],
338324 eph_pub: &[u8; 32],
@@ -369,7 +355,7 @@ fn decompress(data: &[u8]) -> Result<Vec<u8>, CryptoError> {
369355 decoder
370356 .window_log_max(21)
371357 .map_err(|_| CryptoError::Decompress)?;
372− let mut out = Vec::with_capacity(4096);
358+ let mut out = Vec::with_capacity(MAX_PLAINTEXT_LEN + 1);
373359 (&mut decoder)
374360 .take(MAX_PLAINTEXT_LEN as u64 + 1)
375361 .read_to_end(&mut out)
@@ -388,12 +374,6 @@ pub fn encrypt(
388374 recipient_ed_pub: &[u8; 32],
389375 plaintext: &[u8],
390376) -> 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− }
397377 let sender_pub = sender.public_key();
398378
399379 // Ephemeral raw X25519 keypair (libsodium crypto_box_keypair).
@@ -578,12 +558,12 @@ mod tests {
578558
579559 #[test]
580560 fn identity_pub_is_last_32_bytes_and_consistent() {
581− let id = Identity::generate(&mut rng(1)).unwrap();
561+ let id = Identity::generate(&mut rng(1));
582562 assert_eq!(&id.secret_key[32..], &id.public_key());
583563 // Re-deriving from the seed yields the same public key.
584564 let mut seed = [0u8; 32];
585565 seed.copy_from_slice(&id.secret_key[..32]);
586− let id2 = Identity::from_seed(&seed).unwrap();
566+ let id2 = Identity::from_seed(&seed);
587567 assert_eq!(id.public_key(), id2.public_key());
588568 }
589569
@@ -591,7 +571,7 @@ mod tests {
591571 /// under one identity while claiming another.
592572 #[test]
593573 fn from_secret_bytes_rejects_a_seed_pubkey_mismatch() {
594− let id = Identity::from_seed(&[3u8; 32]).unwrap();
574+ let id = Identity::from_seed(&[3u8; 32]);
595575 assert!(Identity::from_secret_bytes(&id.secret_key).is_ok());
596576
597577 let mut tampered = id.secret_key;
@@ -604,8 +584,8 @@ mod tests {
604584
605585 #[test]
606586 fn round_trip_recovers_plaintext_and_sender() {
607− let alice = Identity::generate(&mut rng(10)).unwrap();
608− let bob = Identity::generate(&mut rng(20)).unwrap();
587+ let alice = Identity::generate(&mut rng(10));
588+ let bob = Identity::generate(&mut rng(20));
609589 let msg = br#"{"type":"text","id":"abc","body":"hello bob","ts":1700000000000}"#;
610590
611591 let blob = encrypt(&mut rng(99), &alice, &bob.public_key(), msg).unwrap();
@@ -618,17 +598,17 @@ mod tests {
618598
619599 #[test]
620600 fn wrong_recipient_cannot_decrypt() {
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();
601+ let alice = Identity::generate(&mut rng(1));
602+ let bob = Identity::generate(&mut rng(2));
603+ let eve = Identity::generate(&mut rng(3));
624604 let blob = encrypt(&mut rng(4), &alice, &bob.public_key(), b"secret").unwrap();
625605 assert_eq!(decrypt(&eve, &blob).unwrap_err(), CryptoError::Decrypt);
626606 }
627607
628608 #[test]
629609 fn tampered_ciphertext_is_rejected() {
630− let alice = Identity::generate(&mut rng(1)).unwrap();
631− let bob = Identity::generate(&mut rng(2)).unwrap();
610+ let alice = Identity::generate(&mut rng(1));
611+ let bob = Identity::generate(&mut rng(2));
632612 let mut blob = encrypt(&mut rng(4), &alice, &bob.public_key(), b"secret").unwrap();
633613 let last = blob.len() - 1;
634614 blob[last] ^= 0xff;
@@ -637,8 +617,8 @@ mod tests {
637617
638618 #[test]
639619 fn blob_layout_offsets() {
640− let alice = Identity::generate(&mut rng(1)).unwrap();
641− let bob = Identity::generate(&mut rng(2)).unwrap();
620+ let alice = Identity::generate(&mut rng(1));
621+ let bob = Identity::generate(&mut rng(2));
642622 let blob = encrypt(&mut rng(7), &alice, &bob.public_key(), b"x").unwrap();
643623 // Version byte at offset 0.
644624 assert_eq!(blob[0], WIRE_VERSION);
@@ -655,7 +635,7 @@ mod tests {
655635
656636 #[test]
657637 fn too_short_blob_errors() {
658− let bob = Identity::generate(&mut rng(2)).unwrap();
638+ let bob = Identity::generate(&mut rng(2));
659639 assert_eq!(
660640 decrypt(&bob, &[0u8; 10]).unwrap_err(),
661641 CryptoError::BlobTooShort
@@ -666,7 +646,7 @@ mod tests {
666646 /// scalar mult, an AEAD pass, or a decompression.
667647 #[test]
668648 fn too_long_blob_errors() {
669− let bob = Identity::generate(&mut rng(2)).unwrap();
649+ let bob = Identity::generate(&mut rng(2));
670650 let huge = vec![0u8; MAX_BLOB_LEN + 1];
671651 assert_eq!(decrypt(&bob, &huge).unwrap_err(), CryptoError::BlobTooLong);
672652 }
@@ -683,27 +663,25 @@ mod tests {
683663 );
684664 assert_eq!(decompress(&bomb).unwrap_err(), CryptoError::Decompress);
685665
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− );
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);
700678 }
701679
702680 /// A plaintext right at the cap still round-trips: the bound is inclusive.
703681 #[test]
704682 fn a_plaintext_at_the_cap_still_round_trips() {
705− let alice = Identity::generate(&mut rng(1)).unwrap();
706− let bob = Identity::generate(&mut rng(2)).unwrap();
683+ let alice = Identity::generate(&mut rng(1));
684+ let bob = Identity::generate(&mut rng(2));
707685 let msg = vec![7u8; MAX_PLAINTEXT_LEN];
708686 let blob = encrypt(&mut rng(3), &alice, &bob.public_key(), &msg).unwrap();
709687 assert_eq!(decrypt(&bob, &blob).unwrap().plaintext.as_slice(), msg.as_slice());
@@ -713,7 +691,7 @@ mod tests {
713691 /// secret, so the sender's "encryption" is forgeable. Reject it.
714692 #[test]
715693 fn a_low_order_ephemeral_point_is_rejected() {
716− let bob = Identity::generate(&mut rng(2)).unwrap();
694+ let bob = Identity::generate(&mut rng(2));
717695 // eph_pub = 0 is the canonical low-order point; x25519 with it yields
718696 // an all-zero shared secret for any scalar.
719697 let mut blob = vec![0u8; MIN_BLOB_LEN + 1];
@@ -728,8 +706,8 @@ mod tests {
728706 /// A short message must be padded so the blob size lands on a bucket boundary.
729707 #[test]
730708 fn blob_is_padded_to_bucket_size() {
731− let alice = Identity::generate(&mut rng(1)).unwrap();
732− let bob = Identity::generate(&mut rng(2)).unwrap();
709+ let alice = Identity::generate(&mut rng(1));
710+ let bob = Identity::generate(&mut rng(2));
733711
734712 // Short message → should round up to the smallest bucket (256).
735713 let blob_small = encrypt(&mut rng(7), &alice, &bob.public_key(), b"x").unwrap();
@@ -754,8 +732,8 @@ mod tests {
754732 /// same blob size (same bucket) but different blob contents (random padding).
755733 #[test]
756734 fn padding_is_random_per_message() {
757− let alice = Identity::generate(&mut rng(1)).unwrap();
758− let bob = Identity::generate(&mut rng(2)).unwrap();
735+ let alice = Identity::generate(&mut rng(1));
736+ let bob = Identity::generate(&mut rng(2));
759737 let msg = b"hello bob, this is a test message";
760738
761739 let blob1 = encrypt(&mut rng(100), &alice, &bob.public_key(), msg).unwrap();
@@ -776,8 +754,8 @@ mod tests {
776754 /// never sees trailing padding bytes.
777755 #[test]
778756 fn compressed_len_field_is_parsed_correctly() {
779− let alice = Identity::generate(&mut rng(1)).unwrap();
780− let bob = Identity::generate(&mut rng(2)).unwrap();
757+ let alice = Identity::generate(&mut rng(1));
758+ let bob = Identity::generate(&mut rng(2));
781759
782760 // Normal round-trip.
783761 let msg = b"round trip test with some content";
@@ -799,7 +777,7 @@ mod tests {
799777 /// error says so rather than blaming decryption.
800778 #[test]
801779 fn encrypting_to_an_off_curve_key_reports_an_invalid_public_key() {
802− let alice = Identity::generate(&mut rng(1)).unwrap();
780+ let alice = Identity::generate(&mut rng(1));
803781 let not_a_point = [0xabu8; 32];
804782 assert_eq!(
805783 encrypt(&mut rng(2), &alice, &not_a_point, b"x").unwrap_err(),
@@ -809,7 +787,7 @@ mod tests {
809787
810788 #[test]
811789 fn sk_to_x25519_clamps() {
812− let id = Identity::generate(&mut rng(5)).unwrap();
790+ let id = Identity::generate(&mut rng(5));
813791 let scalar = ed25519_sk_to_x25519(&id.secret_key);
814792 assert_eq!(scalar[0] & 7, 0);
815793 assert_eq!(scalar[31] & 64, 64);
@@ -818,7 +796,7 @@ mod tests {
818796
819797 #[test]
820798 fn challenge_signature_is_deterministic_and_host_bound() {
821− let id = Identity::from_seed(&[7u8; 32]).unwrap();
799+ let id = Identity::from_seed(&[7u8; 32]);
822800 let a = id.sign_challenge("relay.example.com", "deadbeef").unwrap();
823801 let b = id.sign_challenge("relay.example.com", "deadbeef").unwrap();
824802 let c = id.sign_challenge("other.example.com", "deadbeef").unwrap();
@@ -830,7 +808,7 @@ mod tests {
830808 /// We sign whatever a not-yet-trusted relay hands us, so its size is bounded.
831809 #[test]
832810 fn an_oversized_challenge_is_not_signed() {
833− let id = Identity::from_seed(&[7u8; 32]).unwrap();
811+ let id = Identity::from_seed(&[7u8; 32]);
834812 let huge = "a".repeat(MAX_CHALLENGE_LEN + 1);
835813 assert_eq!(
836814 id.sign_challenge("relay.example.com", &huge).unwrap_err(),
@@ -850,9 +828,9 @@ mod tests {
850828 /// explicit rather than implicit in the KDF.
851829 #[test]
852830 fn wrong_recipient_cannot_decrypt_aead_fails() {
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();
831+ let alice = Identity::generate(&mut rng(1));
832+ let bob = Identity::generate(&mut rng(2));
833+ let charlie = Identity::generate(&mut rng(3));
856834
857835 let blob = encrypt(&mut rng(4), &alice, &bob.public_key(), b"hi bob").unwrap();
858836 assert_eq!(
@@ -868,8 +846,8 @@ mod tests {
868846 /// check fires first.
869847 #[test]
870848 fn tampered_version_byte_is_rejected() {
871− let alice = Identity::generate(&mut rng(1)).unwrap();
872− let bob = Identity::generate(&mut rng(2)).unwrap();
849+ let alice = Identity::generate(&mut rng(1));
850+ let bob = Identity::generate(&mut rng(2));
873851 let mut blob = encrypt(&mut rng(3), &alice, &bob.public_key(), b"hi").unwrap();
874852 blob[0] ^= 0xff;
875853 assert_eq!(
@@ -885,8 +863,8 @@ mod tests {
885863 /// catches this.
886864 #[test]
887865 fn tampered_ephemeral_pubkey_is_rejected() {
888− let alice = Identity::generate(&mut rng(1)).unwrap();
889− let bob = Identity::generate(&mut rng(2)).unwrap();
866+ let alice = Identity::generate(&mut rng(1));
867+ let bob = Identity::generate(&mut rng(2));
890868 let mut blob = encrypt(&mut rng(3), &alice, &bob.public_key(), b"hi").unwrap();
891869 blob[5] ^= 0x01;
892870 assert!(decrypt(&bob, &blob).is_err());
core/src/ffi.rs−5
@@ -39,11 +39,6 @@ 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.
4742fn guard(f: impl FnOnce() -> Vec<u8>) -> Vec<u8> {
4843 catch_unwind(AssertUnwindSafe(f)).unwrap_or_default()
4944}
core/src/phonemic.rs+3−8
@@ -96,11 +96,6 @@ 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− }
10499 if !bytes.len().is_multiple_of(2) {
105100 return None;
106101 }
@@ -156,7 +151,7 @@ pub fn try_parse_pubkey(input: &str) -> Option<String> {
156151 if let Some(hex) = from_ob(trimmed).as_deref().and_then(valid_pubkey_hex) {
157152 return Some(hex);
158153 }
159− if let Ok(bytes) = hex::decode(trimmed) {
154+ if let Ok(bytes) = hex::decode(trimmed.to_lowercase()) {
160155 return valid_pubkey_hex(&bytes);
161156 }
162157 None
@@ -212,13 +207,13 @@ mod tests {
212207 fn odd_length_input_has_no_spelling() {
213208 assert_eq!(to_ob(&[7u8]), None);
214209 assert_eq!(to_ob(&[7u8, 0]), Some("hidzod".to_string()));
215− assert_eq!(to_ob(&[]), None);
210+ assert_eq!(to_ob(&[]), Some(String::new()));
216211 }
217212
218213 /// A real Ed25519 public key. `try_parse_pubkey` now checks that a key is a
219214 /// curve point, so tests can't use an arbitrary byte pattern.
220215 fn real_key() -> [u8; 32] {
221− crate::crypto::Identity::from_seed(&[7u8; 32]).unwrap().public_key()
216+ crate::crypto::Identity::from_seed(&[7u8; 32]).public_key()
222217 }
223218
224219 /// 32 bytes that decode fine but are *not* on the curve — the case that used
core/src/protocol.rs+31−64
@@ -35,8 +35,8 @@ pub enum Payload {
3535 ack_ids: Vec<String>,
3636 },
3737 Profile {
38− display_name: Option<String>,
39− bio: Option<String>,
38+ display_name: String,
39+ bio: String,
4040 photo: Option<String>,
4141 },
4242}
@@ -77,16 +77,10 @@ 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,
8082 "ts": ts,
8183 });
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− }
9084 // Omit `photo` entirely when there isn't one, rather than sending an
9185 // explicit null: "no photo" and "field absent" mean the same thing to
9286 // a reader, and the null costs bytes in every broadcast.
@@ -99,38 +93,6 @@ pub fn serialize_payload(payload: &Payload, ts: i64) -> String {
9993 value.to_string()
10094}
10195
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−
13496/// Parse a plaintext payload. Returns `None` for unknown/malformed types so the
13597/// caller silently ignores them (forward compatibility, PROTOCOL.md §4).
13698pub fn parse_payload(bytes: &[u8]) -> Option<ParsedPayload> {
@@ -140,7 +102,7 @@ pub fn parse_payload(bytes: &[u8]) -> Option<ParsedPayload> {
140102 let payload = match ty {
141103 "text" => {
142104 let id = v.get("id")?.as_str()?.to_string();
143− let body = sanitize_text(v.get("body")?.as_str()?);
105+ let body = v.get("body")?.as_str()?.to_string();
144106 if id.len() > MAX_ID_LEN || body.len() > MAX_BODY_LEN {
145107 return None;
146108 }
@@ -165,17 +127,22 @@ pub fn parse_payload(bytes: &[u8]) -> Option<ParsedPayload> {
165127 Payload::DeliveryAck { ack_ids }
166128 }
167129 "profile" => {
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);
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();
173140 let photo = v
174141 .get("photo")
175142 .and_then(|x| x.as_str())
176143 .map(str::to_string);
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)
144+ if display_name.chars().count() > MAX_DISPLAY_NAME_LEN
145+ || bio.chars().count() > MAX_BIO_LEN
179146 || photo.as_ref().is_some_and(|p| p.len() > MAX_PHOTO_LEN)
180147 || photo.as_ref().is_some_and(|p| {
181148 // Reject a photo that isn't valid base64 — a peer can put
@@ -228,14 +195,14 @@ mod tests {
228195 #[test]
229196 fn profile_round_trip_with_and_without_photo() {
230197 let with = Payload::Profile {
231− display_name: Some("Alice".into()),
232− bio: Some("writing".into()),
198+ display_name: "Alice".into(),
199+ bio: "writing".into(),
233200 photo: Some("aGVsbG8=".into()),
234201 };
235202 assert_eq!(reparse(&with, 1).payload, with);
236203 let without = Payload::Profile {
237− display_name: Some("Bob".into()),
238− bio: Some("".into()),
204+ display_name: "Bob".into(),
205+ bio: "".into(),
239206 photo: None,
240207 };
241208 assert_eq!(reparse(&without, 1).payload, without);
@@ -275,8 +242,8 @@ mod tests {
275242
276243 let long_bio = serialize_payload(
277244 &Payload::Profile {
278− display_name: Some("A".into()),
279− bio: Some("b".repeat(MAX_BIO_LEN + 1)),
245+ display_name: "A".into(),
246+ bio: "b".repeat(MAX_BIO_LEN + 1),
280247 photo: None,
281248 },
282249 1,
@@ -285,8 +252,8 @@ mod tests {
285252
286253 let long_name = serialize_payload(
287254 &Payload::Profile {
288− display_name: Some("n".repeat(MAX_DISPLAY_NAME_LEN + 1)),
289− bio: Some(String::new()),
255+ display_name: "n".repeat(MAX_DISPLAY_NAME_LEN + 1),
256+ bio: String::new(),
290257 photo: None,
291258 },
292259 1,
@@ -295,8 +262,8 @@ mod tests {
295262
296263 let big_photo = serialize_payload(
297264 &Payload::Profile {
298− display_name: Some(String::new()),
299− bio: Some(String::new()),
265+ display_name: String::new(),
266+ bio: String::new(),
300267 photo: Some("p".repeat(MAX_PHOTO_LEN + 1)),
301268 },
302269 1,
@@ -330,8 +297,8 @@ mod tests {
330297 fn a_photoless_profile_omits_the_field() {
331298 let json = serialize_payload(
332299 &Payload::Profile {
333− display_name: Some("A".into()),
334− bio: Some("B".into()),
300+ display_name: "A".into(),
301+ bio: "B".into(),
335302 photo: None,
336303 },
337304 1,
@@ -342,8 +309,8 @@ mod tests {
342309 #[test]
343310 fn snake_case_field_names_on_wire() {
344311 let p = Payload::Profile {
345− display_name: Some("A".into()),
346− bio: Some("B".into()),
312+ display_name: "A".into(),
313+ bio: "B".into(),
347314 photo: None,
348315 };
349316 let json = serialize_payload(&p, 1);
server.knot+8−37
@@ -102,14 +102,6 @@ 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
113105-- Global ceiling on the `*forwards` outbox. `maxForwardsPerServer` bounds any one
114106-- peer, but a recipient gossiped as present on many servers fans one blob out to
115107-- all of them, so the total is bounded only by (peers x per-peer cap). This caps
@@ -967,23 +959,13 @@ appendMessage = \toKey encryptedBlob t -> do
967959 -- for this recipient so the post-append count lands exactly on the ceiling.
968960 -- `- 1` reserves the slot the new row is about to take.
969961 --
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.
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.
975965 let forRecipient = filter (\m -> m.toKey == toKey) msgs
976966 let excess = max 0 (count forRecipient - (maxMessagesPerRecipient - 1))
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
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
987969 -- Global cap (S6): after the per-recipient eviction, bound the total mailbox
988970 -- across all recipients by dropping the globally-oldest rows. Only runs on
989971 -- overflow, so the O(n log n) sort is off the hot path. Dropped rows may
@@ -1046,12 +1028,6 @@ pruneExpired = \t -> atomic do
10461028 -- Prune forwards whose message was removed by the TTL sweep above.
10471029 -- ForwardEntry carries no timestamp, so match against the surviving
10481030 -- 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.
10551031 fw <- *forwards
10561032 *forwards = filter (\f -> any (\m -> m.toKey == f.toKey && m.encryptedBlob == f.encryptedBlob) surviving) fw
10571033 ff <- *forwardFailures
@@ -1422,16 +1398,11 @@ handlePoll = \cursor authorization clientIp -> do
14221398 -- so peers don't time us out mid-session.
14231399 let stale = not any (\p -> p.pubkey == pubkey && isLocalServer p.server && p.expiresAt - t >= sessionExpiry / 2) pres
14241400 refreshPeers <- if stale then refreshLocalPresence pubkey (t + sessionExpiry) else yield []
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}}))
1401+ yield (Just {value: {pubkey, clampedCursor, refreshPeers}}))
14301402 case setup of
14311403 Nothing {} -> yield (Err {error: {status: 401, message: "unauthorized"}})
14321404 Just {value: s} -> do
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}}]))
1405+ when federationEnabled (fork (sendBatchedGossip s.refreshPeers [{eventType: "online", pubkey: Just {value: s.pubkey}}]))
14351406 pollResult <- doPoll s t
14361407 yield (Ok {value: pollResult})
14371408
@@ -1550,7 +1521,7 @@ handleSendMessages = \messages authorization clientIp -> do
15501521 let staged = map (\m -> {
15511522 m.to,
15521523 m.encryptedBlob,
1553− remoteServers: take maxRemoteServersPerMessage (collectPeers (filter (\p -> p.pubkey == m.to && p.expiresAt > t) pres) (\p -> p.server))
1524+ remoteServers: take maxForwardsPerServer (collectPeers (filter (\p -> p.pubkey == m.to && p.expiresAt > t) pres) (\p -> p.server))
15541525 }) messages
15551526 forEach staged (\c -> do
15561527 -- Read `*messages` *before* the append: `delivered` below must see the