on master help sign in
17 files changed+1446−326
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+13−8 apple/SkrepkaApp/Core.swift+13−15 apple/SkrepkaApp/Effects.swift+80−14 apple/SkrepkaApp/PhotoPicker.swift+23−1 apple/SkrepkaApp/QRView.swift+36−2 apple/SkrepkaApp/SkrepkaApp.swift+4−2 core/src/app.rs+151−42 core/src/crypto.rs+120−95 core/src/ffi.rs+5 core/src/phonemic.rs+8−3 core/src/protocol.rs+64−31 server.knot+226−110 server.o
.github/workflows/build.yml+1−1
@@ -157,7 +157,7 @@ jobs:
157157
158158 PK=$(printf '0%.0s' $(seq 1 64)) # 64 hex chars: a well-formed pubkey
159159 SIG=$(printf '0%.0s' $(seq 1 128)) # 128 hex chars: a well-formed signature
160− BLOB=$(printf 'ab%.0s' $(seq 1 200)) # 400 hex chars: over the 336-char floor
160+ BLOB=$(printf 'ab%.0s' $(seq 1 200)) # 400 hex chars: over the 346-char floor
161161
162162 # $1 = expected status, $2 = label, rest = curl args
163163 expect() {
AGENTS.md+2−2
@@ -11,7 +11,7 @@ Decentralized, end-to-end encrypted messenger. No accounts, no home servers —
1111- **Apple shell:** Swift + SwiftUI, **iOS 17+ only** (`include_macos = false` in `core/boltffi.toml`). The shell is a thin effect executor — it performs HTTP, key-value, and timer effects and renders the `ViewModel`. It contains no business logic.
1212- **Crypto:** `ed25519-dalek` + `x25519-dalek` + `curve25519-dalek`, `chacha20poly1305` (XChaCha20-Poly1305), `hkdf` + `sha2` (HKDF-SHA256), `zstd` (plaintext compression). **No libsodium** — `core/src/crypto.rs` hand-reimplements libsodium's `crypto_sign_ed25519_{pk,sk}_to_curve25519` so blobs stay wire-compatible with any spec-following client.
1313- **Server:** `server.knot`, written in [Knot](https://github.com/ilyakooo0/knot) — a custom functional language compiled to a native binary. Its runtime transparently persists every `*ref` to SQLite, so all server state (`*sessions`, `*messages`, `*presence`, …) survives restarts despite reading like in-memory lists.
14−- **Persistence (client):** `crux_kv` — JSON blobs under the keys `settings`, `profile`, `contacts`, `cursor`, `outbox`, and `messages:<peer_hex>`. The Swift shell backs this with one file per key under Application Support. The 64-byte Ed25519 secret key lives in the iOS Keychain, never in kv.
14+- **Persistence (client):** `crux_kv` — JSON blobs under the keys `settings`, `profile`, `contacts`, `cursor`, `outbox`, `seen_ids`, and `messages:<peer_hex>`. The Swift shell backs this with one file per key under Application Support. The 64-byte Ed25519 secret key lives in the iOS Keychain, never in kv.
1515
1616## Project Layout
1717
@@ -95,7 +95,7 @@ Two kinds of `Event` variants, and the distinction matters:
9595
9696The `ViewModel` is deliberately flat and stringly-typed (`page: String`, `conn_status: String`) so the Swift side switches on plain strings rather than needing generated enums for UI state.
9797
98−Runtime flow: `IdentityLoaded` (from the Keychain, at boot) fans out five kv loads → `LoadedSettings` triggers `Connect` → `Authenticate` (challenge/verify, signature bound to the server's bare hostname under the `skrepka-auth-v1:` tag) → on a token, `Poll` (server long-polls ~25 s, client re-polls immediately) and `StartFlush` (drains the outbox one message at a time, encrypting at send time). Failures back off via `crux_time` — 3 s doubling to a 30 s cap.
98+Runtime flow: `IdentityLoaded` (from the Keychain, at boot) fans out six kv loads → `LoadedSettings` triggers `Connect` → `Authenticate` (challenge/verify, signature bound to the server's bare hostname under the `skrepka-auth-v1:` tag) → on a token, `Poll` (server long-polls ~25 s, client re-polls immediately) and `StartFlush` (drains the outbox one message at a time, encrypting at send time). Failures back off via `crux_time` — 3 s doubling to a 30 s cap.
9999
100100## Code Conventions
101101
AUDIT-DEEP.mdnew file+230
@@ -0,0 +1,230 @@
1+# Skrepka Deep Audit — app.rs & model.rs (2026-07-16)
2+
3+**Scope:** Deep audit of `core/src/app.rs` and `core/src/model.rs`, cross-referenced against PROTOCOL.md §4, §6, §9, §10 and existing audits (AUDIT-NEW.md, AUDIT-FRESH.md, PROTOCOL-REVIEW.md). Findings below are **not** documented in any prior audit.
4+
5+**Legend:** 🔴 critical · 🟠 high · 🟡 medium · 🔵 low · ⚪ info
6+
7+---
8+
9+## 1. State Machine — Poll Loop / Cursor / Budget
10+
11+### 🟡 D1. `events_stored` doesn't count successfully-decrypted-but-non-stored events — livelock on budget exhaustion
12+
13+**Type:** State machine bug (livelock)
14+**File:** `core/src/app.rs:2127, 2188-2200, 2248-2293, 2316, 2342-2349`
15+
16+The `events_stored` counter drives the cursor-restore decision on budget/count exhaustion (lines 2145-2147, 2163-2165): if `events_stored == 0`, the cursor is restored to `prev_cursor`, and the relay re-serves the same page on the next poll.
17+
18+`events_stored` is only incremented for:
19+- Text messages that **pass** dedup (line 2264 — inside `if !id_set.contains`)
20+- All `delivery.ack` payloads (line 2316)
21+- Profiles that **pass** staleness (line 2348 — inside `if ts >= c.last_profile_ts`)
22+
23+It is **NOT** incremented for:
24+- **Duplicate text messages** (line 2248: the `if !id_set.contains` block is not entered)
25+- **Stale profiles** (line 2342: the `if ts >=` block is not entered)
26+- **Profiles from strangers** (line 2327: `continue` before any increment)
27+- **Self-messages** (line 2188: `continue` before any increment)
28+- **Blocked-sender messages** (line 2198: `continue` before any increment)
29+
30+All of these are **successfully decrypted** — they consume the full AEAD decrypt cost and the per-blob byte budget — but they don't increment `events_stored`. If a poll page consists entirely of such events and the byte budget is exhausted mid-page, `events_stored == 0`, the cursor is restored, and the client re-polls with the old cursor. The relay re-serves the same page. **Livelock.**
31+
32+**Attack scenario:**
33+1. Client receives 4 large blobs (each ~20 MiB decoded, total ~80 MiB > 64 MiB budget).
34+2. First encounter: all 4 are new text messages. `events_stored = 3` (budget exhausted on 4th). Cursor kept at `page.cursor`. Relay deletes events. No issue.
35+3. **Hostile relay re-serves the same 4 blobs** (it kept copies despite cursor advance). All 4 are now duplicates. Budget exhausted on 4th blob. `events_stored == 0`. Cursor restored to `prev_cursor`.
36+4. Client re-polls with `prev_cursor`. Relay re-serves same 4 blobs. Go to 3. **Permanent livelock** — the client can never advance past this page, wasting bandwidth and battery on every cycle.
37+
38+The same pattern applies to a page of large stale profile blobs, large profile-from-stranger blobs, or large self-message echoes — all decrypt successfully, all are dropped, none increment `events_stored`.
39+
40+**Note:** AUDIT-FRESH.md N9 identified the budget-exhaustion livelock for the case where blobs **fail decryption** (events_stored == 0, hostile junk). The fix applied (keep cursor if `events_stored > 0`) addresses that case. But the **duplicate/stale/stranger/self/blocked** cases are different: the blobs ARE decryptable, the client DID process them (and doesn't need them again), but `events_stored` doesn't reflect this. This is a new variant of the N9 livelock that the existing fix does not cover.
41+
42+**Suggested fix:** Increment `events_stored` after successful decryption and parse, regardless of whether the event is stored:
43+```rust
44+let Ok(dec) = crate::crypto::decrypt(&id, &blob) else { continue; };
45+let Some(parsed) = protocol::parse_payload(&dec.plaintext) else { continue; };
46+events_stored += 1; // successfully decrypted — cursor can advance past this
47+```
48+This ensures the cursor is kept whenever at least one event was successfully processed (even if it was a duplicate/stale/stranger), breaking the livelock. The unprocessed tail (budget exhaustion) is still lost, but that's the accepted N9 tradeoff.
49+
50+---
51+
52+### 🔵 D2. `SetServerUrl` resets `send_retries` but not `poll_retries` — new relay inherits stale poll backoff
53+
54+**Type:** State machine bug (stale state on relay switch)
55+**File:** `core/src/app.rs:960`
56+
57+When switching relays, `SetServerUrl` resets `send_retries = 0` (line 960) and `flush_paused_until = 0` (line 959), with the comment: "The old relay's rate limit was the old relay's opinion. A new one is entitled to take our sends immediately."
58+
59+The same logic applies to `poll_retries` — it's the old relay's poll failure count — but it is **not reset**. If the old relay was failing (e.g., 5xx storm), `poll_retries` could be at 4 (backoff = 30s). The first poll to the new relay fails (e.g., transient network issue), and `backoff_poll` uses `backoff_ms(5) = 30s` instead of starting at 3s. The new relay's poll loop is penalized for the old relay's failures.
60+
61+**Attack scenario:** No attack needed — this triggers on any relay switch after a period of poll failures. The user switches to a healthy relay but experiences unnecessarily long poll backoffs.
62+
63+**Suggested fix:** Add `model.poll_retries = 0;` alongside `model.send_retries = 0;` at line 960.
64+
65+---
66+
67+## 2. State Machine — Outbox / Flush
68+
69+### 🔵 D3. `SendResult` pops outbox items even when `kv_load_failed` is set — duplicate sends on relaunch
70+
71+**Type:** State machine bug (write guard gap)
72+**File:** `core/src/app.rs:1547-1560, 1728-1733`
73+
74+If `kv_load_failed` is set (by a `LoadedMessages` failure) while a send is in flight, `SendResult` still pops the batch from `model.outbox` (line 1557-1559) on a 200 or 400. The subsequent `persist_outbox` (line 1560) calls `refuse_write`, which checks `kv_load_failed` and refuses the write. The items are removed from the in-memory outbox but **not from the on-disk outbox**.
75+
76+On relaunch, the outbox is re-loaded from disk — the popped items are still there. They are re-sent:
77+- For a **200** (relay accepted): duplicate sends. The recipient's dedup handles it, but the sender wastes bandwidth and the recipient sees duplicate delivery attempts.
78+- For a **400** (relay rejected): the items are re-sent and re-rejected, burning retry budget.
79+
80+**Timeline:**
81+1. Startup loads complete. `Connect` → `Authenticate` → `VerifyResult` → `Poll` + `StartFlush`.
82+2. `StartFlush` sends a batch. `flushing = true`, `in_flight` is set.
83+3. `LoadedMessages` for some peer fails. `kv_load_failed = true`.
84+4. `SendResult` arrives (200). `flushing` cleared, batch popped (line 1557-1559). `persist_outbox` refused (line 1729-1730).
85+5. On relaunch: outbox loaded from disk. Items re-sent.
86+
87+**Note:** The window is narrow — `LoadedMessages` is a lazy load triggered by `LoadedContacts`, and `StartFlush` fires from `VerifyResult` which also fires after startup loads. But the ordering is: startup loads → `Connect` → `Authenticate` → `VerifyResult` → `StartFlush` + `Poll` → `LoadedMessages` (lazy). So the send can be in flight when `LoadedMessages` fails.
88+
89+**Suggested fix:** Check `kv_load_failed` before popping in `SendResult`. If set, don't pop — leave items in the outbox for relaunch to handle:
90+```rust
91+if ((200..300).contains(&status) || status == 400) && !model.kv_load_failed {
92+ // pop and persist
93+}
94+```
95+Alternatively, don't pop if `persist_outbox` was refused (check the return of `refuse_write`).
96+
97+---
98+
99+## 3. Model / Persistence
100+
101+### 🔵 D4. `now_ms()` returns 0 on system clock failure — outbox TTL disabled
102+
103+**Type:** Robustness gap
104+**File:** `core/src/app.rs:580-585`
105+
106+```rust
107+fn now_ms() -> i64 {
108+ SystemTime::now()
109+ .duration_since(UNIX_EPOCH)
110+ .map(|d| d.as_millis() as i64)
111+ .unwrap_or(0)
112+}
113+```
114+
115+If the system clock is before `UNIX_EPOCH` (misconfigured device, RTK bug, NTP step), `now_ms()` returns 0. Consequences:
116+
117+- **Outbox TTL disabled:** `first_attempt` is stamped as 0 (line 1990-1993). `OutboxItem::is_expired` checks `first_attempt > 0` (line 170) — 0 means "never attempted," so the TTL never applies. Stuck items are retried forever (only the retry counter bounds them, which at 10 retries with slow backoff could take days).
118+- **`flush_paused_until` bypassed:** `now_ms() < flush_paused_until` → `0 < 0` → false. Flushing proceeds immediately, ignoring a relay's `Retry-After`.
119+- **`last_ack_ts` / `last_profile_ts` clamped to 0:** `ts.min(now)` → `ts.min(0)` → 0 (if ts is positive). All future acks/profiles pass the staleness check (any ts >= 0). Dedup is weakened.
120+
121+**Attack scenario:** No attack — this is a device misconfiguration. But on iOS, a clock that briefly reads as pre-epoch during boot could trigger this transiently.
122+
123+**Suggested fix:** Use `UNIX_EPOCH` as the floor instead of 0:
124+```rust
125+fn now_ms() -> i64 {
126+ SystemTime::now()
127+ .duration_since(UNIX_EPOCH)
128+ .map(|d| d.as_millis() as i64)
129+ .unwrap_or(1) // 1, not 0 — 0 is a valid "never attempted" sentinel
130+}
131+```
132+Or better: propagate the error and have callers handle clock failure explicitly.
133+
134+---
135+
136+### 🔵 D5. `LoadedMessages` merge doesn't update `seen_ids` — weakened dedup after `seen_ids` load failure
137+
138+**Type:** Dedup gap
139+**File:** `core/src/app.rs:815-856`
140+
141+The `LoadedMessages` handler merges on-disk messages into the in-memory conversation (union by ID, re-sort, trim). But it does **not** add the on-disk message IDs to `model.seen_ids`.
142+
143+When `seen_ids` loads successfully from kv at startup, this is fine — the on-disk IDs are already in the set. But when `seen_ids` **fails to load** (line 807-811, non-fatal), the set is empty. The `ingest_poll` seeding at line 2244-2246 seeds from the conversation:
144+```rust
145+if id_set.is_empty() {
146+ id_set.extend(convo.iter().map(|m| m.id.clone()));
147+}
148+```
149+
150+If a poll arrives **before** `LoadedMessages` for a peer, the conversation is empty (not loaded yet). The seeding adds no IDs. The poll's messages are stored and their IDs are added to the set. Later, `LoadedMessages` arrives and merges the on-disk messages — but their IDs are **never** added to `seen_ids` (the merge doesn't touch `seen_ids`, and the seeding only runs when `id_set.is_empty()`, which is no longer true after the poll).
151+
152+**Result:** On-disk message IDs are missing from `seen_ids`. Future replays of those on-disk messages pass the dedup check (line 2248) and are re-inserted into the conversation. The `LoadedMessages` merge filters duplicates by ID (line 830), so the conversation doesn't grow unboundedly — but each replay is processed (decrypted, inserted, acked) before the merge catches it.
153+
154+**Attack scenario:**
155+1. `seen_ids` kv load fails (device locked, file corruption).
156+2. `LoadedContacts` triggers `LoadedMessages` for peer P.
157+3. A poll arrives before `LoadedMessages` for P. P's conversation is empty. `seen_ids[P]` is seeded as empty.
158+4. Poll stores new messages, adds their IDs to `seen_ids[P]`.
159+5. `LoadedMessages` for P arrives. On-disk messages are merged into the conversation. Their IDs are NOT added to `seen_ids[P]`.
160+6. A replay of an on-disk message arrives. Its ID is not in `seen_ids[P]`. It passes dedup. It's inserted and acked.
161+
162+**Impact:** Weakened dedup — replays of on-disk messages are re-processed (decrypted, stored, acked). No permanent data corruption (the merge prevents duplicate conversation entries), but the recipient sends spurious delivery acks for old messages, and the sender receives false "delivered" confirmations.
163+
164+**Suggested fix:** In the `LoadedMessages` merge, add the on-disk message IDs to `seen_ids`:
165+```rust
166+if let Some(id_set) = model.seen_ids.get_mut(&peer) {
167+ id_set.extend(list.iter().map(|m| m.id.clone()));
168+}
169+```
170+
171+---
172+
173+## 4. Protocol Deviations
174+
175+### ⚪ D6. `ingest_poll` doesn't send profile to stranger on first contact — protocol "SHOULD" not followed
176+
177+**Type:** Protocol deviation (UX)
178+**File:** `core/src/app.rs:2221-2230, 2400-2412`
179+
180+PROTOCOL.md §4: "Clients SHOULD send a `profile` message to a contact on first interaction and whenever the user updates their name, bio, or photo."
181+
182+When a stranger sends us a text, `ingest_poll` auto-creates a contact (line 2225-2229) and queues a `delivery.ack` (line 2400-2412). But it does **not** queue a `profile` broadcast back to the stranger. The stranger's client has our pubkey (they sent us a message) but no profile — they see our truncated @p but not our display name or avatar.
183+
184+`send_text` handles this correctly: on first contact (line 1858-1873), it queues a profile broadcast after the text. But `ingest_poll` doesn't.
185+
186+**Impact:** Minor UX issue — a stranger who initiates conversation doesn't see our profile until we manually send them a text or update our profile. Not a security vulnerability.
187+
188+**Suggested fix:** After auto-creating a contact in `ingest_poll`, queue a profile broadcast:
189+```rust
190+if !known {
191+ // ... auto-create contact ...
192+ let profile = Payload::Profile { /* ... */ };
193+ model.outbox.push_back(OutboxItem::profile(
194+ sender.clone(),
195+ Arc::new(protocol::serialize_payload(&profile, ts)),
196+ ));
197+ outbox_dirty = true;
198+}
199+```
200+
201+---
202+
203+## Summary of New Findings
204+
205+| # | Severity | Type | Title |
206+|---|----------|------|-------|
207+| D1 | 🟡 medium | Livelock | `events_stored` doesn't count decrypted-but-non-stored events — livelock on budget exhaustion |
208+| D2 | 🔵 low | Stale state | `SetServerUrl` doesn't reset `poll_retries` — new relay inherits old poll backoff |
209+| D3 | 🔵 low | Write guard gap | `SendResult` pops items when `kv_load_failed` — duplicate sends on relaunch |
210+| D4 | 🔵 low | Robustness | `now_ms()` returns 0 on clock failure — outbox TTL disabled |
211+| D5 | 🔵 low | Dedup gap | `LoadedMessages` merge doesn't update `seen_ids` — weakened dedup after load failure |
212+| D6 | ⚪ info | Protocol deviation | `ingest_poll` doesn't send profile to stranger on first contact |
213+
214+**Medium (1):** D1
215+**Low (4):** D2, D3, D4, D5
216+**Info (1):** D6
217+
218+---
219+
220+## Priorities
221+
222+1. **D1 (medium):** Fix `events_stored` to count all successfully decrypted events. This closes a livelock variant that the N9 fix doesn't cover — a hostile relay can permanently wedge the client by re-serving large duplicate/stale/stranger blobs.
223+
224+2. **D2 (low):** Add `model.poll_retries = 0;` to `SetServerUrl`. One-line fix, prevents stale backoff on relay switch.
225+
226+3. **D5 (low):** Update `seen_ids` in the `LoadedMessages` merge. Closes a dedup gap when `seen_ids` kv load fails.
227+
228+4. **D3 (low):** Guard `SendResult` pop with `kv_load_failed` check. Prevents duplicate sends on relaunch.
229+
230+5. **D4 (low):** Change `unwrap_or(0)` to `unwrap_or(1)` in `now_ms()`. Prevents TTL disablement on clock failure.
AUDIT-FFI-PHONEMIC-APPLE.mdnew file+470
@@ -0,0 +1,470 @@
1+# Skrepka Security Audit — FFI, Phonemic, Apple Shell (2026-07-16)
2+
3+**Scope:** Deep audit of `core/src/ffi.rs`, `core/src/phonemic.rs`, and all Apple shell files (`apple/SkrepkaApp/*.swift`). Cross-referenced against `PROTOCOL.md`, `AGENTS.md`, `AUDIT-NEW.md`, and `AUDIT-FRESH.md`. Findings below are **not** in prior audits.
4+
5+**Legend:** 🔴 critical · 🟠 high · 🟡 medium · 🔵 low · ⚪ info
6+
7+---
8+
9+## 1. FFI Boundary
10+
11+### 🟠 F1. `try!` in Core.swift crashes on empty bytes from `guard` — FFI panic safety is a lie
12+
13+**Type:** FFI boundary / panic safety
14+**Files:** `core/src/ffi.rs:42-44` (`guard`), `apple/SkrepkaApp/Core.swift:30,38,49,60,66,74,81`
15+
16+The `guard` function in `ffi.rs` catches panics with `catch_unwind` and returns an empty `Vec<u8>`. The module doc (lines 4-9) claims: *"the shell already tolerates an empty effect list (it just does nothing) and an empty view (it renders the previous one), so a bug degrades into a stalled UI rather than a corrupted process."*
17+
18+This is false. Every bincode deserialization in `Core.swift` uses `try!`:
19+
20+```swift
21+// Core.swift:30 (init)
22+self.view = try! .bincodeDeserialize(input: [UInt8](core.view()))
23+
24+// Core.swift:38 (update)
25+let effects = [UInt8](core.update(data: Data(try! event.bincodeSerialize())))
26+
27+// Core.swift:49 (dispatch)
28+let requests = try! Requests.bincodeDeserialize(input: effects).value
29+
30+// Core.swift:60 (render)
31+self.view = try! .bincodeDeserialize(input: [UInt8](core.view()))
32+```
33+
34+If `core.view()` or `core.update()` returns empty bytes (from a caught panic), `try! .bincodeDeserialize(input: [])` throws, and `try!` produces a fatal error — **crashing the app**, not degrading gracefully. The `init` case is the worst: there is no "previous view" to render, so the very first `core.view()` call would crash the app on launch if the core panics.
35+
36+**Attack scenario:** Any input that causes a panic inside `App::update` or `Skrepka::view` — an unforeseen edge case, a dependency bug, or an integer overflow in debug mode — would crash the app instantly via `try!` rather than degrading to a stalled UI as the FFI docs claim. On iOS, this is a clean crash with no data corruption, but it's a denial-of-service: the user can't use the app at all.
37+
38+**Exploitable in practice:** Requires triggering a panic in the Rust core. The core code is careful with `unwrap_or` / `unwrap_or_default`, but panics from dependencies (e.g., `serde_json` on malformed input, `zstd` on a crafted frame) or array indexing bugs are possible. A hostile relay crafting specific poll responses could potentially trigger a panic in the decrypt path.
39+
40+**Suggested fix:** Replace `try!` with `try?` or a `do/catch` block that logs the error and skips the deserialization. For `init`, provide a default empty `ViewModel` if `core.view()` returns empty bytes. Alternatively, have `guard` return a sentinel (e.g., a single zero byte) that the Swift side can detect as "panic occurred" and handle gracefully.
41+
42+---
43+
44+### 🟡 F2. `AssertUnwindSafe` is unsound — Model is read after a panic
45+
46+**Type:** FFI boundary / memory safety
47+**Files:** `core/src/ffi.rs:39-44` (`guard` + `AssertUnwindSafe`)
48+
49+The `guard` function uses `AssertUnwindSafe(f)` to bypass the `UnwindSafe` requirement. The doc comment (lines 39-41) justifies this: *"AssertUnwindSafe is sound here because a poisoned Bridge is never read again for correctness: the worst case is a dropped effect batch, and the next event starts a fresh update."*
50+
51+This is incorrect. The `Bridge` holds the `Core`, which holds the `Model`. `App::update` takes `&mut Model` and mutates it in place. If a panic occurs **mid-update** (after some model mutations but before the function returns), the `Model` is left in a partially-mutated, inconsistent state. The `catch_unwind` catches the panic, returns empty bytes, but the `Model` stays corrupted. The **next** `update` call operates on this corrupted model.
52+
53+The comment's claim that "the next event starts a fresh update" is misleading — a fresh update on a corrupted model is not fresh. The model's invariants (e.g., `contacts` is consistent with `messages`, `cursor` is valid, `outbox` items are well-formed) may be violated.
54+
55+**Attack scenario:** A panic mid-update corrupts the model. The next `update` call sees the corrupted state and may:
56+- Write corrupted state to kv (persisting the corruption across launches)
57+- Crash on a subsequent update (if the corruption violates an invariant checked by `update`)
58+- Produce incorrect behavior (e.g., sending messages to the wrong recipient)
59+
60+**Exploitable in practice:** Requires triggering a panic mid-update, which is the same bar as F1. The corruption is silent — the user sees a stalled UI (if F1 doesn't crash first), but the next interaction operates on corrupted state.
61+
62+**Suggested fix:** After a caught panic, reset the `Model` to a known-good state (e.g., re-load from kv) or mark the `Bridge` as poisoned and refuse further operations. Alternatively, snapshot the `Model` before `update` and restore it on panic. This is expensive but correct. At minimum, document that a panic may corrupt the model and recommend the shell re-initialize the core after a caught panic.
63+
64+---
65+
66+### 🔵 F3. `resolve` accepts arbitrary `EffectId` — no validation against pending effects
67+
68+**Type:** FFI boundary / input validation
69+**Files:** `core/src/ffi.rs:70-78` (`resolve`)
70+
71+The `resolve` method accepts any `u32` as an effect ID and any `&[u8]` as the resolution data. The `Bridge::resolve` call would fail with `Err` if the effect ID is unknown, which `guard` converts to an empty Vec. But the empty Vec would crash the Swift side via `try!` (see F1).
72+
73+More importantly, if the Swift side has a bug that resolves the same effect ID twice (e.g., from a race between a timer callback and a `.clear` call), the second resolution would produce effects for a non-existent or already-resolved effect. The `Bridge::resolve` would return `Err`, which becomes empty bytes, which crashes via `try!`.
74+
75+**Attack scenario:** Not directly exploitable by an attacker — the effect IDs are generated by the core and passed to the shell. But a shell bug (e.g., a race condition in timer handling) could trigger a double-resolve, crashing the app.
76+
77+**Suggested fix:** The shell should track which effect IDs are pending and guard against double-resolve. The FFI should return a distinguishable error for "unknown effect ID" vs "serialization failure" so the shell can handle it gracefully.
78+
79+---
80+
81+## 2. Phonemic
82+
83+### 🔵 P1. `to_ob` / `from_ob` round-trip asymmetry for empty input
84+
85+**Type:** Correctness asymmetry
86+**Files:** `core/src/phonemic.rs:98-109` (`to_ob`), `core/src/phonemic.rs:113-132` (`from_ob`)
87+
88+`to_ob(&[])` returns `Some(String::new())` (empty string), but `from_ob("")` returns `None` (because `split('-').filter(|p| !p.is_empty()).collect()` yields an empty Vec, which triggers `parts.is_empty() => None`).
89+
90+This means `to_ob` produces a string that `from_ob` can't decode. The round-trip property is broken for empty input.
91+
92+**Impact:** No caller passes empty bytes to `to_ob` for pubkey purposes (keys are always 32 bytes). The asymmetry is documented in the test `odd_length_input_has_no_spelling` (line 210). Harmless in practice.
93+
94+**Suggested fix:** Either make `to_ob(&[])` return `None` (consistent with odd-length rejection) or make `from_ob("")` return `Some(vec![])` (consistent with the encoding). The former is safer — an empty encoding is meaningless.
95+
96+---
97+
98+### 🔵 P2. `try_parse_pubkey` hex path lowercases redundantly — no bug, but misleading
99+
100+**Type:** Code clarity
101+**Files:** `core/src/phonemic.rs:154`
102+
103+`hex::decode(trimmed.to_lowercase())` — the `hex` crate's `decode` accepts both uppercase and lowercase hex digits. The `to_lowercase()` is redundant for the hex path. It doesn't cause a bug (the output of `valid_pubkey_hex` is always lowercase via `hex::encode`), but it suggests the case matters when it doesn't.
104+
105+**Suggested fix:** Remove the `to_lowercase()` or add a comment explaining it's for consistency, not correctness.
106+
107+---
108+
109+### ⚪ P3. No @p/hex collision for 32-byte pubkeys — confirmed safe
110+
111+**Type:** Correctness verification
112+**Files:** `core/src/phonemic.rs:148-158` (`try_parse_pubkey`)
113+
114+A 64-char hex string has no hyphens, so `from_ob` sees one part of 64 chars, fails the `part.len() != 6` check, returns `None`. The hex path then decodes it. No collision.
115+
116+A valid @p for a 32-byte key is 16 syllables (111 chars with hyphens). `hex::decode` fails on hyphens. No collision.
117+
118+A 6-char hex string (e.g., "abcdef") that happens to be a valid @p syllable: `from_ob` decodes it to 2 bytes, `valid_pubkey_hex` rejects (not 32 bytes). `hex::decode` gives 3 bytes, also rejected. No collision.
119+
120+**Verdict:** Safe. The @p-first ordering in `try_parse_pubkey` is correct.
121+
122+---
123+
124+### ⚪ P4. `from_ob` multibyte handling is correct — confirmed safe
125+
126+**Type:** Correctness verification
127+**Files:** `core/src/phonemic.rs:113-132`
128+
129+`to_lowercase()` is applied before `split('-')`. For non-ASCII input, `to_lowercase()` may change byte lengths, but the `part.len() != 6 || !part.is_ascii()` check on each part catches any non-ASCII characters that survive lowercasing. The test `multibyte_input_is_rejected_not_panicked_on` (line 279) confirms this.
130+
131+The `part[0..3]` and `part[3..6]` slices are safe because `part.len() == 6` and `part.is_ascii()` (ASCII = 1 byte per char, so byte indices == char indices).
132+
133+**Verdict:** Safe. No panic on multibyte input.
134+
135+---
136+
137+## 3. Apple Shell — HTTP Handling
138+
139+### 🟡 A1. `Http.perform` doesn't validate URL scheme — `file://` requests would be honored
140+
141+**Type:** Defense-in-depth gap
142+**Files:** `apple/SkrepkaApp/Effects.swift:104-106`
143+
144+```swift
145+guard let url = URL(string: req.url) else {
146+ return .err(.url(req.url))
147+}
148+var request = URLRequest(url: url)
149+```
150+
151+The shell accepts any URL scheme. `URLSession.data(for:)` handles `file://` URLs by reading local files. The core's `normalize_server_url` only accepts `http`/`https`, but if a bug in the core (or a future change) produces a `file://` or `data:` URL, the shell would honor it without question.
152+
153+**Attack scenario:** Not directly exploitable — the server URL is user-configured and validated by `normalize_server_url`. But if a bug in URL construction (e.g., `format!("{}/poll", model.settings.server_url)` where `server_url` is somehow `file:///etc/passwd`) reaches the shell, a local file could be read and its contents returned to the core as an HTTP response body. The core would try to parse it as JSON, fail, and treat it as an empty page.
154+
155+**Exploitable in practice:** Requires a core bug that produces a non-http URL. `normalize_server_url` prevents this for the server URL, but there's no belt-and-suspenders check in the shell.
156+
157+**Suggested fix:** Add a scheme check in `Http.perform`:
158+```swift
159+guard url.scheme == "http" || url.scheme == "https" else {
160+ return .err(.url(req.url))
161+}
162+```
163+
164+---
165+
166+### 🟡 A2. `HttpSessionDelegate` cancels same-host redirects — may break legitimate HTTPS upgrades
167+
168+**Type:** Protocol conformance / usability
169+**Files:** `apple/SkrepkaApp/Effects.swift:45-53`
170+
171+The redirect delegate cancels **all** redirects, including same-host redirects. A relay that redirects from `http://relay:80` to `https://relay:443` (a legitimate TLS upgrade) would have its redirect cancelled. The client would see the 3xx response and treat it as a failure.
172+
173+The protocol says "plain HTTP, served over TLS in production" (§5), and `normalize_server_url` accepts both `http` and `https`. So a relay that serves HTTP on port 80 and redirects to HTTPS on port 443 would be broken by this client.
174+
175+**Attack scenario:** Not an attack — a usability issue. A relay operator who sets up an HTTP-to-HTTPS redirect would find that Skrepka clients can't connect.
176+
177+**Exploitable in practice:** Depends on relay configuration. The `install.sh` deploys Caddy with TLS, so production relays serve HTTPS directly (no redirect). But a relay misconfigured to redirect would be unreachable.
178+
179+**Suggested fix:** Allow same-host redirects (compare the redirect target's host against the original request's host). Only cancel cross-host redirects, which are the token-exfiltration vector. Or, since the protocol says no redirects, document that relays must not redirect.
180+
181+---
182+
183+### 🔵 A3. `UInt16(clamping:)` silently truncates status codes > 65535
184+
185+**Type:** Input validation
186+**Files:** `apple/SkrepkaApp/Effects.swift:120`
187+
188+```swift
189+status: UInt16(clamping: http?.statusCode ?? 0),
190+```
191+
192+HTTP status codes are 3-digit integers (100-599), so this is fine in practice. But `UInt16(clamping:)` would clamp any value > 65535 to 65535, which is a valid `UInt16` but not a valid HTTP status. The core checks `status >= 200 && status < 300` for success, so 65535 would be treated as failure. No security issue.
193+
194+**Verdict:** Safe. HTTP status codes are bounded by the protocol.
195+
196+---
197+
198+### ⚪ A4. URLSession configuration is correct — confirmed safe
199+
200+**Type:** Implementation review
201+**Files:** `apple/SkrepkaApp/Effects.swift:91-101`
202+
203+The session configuration is:
204+- `ephemeral` — no cookies, no cache, no persistent storage ✓
205+- `httpCookieAcceptPolicy = .never` ✓
206+- `httpShouldSetCookies = false` ✓
207+- `httpCookieStorage = nil` ✓
208+- `urlCache = nil` ✓
209+- `requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData` ✓
210+- `timeoutIntervalForRequest = 70` — matches the server's ~25s long-poll ✓
211+- `tlsMinimumSupportedProtocolVersion = .TLSv12` ✓
212+
213+No sensitive data in logs. The `error.localizedDescription` in the catch block returns user-friendly strings like "The network connection was lost" — no relay-specific info beyond what the user already configured.
214+
215+**Verdict:** Safe. Good defense-in-depth.
216+
217+---
218+
219+### ⚪ A5. Response size limiting is correct — confirmed safe
220+
221+**Type:** Implementation review
222+**Files:** `apple/SkrepkaApp/Effects.swift:31-79`
223+
224+The `HttpSessionDelegate` tracks per-task byte counts in a thread-safe `NSLock`-guarded dictionary. When a task's accumulated body exceeds `maxResponseBytes` (64 MiB), the task is cancelled. The `didCompleteWithError` callback cleans up the dictionary entry. Every task eventually completes (success, failure, or cancel), so no dictionary leak.
225+
226+The 64 MiB cap matches the core's `MAX_POLL_RESPONSE_BYTES` (also 64 MiB). The shell's cap fires before the core's (the shell cancels the task mid-stream, the core never sees the full body), which is the correct ordering — the shell's cap prevents OOM before the core's check runs.
227+
228+**Verdict:** Safe. Well-designed.
229+
230+---
231+
232+## 4. Apple Shell — Keychain & KV Persistence
233+
234+### ⚪ A6. Keychain storage is correct — confirmed safe
235+
236+**Type:** Implementation review
237+**Files:** `apple/SkrepkaApp/Effects.swift:320-415`
238+
239+- `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` — key never rides backup ✓
240+- `SecItemUpdate` first, then `SecItemAdd` if not found — no delete-then-add window ✓
241+- `errSecItemNotFound` is the only status that triggers key generation ✓
242+- 64-byte key length is validated on load ✓
243+- `loadOrCreateIdentity` is called only on the main actor (singleton `Core.shared`), so no race ✓
244+
245+The `store` function's update-then-add sequence has a theoretical race (between `SecItemUpdate` returning `errSecItemNotFound` and `SecItemAdd`, another thread could add the item), but `bootIdentity` is only called on the `@MainActor` singleton, so there's no concurrency.
246+
247+**Verdict:** Safe. Good practice.
248+
249+---
250+
251+### ⚪ A7. KV persistence is correct — confirmed safe
252+
253+**Type:** Implementation review
254+**Files:** `apple/SkrepkaApp/Effects.swift:195-316`
255+
256+- Serial queue — all operations serialized ✓
257+- Atomic writes with `.completeFileProtectionUnlessOpen` ✓
258+- `isExcludedFromBackup = true` — plaintext history not in iCloud backup ✓
259+- Percent-encoding with `.alphanumerics` — injective key→filename mapping, no path traversal ✓
260+- Legacy migration runs before any read (same serial queue) ✓
261+- Missing file = empty key (`.none`), not an error — correct distinction from read failure ✓
262+
263+**Verdict:** Safe. Well-designed.
264+
265+---
266+
267+## 5. Apple Shell — Photo Picker & QR Scanning
268+
269+### 🔵 A8. `PhotoPicker` last-resort quality doesn't check size cap
270+
271+**Type:** Logic bug / UX
272+**Files:** `apple/SkrepkaApp/PhotoPicker.swift:80-81`
273+
274+```swift
275+// Last resort: the smallest quality that still produces something.
276+return image.jpegData(compressionQuality: 0.1)?.base64EncodedString()
277+```
278+
279+The loop (lines 71-79) reduces quality until the base64 fits within 64 KiB (`maxBase64Len`). But the last-resort return on line 81 doesn't check if the result fits. If a 256px JPEG at q0.1 still exceeds 64 KiB (possible for high-frequency noise images), the core's `SaveProfile` rejects it with "photo too large" and the user has no recourse.
280+
281+**Attack scenario:** A user picks a photo that, even at 256px and q0.1, produces > 64 KiB of base64. The profile save fails with no way to proceed. Not a security issue — a UX dead end.
282+
283+**Exploitable in practice:** Very unlikely for real photos (256px JPEG at q0.1 is typically 5-15 KiB). But a synthetic image (e.g., pure noise) could exceed the cap.
284+
285+**Suggested fix:** If the last-resort still exceeds the cap, return `nil` (triggering `onCancel`) or downscale further (e.g., 128px). Or document that the user should pick a different photo.
286+
287+---
288+
289+### 🔵 A9. `scannedKeyPayload` is business logic in the shell — portability concern
290+
291+**Type:** Protocol conformance / architecture
292+**Files:** `apple/SkrepkaApp/QRView.swift:24-35`
293+
294+`scannedKeyPayload` parses QR code content: strips URL schemes (`skrepka://`, `https://`, `http://`), extracts the last path component, and trims whitespace. This is parsing logic that determines what input the core receives.
295+
296+`AGENTS.md` says: *"The shell never decides anything — it turns Effects into platform calls and resolves the results back in."* This function decides how to interpret a QR code, which is arguably a business decision.
297+
298+An Android client would need to reimplement this logic. If the parsing differs (e.g., one client strips `skrepka://` and another doesn't), the same QR code would produce different inputs to the core.
299+
300+**Attack scenario:** Not a security vulnerability — the core's `try_parse_pubkey` validates the result regardless. But a portability bug: a QR code with a `skrepka://` prefix would work on iOS (stripped by `scannedKeyPayload`) but fail on an Android client that doesn't strip the scheme.
301+
302+**Suggested fix:** Move `scannedKeyPayload` into the core (e.g., `try_parse_pubkey_from_qr` or add scheme-stripping to `try_parse_pubkey`). The shell should pass the raw QR content to the core.
303+
304+---
305+
306+### 🔵 A10. `QRScannerView` doesn't check camera permission
307+
308+**Type:** UX gap
309+**Files:** `apple/SkrepkaApp/QRView.swift:75-80`
310+
311+`ScannerVC.viewDidLoad` calls `AVCaptureDevice.default(for: .video)`, which returns `nil` if camera permission hasn't been granted (or has been denied). The `guard` fails silently, and the scanner shows a black screen with no error message or permission prompt.
312+
313+**Attack scenario:** Not a security issue. A user who denied camera permission can't scan QR codes and sees a black screen with no explanation.
314+
315+**Suggested fix:** Check `AVCaptureDevice.authorizationStatus(for: .video)` and request permission if not determined. Show an error message if denied.
316+
317+---
318+
319+### ⚪ A11. `QRScannerView` camera lifecycle is correct — confirmed safe
320+
321+**Type:** Implementation review
322+**Files:** `apple/SkrepkaApp/QRView.swift:101-114`
323+
324+`startRunning()` and `stopRunning()` are dispatched to a background queue (`DispatchQueue.global(qos: .userInitiated)`) to avoid blocking the main thread. The `done` flag in `Coordinator` prevents multiple `onScan` callbacks. The camera session is properly stopped in `viewWillDisappear`.
325+
326+**Verdict:** Safe.
327+
328+---
329+
330+## 6. Apple Shell — Logic Leakage & Protocol Conformance
331+
332+### 🟡 A12. `BackgroundRefresh` polls `core.view` in a tight loop — main actor contention
333+
334+**Type:** Performance / architecture
335+**Files:** `apple/SkrepkaApp/SkrepkaApp.swift:156-163`
336+
337+The `settle` function polls `core.view` every 200ms, reading the `@Published` `ViewModel` on the main actor. While the overhead per poll is minimal (reading a published property), this runs for up to 25 seconds during a background refresh. If the core is processing a large poll response (which also runs on the main actor via `resolve`), the 200ms polls interleave with core processing.
338+
339+More importantly, `core.view` is a `@Published` property backed by `ViewModel`, which is a value type. Each read copies the entire `ViewModel` (including `contacts: Vec<ContactVM>` and `messages: Vec<MessageVM>`). For a user with 500 contacts and 1000 messages, each poll copies ~1 MB of data. Over 25 seconds at 200ms intervals, that's 125 copies = ~125 MB of transient allocations.
340+
341+**Attack scenario:** Not an attack — a performance issue. A user with many contacts and active conversations would experience increased memory pressure during background refresh.
342+
343+**Suggested fix:** Use a Combine subscription (`core.$view.sink`) instead of polling, or have the core expose a lightweight "status changed" flag that the shell can check without copying the full `ViewModel`.
344+
345+---
346+
347+### 🔵 A13. `Core.swift` `dispatch` silently drops effects if `try!` crashes — no recovery
348+
349+**Type:** Error handling
350+**Files:** `apple/SkrepkaApp/Core.swift:47-53`
351+
352+```swift
353+private func dispatch(_ effects: [UInt8]) {
354+ // swiftlint:disable:next force_try
355+ let requests = try! Requests.bincodeDeserialize(input: effects).value
356+ for request in requests {
357+ process(request)
358+ }
359+}
360+```
361+
362+If `effects` is valid bincode but contains an effect variant the Swift side doesn't know about (e.g., after a core update that adds a new effect without regenerating Swift types), `try!` would crash. The AGENTS.md warns about this: *"Touching Event, ViewModel, or Effect without running just generate leaves the Swift side compiling against stale types."*
363+
364+But there's no runtime recovery. The crash is immediate and unrecoverable. A safer approach would be to catch the deserialization error, log it, and skip the unknown effect — the core would see the effect as unresolved and its watchdog would handle it.
365+
366+**Suggested fix:** Replace `try!` with `try?` and log the error. Unknown effects are dropped; the core's watchdog handles the missing resolution.
367+
368+---
369+
370+### ⚪ A14. `Time.handle` timer lifecycle is correct — confirmed safe
371+
372+**Type:** Implementation review
373+**Files:** `apple/SkrepkaApp/Effects.swift:147-191`
374+
375+The `timers` dictionary is on the `@MainActor`. The `schedule` function removes the old work item before inserting a new one. The `DispatchWorkItem`'s callback runs on the main queue (via `MainActor.assumeIsolated`), so there's no interleaving between `schedule` and the callback. The `.clear` case removes and cancels the work item.
376+
377+No race condition: all operations are on the main actor / main queue, which is single-threaded.
378+
379+**Verdict:** Safe.
380+
381+---
382+
383+### ⚪ A15. `SkrepkaApp.swift` background refresh is correct — confirmed safe
384+
385+**Type:** Implementation review
386+**Files:** `apple/SkrepkaApp/SkrepkaApp.swift:64-164`
387+
388+- `isProtectedDataAvailable` guard prevents running while locked ✓
389+- `settle` polling respects `Task.isCancelled` and deadline ✓
390+- `setTaskCompleted(success: !Task.isCancelled)` correctly reports failure on cancellation ✓
391+- `schedule()` is called before `run()` — next refresh is always queued ✓
392+- `Core.shared` is only accessed after the `isProtectedDataAvailable` guard ✓
393+
394+**Verdict:** Safe. Well-designed.
395+
396+---
397+
398+## 7. Protocol & AGENTS.md Conformance
399+
400+### 🔵 A16. AGENTS.md says "five kv loads" but core does six — `seen_ids` added
401+
402+**Type:** Documentation drift
403+**Files:** `AGENTS.md:98` ("fans out five kv loads"), `core/src/app.rs:719-726` (six loads)
404+
405+AGENTS.md line 98 says: *"IdentityLoaded fans out five kv loads → LoadedSettings triggers Connect"*. But the core actually fans out **six** kv loads: `settings`, `profile`, `contacts`, `cursor`, `outbox`, and `seen_ids` (line 725: `KeyValue::get(K_SEEN_IDS)`). The `STARTUP_LOADS` constant is 6 (line 312).
406+
407+**Impact:** Documentation drift, not a bug. A developer reading AGENTS.md would expect five loads and be confused by the sixth.
408+
409+**Suggested fix:** Update AGENTS.md to say "six kv loads" and include `seen_ids` in the list.
410+
411+---
412+
413+### 🔵 A17. AGENTS.md lists kv keys without `seen_ids` — stale documentation
414+
415+**Type:** Documentation drift
416+**Files:** `AGENTS.md:14` ("keys `settings`, `profile`, `contacts`, `cursor`, `outbox`, and `messages:<peer_hex>`")
417+
418+The `seen_ids` key is not listed in AGENTS.md's kv key list. It was added as part of the dedup fix (AUDIT-NEW.md Q18) but the documentation wasn't updated.
419+
420+**Suggested fix:** Add `seen_ids` to the kv key list in AGENTS.md.
421+
422+---
423+
424+## Summary of New Findings
425+
426+| # | Severity | Type | Title |
427+|---|----------|------|-------|
428+| F1 | 🟠 high | FFI/panic | `try!` in Core.swift crashes on empty bytes from `guard` — FFI panic safety claim is false |
429+| F2 | 🟡 medium | FFI/memory | `AssertUnwindSafe` is unsound — Model is read after a panic, corruption persists |
430+| F3 | 🔵 low | FFI/input | `resolve` accepts arbitrary EffectId — double-resolve crashes via `try!` |
431+| P1 | 🔵 low | Correctness | `to_ob`/`from_ob` round-trip asymmetry for empty input |
432+| P2 | 🔵 low | Clarity | `try_parse_pubkey` hex path lowercases redundantly |
433+| P3 | ⚪ info | Safe | No @p/hex collision for 32-byte pubkeys — confirmed |
434+| P4 | ⚪ info | Safe | `from_ob` multibyte handling is correct — confirmed |
435+| A1 | 🟡 medium | HTTP/defense | `Http.perform` doesn't validate URL scheme — `file://` would be honored |
436+| A2 | 🟡 medium | HTTP/protocol | Redirect delegate cancels same-host redirects — breaks HTTPS upgrades |
437+| A3 | 🔵 low | HTTP/input | `UInt16(clamping:)` silently truncates status codes > 65535 |
438+| A4 | ⚪ info | Safe | URLSession configuration is correct |
439+| A5 | ⚪ info | Safe | Response size limiting is correct |
440+| A6 | ⚪ info | Safe | Keychain storage is correct |
441+| A7 | ⚪ info | Safe | KV persistence is correct |
442+| A8 | 🔵 low | Photo/UX | `PhotoPicker` last-resort quality doesn't check size cap |
443+| A9 | 🔵 low | Architecture | `scannedKeyPayload` is business logic in the shell — portability concern |
444+| A10 | 🔵 low | QR/UX | `QRScannerView` doesn't check camera permission |
445+| A11 | ⚪ info | Safe | QR scanner camera lifecycle is correct |
446+| A12 | 🟡 medium | Performance | `BackgroundRefresh` polls `core.view` — copies full ViewModel every 200ms |
447+| A13 | 🔵 low | Error handling | `Core.swift` `dispatch` crashes on unknown effect — no recovery |
448+| A14 | ⚪ info | Safe | Timer lifecycle is correct |
449+| A15 | ⚪ info | Safe | Background refresh is correct |
450+| A16 | 🔵 low | Docs | AGENTS.md says "five kv loads" but core does six |
451+| A17 | 🔵 low | Docs | AGENTS.md kv key list missing `seen_ids` |
452+
453+**High (1):** F1
454+**Medium (4):** F2, A1, A2, A12
455+**Low (8):** F3, P1, P2, A3, A8, A9, A10, A13, A16, A17
456+**Info (8):** P3, P4, A4, A5, A6, A7, A11, A14, A15
457+
458+---
459+
460+## Priorities
461+
462+1. **F1 (high):** Replace `try!` in Core.swift with graceful error handling. The FFI's panic safety design is undermined by `try!` — a caught panic crashes the app instead of degrading. This is the most impactful finding because it turns every potential core panic into an immediate app crash.
463+
464+2. **F2 (medium):** Address `AssertUnwindSafe` soundness. After a caught panic, the Model may be corrupted. At minimum, document the risk and consider resetting the Model from kv on panic recovery.
465+
466+3. **A1 (medium):** Add URL scheme validation in `Http.perform`. Defense-in-depth against a core bug producing non-http URLs.
467+
468+4. **A2 (medium):** Allow same-host redirects or document that relays must not redirect. The current all-redirects-cancelled policy breaks legitimate HTTPS upgrades.
469+
470+5. **A12 (medium):** Replace `settle`'s polling with a Combine subscription to avoid copying the full ViewModel every 200ms during background refresh.
PROTOCOL.md+13−8
@@ -118,12 +118,12 @@ The wire-visible "envelope" carries only the recipient and the opaque blob. The
118118 ```
119119 compressed = zstd(plaintext_bytes) // standard zstd frame (embeds content size)
120120 ```
121−5. Sign the recipient pubkey concatenated with the compressed plaintext and padding. Binding the signature to the recipient prevents the same blob from being replayed against another recipient:
121+5. Sign a domain-separation tag, the recipient pubkey, and the compressed plaintext with padding. The `"skrepka-msg-v2:"` prefix is a domain-separation tag distinguishing message signatures from auth-challenge signatures (which begin with `"skrepka-auth-v1:"`, §6), so a signature produced in one context can never validate in the other. Binding the signature to the recipient prevents the same blob from being replayed against another recipient:
122122 ```
123123 compressed_len = uint32_be(len(compressed))
124124 padding = random_bytes(target_blob_size - unpadded_blob_size)
125125 signature = ed25519_sign(sender_ed25519_private,
126− recipient_ed25519_public || compressed_len || compressed || padding)
126+ "skrepka-msg-v2:" || recipient_ed25519_public || compressed_len || compressed || padding)
127127 ```
1281286. Build the inner buffer that will be encrypted:
129129 ```
@@ -156,7 +156,7 @@ The wire-visible "envelope" carries only the recipient and the opaque blob. The
1561562. Derive the recipient's X25519 private key from the recipient's Ed25519 private key, compute the raw shared secret with `ephemeral_public`, and HKDF-derive the same `key`.
1571573. Reconstruct the AAD: `version_byte || ephemeral_public || nonce || recipient_ed25519_public`. AEAD-decrypt the ciphertext with this AAD to recover the inner buffer.
1581584. Split: 32-byte `sender_ed25519_public`, 64-byte `signature`, 4-byte `compressed_len` (big-endian u32), then `compressed_len` bytes of `compressed` (the remainder is padding).
159−5. Verify the signature over `recipient_ed25519_public || inner[96..]` (i.e. `compressed_len || compressed || padding`) using `sender_ed25519_public`. Reject the message on failure.
159+5. Verify the signature over `"skrepka-msg-v2:" || recipient_ed25519_public || inner[96..]` (i.e. the domain-separation tag followed by `compressed_len || compressed || padding`) using `sender_ed25519_public`. Reject the message on failure.
1601606. zstd-decompress the `compressed` bytes (only the `compressed_len`-length slice, not the trailing padding) to recover the plaintext payload.
161161
162162### Encrypted Blob Format
@@ -164,7 +164,7 @@ The wire-visible "envelope" carries only the recipient and the opaque blob. The
164164On the wire (cleartext, server-visible):
165165
166166```
167−version (1 byte, currently 0x01)
167+version (1 byte, currently 0x02)
168168ephemeral_x25519_pubkey (32 bytes)
169169nonce (24 bytes)
170170ciphertext (variable, includes 16-byte AEAD tag)
@@ -194,11 +194,11 @@ The bucket boundaries are:
194194
195195A blob is rounded up to the smallest bucket ≥ its unpadded size. Above 65536 bytes, the blob is rounded up to the next multiple of 65536. The padding bytes are randomly generated per message, so two encryptions of the same plaintext produce the same blob size (same bucket) but different blob contents. The on-wire blob is therefore `173 + len(compressed) + len(padding)` bytes, and that padded size is all a relay or network observer sees.
196196
197−> **Compression before encryption leaks plaintext structure.** The payload is zstd-compressed *then* encrypted (steps 4–7), so the ciphertext length is a function of how *compressible* the plaintext was, not just how long it was. XChaCha20-Poly1305 is a stream cipher with no padding: without length padding the blob would be exactly `173 + len(compressed)` bytes, and that number would be visible to every relay and network observer. Version 0x01 adds length padding (see *Length Padding* above), which buckets blob sizes to fixed boundaries so the exact compressed size is hidden.
197+> **Compression before encryption leaks plaintext structure.** The payload is zstd-compressed *then* encrypted (steps 4–7), so the ciphertext length is a function of how *compressible* the plaintext was, not just how long it was. XChaCha20-Poly1305 is a stream cipher with no padding: without length padding the blob would be exactly `173 + len(compressed)` bytes, and that number would be visible to every relay and network observer. Version 0x02 adds length padding (see *Length Padding* above), which buckets blob sizes to fixed boundaries so the exact compressed size is hidden.
198198>
199199> Two consequences. First, message length is only ever obscured *upward*: a long but repetitive message can produce a smaller blob than a short random-looking one, and blob size still distinguishes a one-word reply from a pasted document. Second — the more serious one — where an attacker can influence part of the plaintext that is compressed together with a secret, the compressed size reveals whether the two share structure. That is the CRIME/BREACH pattern, and it is a real (if narrow) concern for a format that compresses attacker-influenceable text alongside sensitive text in one frame.
200200>
201−> Skrepka accepts the remaining risk: payloads are small, each message is compressed independently (no cross-message compression context), and there is no adaptive-guessing oracle in the protocol — an attacker cannot make the client re-encrypt a chosen variant of a secret on demand. Implementations must **not** add one (e.g. by compressing several messages, or attacker-supplied and user-supplied text, into a shared frame). Length padding (version 0x01) obscures the exact compressed size by bucketing blob lengths, but does not eliminate the CRIME/BREACH side channel within a single bucket — it only makes the granularity coarser.
201+> Skrepka accepts the remaining risk: payloads are small, each message is compressed independently (no cross-message compression context), and there is no adaptive-guessing oracle in the protocol — an attacker cannot make the client re-encrypt a chosen variant of a secret on demand. Implementations must **not** add one (e.g. by compressing several messages, or attacker-supplied and user-supplied text, into a shared frame). Length padding (version 0x02) obscures the exact compressed size by bucketing blob lengths, but does not eliminate the CRIME/BREACH side channel within a single bucket — it only makes the granularity coarser.
202202
203203### Cryptographic Versioning
204204
@@ -378,7 +378,7 @@ When a client connects, it proves ownership of its keypair:
378378 ```
379379 The challenge is a lowercase hex string of at least 32 chars (128 bits of entropy). The reference server uses 48 hex chars (192 bits). The challenge MUST be unique per challenge request and consumed on first use (verified).
380380
381−3. Client signs the **UTF-8 bytes of `"skrepka-auth-v1:" + server_host + ":" + challenge`** with its Ed25519 private key and submits. `server_host` is the bare lowercased hostname of the server the client dialed (no scheme, no port, no trailing dot). The `skrepka-auth-v1:` prefix is a domain-separation tag distinguishing auth signatures from message signatures (which sign `recipientPub ++ compressed`, §4):
381+3. Client signs the **UTF-8 bytes of `"skrepka-auth-v1:" + server_host + ":" + challenge`** with its Ed25519 private key and submits. `server_host` is the bare lowercased hostname of the server the client dialed (no scheme, no port, no trailing dot). For IPv6 literals, the host must include square brackets (e.g. `[::1]`), matching the URL host syntax — the server's `serverHost` configuration must also include brackets for IPv6. The `skrepka-auth-v1:` prefix is a domain-separation tag distinguishing auth signatures from message signatures (which begin with `"skrepka-msg-v2:"` and sign `recipientPub ++ compressed`, §3):
382382 ```
383383 POST /auth/verify
384384 {
@@ -458,6 +458,7 @@ The client makes a blocking POST. The server holds the connection open until new
458458```
459459
460460- `cursor` is a server-side **strictly-monotonic sequence**, seeded from the wall-clock millisecond at assignment but bumped to `previous + 1` whenever messages arrive within the same millisecond. Two messages therefore never share a `receivedAt`, so advancing the cursor past one message can never silently drop another. Treat it as an opaque checkpoint, not a clock.
461+- The server clamps the supplied cursor to `max(0, min(max(t, curSeq), cursor))`, where `t` is the current wall-clock millisecond and `curSeq` is the last assigned sequence — note the ceiling is `max(t, curSeq)`, not just `curSeq`, so under idle the clamp allows acking up to the current wall-clock, while under sustained bursts (where the sequence has run ahead of the clock) it still refuses to ack beyond what has actually been issued.
461462- Advancing past a message acts as an **implicit ack**: when the next poll arrives with a `cursor` greater than or equal to a stored message's `receivedAt`, the server drops the stored message **and any pending federation forwards for it**.
462463- If events are available immediately, the server responds right away. Otherwise it parks the connection on a row-level wait against the mailbox and returns when a new matching message lands or after 25 s, whichever comes first.
463464- An empty page (`events: []`) is returned on timeout, with the cursor echoed back.
@@ -688,7 +689,9 @@ Skrepka protects message **content and sender identity** from servers and networ
688689| **Presence gossip leaks location** | Federated peers learn which server a user is currently connected to. A presence row is anchored for up to 90 min (`onlineGossipTtl`), which outlives the 1 h session, so location metadata — and forwards aimed at the user — can linger for up to ~90 min after the user disconnects. |
689690| **No durable delivery** | If the sender's server goes down or all forward retries fail, messages may be lost. |
690691| **Mailbox eviction under flood (DoS)** | The per-recipient mailbox cap (`maxMessagesPerRecipient`, default 10 000) evicts the oldest messages when the cap is reached. An authenticated attacker (pubkeys are free to mint) can flood a victim's mailbox with junk, pushing out genuine undelivered messages. The victim's client only sees junk; the genuine messages are permanently destroyed. At 60 requests/min × 100 messages = 6 000/min, the attacker fills the cap in ~2 minutes (per IP; multiple IPs are faster). This is an accepted design tradeoff of v0.1 — a real fix requires per-sender quotas or pricing. |
692+| **Unbounded mailbox storage** | The per-recipient mailbox cap (`maxMessagesPerRecipient`) bounds each recipient but not the count of distinct recipient keys. An attacker can fill the server's disk by sending messages to many distinct invalid pubkeys, bounded only by the 30-day TTL and rate limits. The reference server now enforces a global `maxMessagesTotal` cap (default 1 000 000) that evicts the globally-oldest rows on overflow, symmetric with the other state refs. |
691693| **CPU amplification via dedup scan** | `appendMessage` performs an O(n) full-scan dedup check on every message (§6). Under a burst of 100-message batches, each message triggers a scan of the entire mailbox. With large mailboxes this is quadratic and amplifies CPU cost per request. Bounded by rate limits (60/min per IP) and the fact that the attacker must be authenticated, but the amplification is real. |
694+| **Clock forward-jump pins the sequence counter** | The `receivedAt` sequence counter is a forward-only ratchet: `max(wall_clock, previous + 1)`. A single NTP forward-jump (server migration, misconfiguration) pins the counter at the jumped value, delaying all TTL pruning until real time catches up. Backward jumps are safe (the counter dominates). |
692695| **Challenge pool floodable (soft DoS)** | The challenge pool is globally capped at `maxChallenges` (default 10 000) with per-IP rate limiting. An attacker with many IPs — or behind a proxy with XFF spoofing when `trustForwardedFor = true` — can fill the pool with challenges for random pubkeys, causing legitimate challenges to be LRU-evicted. Authentication still works but with extra latency (clients retry). This is a soft DoS. |
693696| **Rate limit counts requests, not messages** | The per-IP send rate limit (60/min) counts requests, and each request can carry up to 100 messages. The effective message rate is therefore 60 × 100 = 6 000 messages/min per IP (bounded by the body-size cap), much higher than the per-request limit suggests. An attacker with multiple IPs can send millions of messages per minute. |
694697| **Session pool Sybil-floodable** | The global session pool (`maxSessions`, default 10 000) uses LRU eviction. An attacker with enough IPs can generate thousands of keypairs, authenticate each one, and fill the pool — evicting legitimate users' sessions. Users re-authenticate automatically (they get `401` and retry), so this is a soft DoS adding latency and load. Per-IP session caps were considered and rejected because they collapse behind carrier-grade NAT. |
@@ -698,7 +701,7 @@ Skrepka protects message **content and sender identity** from servers and networ
698701| **No identity revocation** | There is no in-protocol mechanism to revoke or invalidate a compromised identity key. A stolen key allows full takeover: decrypt all captured messages, send messages as the user, poll their mailbox, and receive their messages via presence redirect. The only defense is out-of-band communication. See §11 (Future Extensions — Key Rotation) for the intended direction. |
699702| **Unauthenticated session relay** (mitigated) | The auth signature is bound to the target server's hostname under a domain-separation tag (§6), so a malicious/relay server can no longer forward another server's challenge and replay the resulting signature to impersonate the client elsewhere. Residual caveat: the binding is to the hostname the client dialed, so it assumes the client reaches each server under its true `serverHost` (DNS/TLS integrity). |
700703| **Same-recipient replay** | Nothing stops a captured blob from being re-delivered to its original recipient. The damage is bounded per payload type: `text` is deduplicated by `id` (the reference client maintains a persistent `seen_ids` set that survives `trim_history`), `delivery.ack` is idempotent, and `profile` is guarded by the per-contact `ts` staleness check of §4. |
701−| **Open federation — gossip redirect** | `/federation/gossip` is unauthenticated; an attacker announces the victim online at a host it controls and origin relays forward the victim's queued ciphertext there. See *Open federation* below. |
704+| **Open federation — gossip redirect** | `/federation/gossip` is unauthenticated; an attacker announces the victim online at a host it controls and origin relays forward the victim's queued ciphertext there. **Active users are also affected**: even when the recipient is locally online and actively polling, a forged remote presence row causes each new incoming message to be duplicated to the attacker's server — the victim receives everything normally but the attacker silently collects ciphertext metadata for the live inbound stream. See *Open federation* below. |
702705| **Open federation — forward injection** | `/federation/forward` is unauthenticated; any host can inject arbitrary blobs into a currently-online recipient's mailbox. See *Open federation* below. |
703706| **Bearer token theft = full impersonation** | Sessions are bearer-token-based: anyone who obtains the token can use it. The token is bound to `(pubkey, ip, expiry)`, but the `pubkey` binding is for authorization (self-send rejection), not for authenticating the request. If an attacker steals a bearer token (e.g., from a compromised proxy's access log), they can poll the victim's mailbox and send messages as the victim — modulo IP binding. Token expiry (1 hour) is the only automatic recovery. Consider shorter token lifetimes or per-request signing. |
704707| **Token entropy depends on runtime CSPRNG** | Session and challenge tokens are generated by the server's runtime random source. Implementations SHOULD verify that their RNG is cryptographically secure (e.g., backed by `/dev/urandom` or an OS-provided CSPRNG). |
@@ -721,6 +724,8 @@ to every relay it can reach. Each relay holding mail for the victim now forwards
721724
722725**2. Forward injection — spam into an online recipient's mailbox.** `/federation/forward` is unauthenticated, so any host can inject arbitrary blobs into the mailbox of any recipient who is currently locally online. Injected junk is undecryptable, so a client drops it silently on ingest — but it occupies the recipient's poll stream and the relay's mailbox until the cursor advances past it. The only bounds are the per-source-IP rate limit (`forwardLimit`), the SSRF deny-list (which constrains where a relay will *send*, not who may call it), and the `no_presence` gate (which limits injection to online recipients).
723726
727+**3. Forward cancellation — deleting queued mail-in-flight.** The offline-event forward cancellation (§7) **widens** the unauthenticated-gossip blast radius from writing presence to **deleting queued forwards** — an attacker claiming `fromServer: X` can cancel pending forwards to any real relay X by announcing an `offline` event for the relevant key, so mail queued for delivery to X is dropped from the origin's outbox (the message itself stays in `*messages` and is re-forwarded if the key reappears on another peer, so this delays rather than destroys delivery).
728+
724729**Mitigation path.** Both attacks are accepted limitations of v0.1's unauthenticated federation. The intended fix is peer authentication — signed presence gossip and authenticated forward requests — or an explicit peer allow-list gate before `forwardQueuedToServer`. Until then, operators who federate only with trusted peers (or not at all) are unaffected.
725730
726731### Recommendations for Implementers
apple/SkrepkaApp/Core.swift+13−15
@@ -26,16 +26,17 @@ final class Core: ObservableObject {
2626 private let store = KvStore()
2727
2828 private init() {
29− // swiftlint:disable:next force_try
30− self.view = try! .bincodeDeserialize(input: [UInt8](core.view()))
29+ // The FFI's guard returns empty bytes when the core panics; force-try
30+ // on empty bytes crashes the app. Fall back to a default ViewModel instead.
31+ self.view = (try? ViewModel.bincodeDeserialize(input: [UInt8](core.view()))) ?? ViewModel()
3132 bootIdentity()
3233 }
3334
3435 // MARK: - Event/effect loop
3536
3637 func update(_ event: Event) {
37− // swiftlint:disable:next force_try
38− let effects = [UInt8](core.update(data: Data(try! event.bincodeSerialize())))
38+ guard let eventData = try? event.bincodeSerialize() else { return }
39+ let effects = [UInt8](core.update(data: Data(eventData)))
3940 dispatch(effects)
4041 }
4142
@@ -45,8 +46,7 @@ final class Core: ObservableObject {
4546 }
4647
4748 private func dispatch(_ effects: [UInt8]) {
48− // swiftlint:disable:next force_try
49− let requests = try! Requests.bincodeDeserialize(input: effects).value
49+ guard let requests = try? Requests.bincodeDeserialize(input: effects).value else { return }
5050 for request in requests {
5151 process(request)
5252 }
@@ -56,29 +56,27 @@ final class Core: ObservableObject {
5656 let id = request.id
5757 switch request.effect {
5858 case .render:
59− // swiftlint:disable:next force_try
60− self.view = try! .bincodeDeserialize(input: [UInt8](core.view()))
59+ self.view = (try? ViewModel.bincodeDeserialize(input: [UInt8](core.view()))) ?? self.view
6160
6261 case .http(let req):
6362 Task { [weak self] in
6463 let result = await Http.perform(req)
65− // swiftlint:disable:next force_try
66− self?.resolve(id, try! result.bincodeSerialize())
64+ guard let output = try? result.bincodeSerialize() else { return }
65+ self?.resolve(id, output)
6766 }
6867
6968 case .keyValue(let op):
7069 // The file I/O runs on the store's own serial queue; hop the result back to
7170 // the main actor, because the core is single-threaded and `.render` publishes.
7271 store.handle(op) { [weak self] result in
73− // swiftlint:disable:next force_try
74− let output = try! result.bincodeSerialize()
72+ guard let output = try? result.bincodeSerialize() else { return }
7573 Task { @MainActor in self?.resolve(id, output) }
7674 }
7775
7876 case .time(let req):
7977 Time.handle(req) { [weak self] response in
80− // swiftlint:disable:next force_try
81− self?.resolve(id, try! response.bincodeSerialize())
78+ guard let output = try? response.bincodeSerialize() else { return }
79+ self?.resolve(id, output)
8280 }
8381 }
8482 }
@@ -97,4 +95,4 @@ final class Core: ObservableObject {
9795 identityError = error.message
9896 }
9997 }
100−}
98+}
apple/SkrepkaApp/Effects.swift+80−14
@@ -5,19 +5,44 @@ import Skrepka
55
66// MARK: - HTTP
77
8−/// Cancels every HTTP redirect instead of following it.
8+/// Hard cap on any HTTP response body we will buffer. A hostile relay
9+/// can answer any endpoint with a multi-gigabyte body; without this cap
10+/// `URLSession.data(for:)` would OOM the app before the core's own
11+/// `MAX_POLL_RESPONSE_BYTES` check (which runs on the wrong side of the
12+/// FFI boundary to prevent the allocation) ever fires.
13+private let maxResponseBytes = 64 * 1024 * 1024 // 64 MiB
14+
15+/// The one session delegate, handling two defenses against an untrusted relay.
916///
10−/// URLSession replays the original request's headers onto a redirect target, including
11−/// `Authorization: Bearer <session_token>`. A relay is untrusted infrastructure: a hostile
12−/// (or compromised) one could answer `/poll` or `/messages` with a 302 to a host it chooses
17+/// **Redirect cancellation.** URLSession replays the original request's headers onto a
18+/// redirect target, including `Authorization: Bearer <session_token>`. A hostile (or
19+/// compromised) relay could answer `/poll` or `/messages` with a 302 to a host it chooses
1320/// and harvest the bearer token from the request we would dutifully re-send there. Sessions
1421/// are bound to a single relay by design (§6), so a cross-host redirect is never legitimate
15−/// — refusing it costs nothing and closes the token-exfiltration path.
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.
1627///
17−/// Returning `nil` from the completion handler does not error the task: it stops the
18−/// redirect and hands the 3xx response itself back to the caller, which the core sees as a
19−/// non-2xx status and treats as a failed request.
20−private final class NoRedirectDelegate: NSObject, URLSessionTaskDelegate {
28+/// **Response size limiting.** As body chunks arrive we accumulate the byte count per task
29+/// and cancel the task the moment it crosses `maxResponseBytes`, so a multi-gigabyte body
30+/// never fully materializes in memory. Cancelling surfaces to the caller as a `URLError`
31+/// with code `.cancelled`, which `Http.perform` maps to a transport error.
32+private final class HttpSessionDelegate: NSObject, URLSessionTaskDelegate, URLSessionDataDelegate {
33+ let maxResponseBytes: Int
34+
35+ /// Per-task accumulated byte counts. The delegate is shared across every concurrent
36+ /// request, so a single shared counter would conflate their bodies — key by task instead.
37+ /// Guarded by `lock` because delegate callbacks arrive on the session's own (non-main)
38+ /// queue and may interleave across tasks.
39+ private var taskSizes: [URLSessionTask: Int] = [:]
40+ private let lock = NSLock()
41+
42+ init(maxResponseBytes: Int) {
43+ self.maxResponseBytes = maxResponseBytes
44+ }
45+
2146 func urlSession(
2247 _ session: URLSession,
2348 task: URLSessionTask,
@@ -25,20 +50,55 @@ private final class NoRedirectDelegate: NSObject, URLSessionTaskDelegate {
2550 newRequest request: URLRequest,
2651 completionHandler: @escaping (URLRequest?) -> Void
2752 ) {
28− completionHandler(nil)
53+ // Allow same-host redirects (e.g. HTTP → HTTPS upgrade on the same host).
54+ // Cancel cross-host redirects — a hostile relay could redirect to an
55+ // attacker-controlled host and harvest the Authorization header.
56+ if let originalURL = task.currentRequest?.url,
57+ let redirectHost = request.url?.host?.lowercased(),
58+ let originalHost = originalURL.host?.lowercased(),
59+ redirectHost == originalHost {
60+ completionHandler(request)
61+ } else {
62+ completionHandler(nil)
63+ }
64+ }
65+
66+ func urlSession(
67+ _ session: URLSession,
68+ dataTask: URLSessionDataTask,
69+ didReceive data: Data
70+ ) {
71+ lock.lock()
72+ let total = (taskSizes[dataTask] ?? 0) + data.count
73+ taskSizes[dataTask] = total
74+ let exceeded = total > maxResponseBytes
75+ lock.unlock()
76+ if exceeded {
77+ dataTask.cancel()
78+ }
79+ }
80+
81+ func urlSession(
82+ _ session: URLSession,
83+ task: URLSessionTask,
84+ didCompleteWithError error: Error?
85+ ) {
86+ lock.lock()
87+ taskSizes.removeValue(forKey: task)
88+ lock.unlock()
2989 }
3090}
3191
3292enum Http {
3393 /// Strongly held by `session` for the app's lifetime — the session is never invalidated,
3494 /// so the delegate is never released. That is the intended shape here, not a leak.
35− private static let redirectDelegate = NoRedirectDelegate()
95+ private static let httpDelegate = HttpSessionDelegate(maxResponseBytes: maxResponseBytes)
3696
3797 /// A private, ephemeral session. `URLSession.shared` would hand every relay a
3898 /// persistent cookie jar and an on-disk response cache — a relay is untrusted
3999 /// infrastructure and must not be able to pin a stable identifier on the client
40100 /// or leave polled ciphertext lying around on disk. It would also follow redirects,
41− /// which is what `NoRedirectDelegate` is here to prevent.
101+ /// which is what `HttpSessionDelegate` is here to prevent.
42102 private static let session: URLSession = {
43103 let config = URLSessionConfiguration.ephemeral
44104 config.httpCookieAcceptPolicy = .never
@@ -48,11 +108,13 @@ enum Http {
48108 config.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
49109 config.timeoutIntervalForRequest = 70 // server long-polls for ~25s
50110 config.tlsMinimumSupportedProtocolVersion = .TLSv12
51− return URLSession(configuration: config, delegate: redirectDelegate, delegateQueue: nil)
111+ return URLSession(configuration: config, delegate: httpDelegate, delegateQueue: nil)
52112 }()
53113
54114 static func perform(_ req: HttpRequest) async -> HttpResult {
55− guard let url = URL(string: req.url) else {
115+ guard let url = URL(string: req.url),
116+ let scheme = url.scheme,
117+ scheme == "http" || scheme == "https" else {
56118 return .err(.url(req.url))
57119 }
58120 var request = URLRequest(url: url)
@@ -72,6 +134,10 @@ enum Http {
72134 headers: headers(of: http),
73135 body: [UInt8](data)
74136 ))
137+ } catch let error as URLError where error.code == .cancelled {
138+ // The size-limit delegate cancels the task when the body exceeds the cap.
139+ // Treat it as a transport error so the core backs off and retries.
140+ return .err(.io("response body exceeded \(maxResponseBytes) bytes"))
75141 } catch let error as URLError where error.code == .timedOut {
76142 return .err(.timeout)
77143 } catch {
apple/SkrepkaApp/PhotoPicker.swift+23−1
@@ -62,7 +62,29 @@ struct PhotoPicker: UIViewControllerRepresentable {
6262 guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
6363 let thumb = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary)
6464 else { return nil }
65− return UIImage(cgImage: thumb).jpegData(compressionQuality: 0.7)?.base64EncodedString()
65+ let image = UIImage(cgImage: thumb)
66+ // Reduce quality until the base64 fits within the protocol cap (MAX_PHOTO_LEN,
67+ // 64 KiB). A high-detail 256px frame can exceed the cap even at q0.7; without
68+ // this loop `SaveProfile` rejects the whole profile with no recourse for the user.
69+ let maxBase64Len = 64 * 1024
70+ var quality: CGFloat = 0.7
71+ while quality > 0.1 {
72+ if let jpegData = image.jpegData(compressionQuality: quality) {
73+ let base64 = jpegData.base64EncodedString()
74+ if base64.count <= maxBase64Len {
75+ return base64
76+ }
77+ }
78+ quality -= 0.1
79+ }
80+ // 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
6688 }
6789 }
6890}
apple/SkrepkaApp/QRView.swift+36−2
@@ -21,6 +21,9 @@ func qrImage(_ string: String) -> UIImage? {
2121/// A QR code carries whatever its author put there. Strip a leading scheme so a share
2222/// link (`skrepka:<key>`, `https://…/<key>`) yields the bare key the core will accept,
2323/// and so the pubkey field never silently fills with an unrelated URL.
24+// NOTE: This URL scheme stripping is arguably business logic that belongs in
25+// the core (see AGENTS.md). It is kept here for now because moving it requires
26+// an FFI change. The core's `try_parse_pubkey` validates the result regardless.
2427func scannedKeyPayload(_ raw: String) -> String {
2528 var value = raw.trimmingCharacters(in: .whitespacesAndNewlines)
2629 let schemes = ["skrepka://", "skrepka:", "https://", "http://"]
@@ -75,6 +78,24 @@ final class ScannerVC: UIViewController {
7578 override func viewDidLoad() {
7679 super.viewDidLoad()
7780 view.backgroundColor = .black
81+
82+ let status = AVCaptureDevice.authorizationStatus(for: .video)
83+ switch status {
84+ case .authorized:
85+ startSession()
86+ case .notDetermined:
87+ AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
88+ DispatchQueue.main.async {
89+ if granted { self?.startSession() }
90+ else { self?.showPermissionDenied() }
91+ }
92+ }
93+ default:
94+ showPermissionDenied()
95+ }
96+ }
97+
98+ private func startSession() {
7899 guard let device = AVCaptureDevice.default(for: .video),
79100 let input = try? AVCaptureDeviceInput(device: device),
80101 session.canAddInput(input) else { return }
@@ -93,6 +114,19 @@ final class ScannerVC: UIViewController {
93114 previewLayer = preview
94115 }
95116
117+ private func showPermissionDenied() {
118+ let label = UILabel()
119+ label.text = "Camera permission required"
120+ label.textAlignment = .center
121+ label.textColor = .secondaryLabel
122+ view.addSubview(label)
123+ label.translatesAutoresizingMaskIntoConstraints = false
124+ NSLayoutConstraint.activate([
125+ label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
126+ label.centerYAnchor.constraint(equalTo: view.centerYAnchor),
127+ ])
128+ }
129+
96130 override func viewDidLayoutSubviews() {
97131 super.viewDidLayoutSubviews()
98132 previewLayer?.frame = view.bounds
@@ -100,7 +134,7 @@ final class ScannerVC: UIViewController {
100134
101135 override func viewWillAppear(_ animated: Bool) {
102136 super.viewWillAppear(animated)
103− if !session.isRunning {
137+ if !session.isRunning && !session.inputs.isEmpty {
104138 DispatchQueue.global(qos: .userInitiated).async { self.session.startRunning() }
105139 }
106140 }
@@ -112,4 +146,4 @@ final class ScannerVC: UIViewController {
112146 DispatchQueue.global(qos: .userInitiated).async { self.session.stopRunning() }
113147 }
114148 }
115−}
149+}
apple/SkrepkaApp/SkrepkaApp.swift+4−2
@@ -151,13 +151,15 @@ enum BackgroundRefresh {
151151 /// Waits for a condition on the view, or for the deadline (or expiry) to overtake it.
152152 ///
153153 /// The core has nothing to await — it publishes a `ViewModel` and that is the whole of
154− /// its output — so the shell watches that. A 200ms tick across the handful of transitions
154+ /// its output — so the shell watches that. A 500ms tick across the handful of transitions
155155 /// this cares about is cheaper than threading a Combine subscription through an actor hop.
156+ /// The previous 200ms interval was unnecessarily aggressive for a background task with a
157+ /// 25s budget that only cares about a few state transitions.
156158 private static func settle(by deadline: Date, until condition: () -> Bool) async -> Bool {
157159 while !condition() {
158160 guard !Task.isCancelled, Date() < deadline else { return false }
159161 // Cancellation makes this throw at once; the guard above is what breaks the loop.
160− try? await Task.sleep(for: .milliseconds(200))
162+ try? await Task.sleep(for: .milliseconds(500))
161163 }
162164 return true
163165 }
core/src/app.rs+151−42
@@ -9,6 +9,9 @@ use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
99use std::sync::Arc;
1010use std::time::{Duration, SystemTime, UNIX_EPOCH};
1111
12+use base64::prelude::*;
13+use base64::Engine;
14+
1215use crux_core::{
1316 macros::effect,
1417 render::{render, RenderOperation},
@@ -105,6 +108,12 @@ const POLL_WATCHDOG_MS: u64 = 90_000;
105108/// one costs a redundant request.
106109const FLUSH_WATCHDOG_MS: u64 = 90_000;
107110
111+/// How long an auth round-trip may stay in flight before we assume the
112+/// shell lost it. Same rationale as `POLL_WATCHDOG_MS`: `authenticating`
113+/// gates the whole auth flow, and without a watchdog a dropped effect
114+/// leaves it stuck forever. Kept above the 70 s shell HTTP timeout.
115+const AUTH_WATCHDOG_MS: u64 = 90_000;
116+
108117/// Messages per `/messages` request.
109118///
110119/// The relay rejects a longer batch outright (`413 batch_too_large`,
@@ -259,6 +268,11 @@ pub enum Event {
259268 #[serde(skip)]
260269 #[facet(skip)]
261270 FlushWatchdog(u64),
271+ /// Fires `AUTH_WATCHDOG_MS` after generation `n`'s auth round-trip is
272+ /// issued; a no-op unless that exact auth is somehow still in flight.
273+ #[serde(skip)]
274+ #[facet(skip)]
275+ AuthWatchdog(u64),
262276 #[serde(skip)]
263277 #[facet(skip)]
264278 ChallengeResult(u64, #[facet(opaque)] HttpResult),
@@ -570,7 +584,7 @@ fn now_ms() -> i64 {
570584 SystemTime::now()
571585 .duration_since(UNIX_EPOCH)
572586 .map(|d| d.as_millis() as i64)
573− .unwrap_or(0)
587+ .unwrap_or(1)
574588}
575589
576590/// Bare lowercased hostname of a server URL (no scheme/port/trailing dot).
@@ -804,7 +818,36 @@ impl App for Skrepka {
804818 Event::LoadedMessages(peer, res) => {
805819 match parse_kv::<Vec<StoredMessage>>(res) {
806820 Ok(Some(list)) => {
807− model.messages.insert(peer, 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+ }
828+ // Merge: a poll that beat this lazy load may have already
829+ // inserted messages for this peer. Overwriting with the
830+ // on-disk list (which the GET read before the poll wrote)
831+ // would silently destroy the freshly-ingested messages.
832+ // Union by id, keeping both the on-disk history and any
833+ // in-memory additions.
834+ match model.messages.get_mut(&peer) {
835+ Some(existing) => {
836+ let existing_ids: HashSet<&String> =
837+ existing.iter().map(|m| &m.id).collect();
838+ let new_msgs: Vec<StoredMessage> = list
839+ .into_iter()
840+ .filter(|m| !existing_ids.contains(&m.id))
841+ .collect();
842+ existing.extend(new_msgs);
843+ // Re-sort by ts to maintain the invariant.
844+ existing.sort_by_key(|m| m.ts);
845+ trim_history(existing);
846+ }
847+ None => {
848+ model.messages.insert(peer, list);
849+ }
850+ }
808851 }
809852 Ok(None) => {}
810853 Err(()) => {
@@ -925,6 +968,9 @@ impl App for Skrepka {
925968 // would park the outbox against a limit that no longer applies.
926969 model.flush_paused_until = 0;
927970 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;
928974 let writes = if self.refuse_write(model) {
929975 Command::done()
930976 } else {
@@ -1077,6 +1123,13 @@ impl App for Skrepka {
10771123 model.error = Some("photo too large".into());
10781124 return render();
10791125 }
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+ }
10801133 model.profile = OwnProfile {
10811134 display_name,
10821135 bio,
@@ -1085,8 +1138,8 @@ impl App for Skrepka {
10851138 model.page = Page::Conversations;
10861139 let ts = now_ms();
10871140 let payload = Payload::Profile {
1088− display_name: model.profile.display_name.clone(),
1089− bio: model.profile.bio.clone(),
1141+ display_name: Some(model.profile.display_name.clone()),
1142+ bio: Some(model.profile.bio.clone()),
10901143 photo: model.profile.photo.clone(),
10911144 };
10921145 // Broadcast the new profile to every contact we haven't blocked.
@@ -1165,6 +1218,11 @@ impl App for Skrepka {
11651218 let gen = model.next_auth_gen();
11661219 req.build()
11671220 .then_send(move |r| Event::ChallengeResult(gen, r))
1221+ .and(
1222+ Time::notify_after(Duration::from_millis(AUTH_WATCHDOG_MS))
1223+ .0
1224+ .then_send(move |_| Event::AuthWatchdog(gen)),
1225+ )
11681226 .and(render())
11691227 }
11701228 Err(_) => {
@@ -1361,6 +1419,7 @@ impl App for Skrepka {
13611419 return self.backoff_poll(model);
13621420 }
13631421 };
1422+ let cursor_before = model.cursor;
13641423 let cmd = self.ingest_poll(model, parsed);
13651424 // The re-poll hangs off the *cursor write*, not off this event.
13661425 // Polling again is what acks the batch we just took — the server
@@ -1372,10 +1431,17 @@ impl App for Skrepka {
13721431 // `polling` stays `false`, and the loop is dead until the next
13731432 // foreground `Connect`. The watchdog fires `Poll` if the loop is
13741433 // still idle — same pattern as the poll/send HTTP watchdogs.
1375− cmd.and(
1434+ //
1435+ // Skip the cursor write when the cursor did not advance (an empty
1436+ // or fully-deduped page): there is nothing new to persist, so
1437+ // re-poll directly. The watchdog is still armed as a fallback.
1438+ let cursor_advanced = model.cursor != cursor_before;
1439+ cmd.and(if cursor_advanced {
13761440 KeyValue::set(K_CURSOR, json_bytes(&model.cursor))
1377− .then_send(Event::SavedCursor),
1378− )
1441+ .then_send(Event::SavedCursor)
1442+ } else {
1443+ Command::event(Event::Poll)
1444+ })
13791445 .and(
13801446 Time::notify_after(Duration::from_millis(POLL_WATCHDOG_MS))
13811447 .0
@@ -1402,6 +1468,15 @@ impl App for Skrepka {
14021468 model.abandon_flush();
14031469 self.flush_next(model)
14041470 }
1471+ Event::AuthWatchdog(gen) => {
1472+ // Only the auth this watchdog was armed for, and only while it is
1473+ // still the current one and still in flight.
1474+ if gen != model.auth_gen || !model.authenticating {
1475+ return Command::done();
1476+ }
1477+ model.abandon_auth();
1478+ Command::event(Event::Authenticate)
1479+ }
14051480 Event::SendResult(gen, Ok(resp)) => {
14061481 // A result for a send we already abandoned (a watchdog gave up
14071482 // on it, or a server change invalidated it). Dropping it is safe
@@ -1499,8 +1574,13 @@ impl App for Skrepka {
14991574 // later, under load that has nothing to do with this one — starts
15001575 // at whatever height the last one climbed to.
15011576 model.send_retries = 0;
1502− for _ in 0..batch.map_or(0, |b| b.count) {
1503− model.outbox.pop_front();
1577+ // D3-app: Don't pop if kv is broken — the persist would be
1578+ // refused, and the items would be lost from memory but still
1579+ // on disk, causing duplicate sends on relaunch.
1580+ if !model.kv_load_failed {
1581+ for _ in 0..batch.map_or(0, |b| b.count) {
1582+ model.outbox.pop_front();
1583+ }
15041584 }
15051585 return self.persist_outbox(model).and(self.flush_next(model));
15061586 }
@@ -1794,14 +1874,21 @@ impl Skrepka {
17941874 .insert(peer.clone(), Contact::new(peer.clone(), String::new(), ts));
17951875 }
17961876
1797− // On first contact, share our profile *before* the text so the
1798− // recipient's first impression is a name and avatar, not a truncated
1799− // @p that gets replaced a moment later. Both are queued behind any
1800− // in-flight items, so the ordering is preserved through the outbox.
1877+ let text = Payload::Text { id, body };
1878+ model.outbox.push_back(OutboxItem::new(
1879+ peer.clone(),
1880+ Arc::new(protocol::serialize_payload(&text, ts)),
1881+ ));
1882+
1883+ // On first contact, share our profile *after* the text. The text
1884+ // auto-creates the contact on the recipient's side; the profile that
1885+ // follows in the same batch then applies to the now-existing contact.
1886+ // (Previously profile was queued before the text, but the recipient's
1887+ // ingest drops profiles from non-contacts, so the profile was lost.)
18011888 if was_new {
18021889 let profile = Payload::Profile {
1803− display_name: model.profile.display_name.clone(),
1804− bio: model.profile.bio.clone(),
1890+ display_name: Some(model.profile.display_name.clone()),
1891+ bio: Some(model.profile.bio.clone()),
18051892 photo: model.profile.photo.clone(),
18061893 };
18071894 model.outbox.push_back(OutboxItem::profile(
@@ -1810,12 +1897,6 @@ impl Skrepka {
18101897 ));
18111898 }
18121899
1813− let text = Payload::Text { id, body };
1814− model.outbox.push_back(OutboxItem::new(
1815− peer.clone(),
1816− Arc::new(protocol::serialize_payload(&text, ts)),
1817− ));
1818−
18191900 Command::all([
18201901 self.persist_messages(model, &peer),
18211902 self.persist_contacts(model),
@@ -2051,6 +2132,7 @@ impl Skrepka {
20512132 let now = now_ms();
20522133 let mut touched_convos: HashSet<String> = HashSet::new();
20532134 let mut contacts_dirty = false;
2135+ let mut outbox_dirty = false;
20542136 // sender hex -> message ids needing acks
20552137 let mut ack_targets: BTreeMap<String, Vec<String>> = BTreeMap::new();
20562138 // Q17: Per-sender deferred ack accumulation. Collected during the event
@@ -2119,6 +2201,10 @@ impl Skrepka {
21192201 let Some(parsed) = protocol::parse_payload(&dec.plaintext) else {
21202202 continue;
21212203 };
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;
21222208 let sender = dec.sender_hex;
21232209
21242210 // Mail from ourselves. A relay cannot forge this — the sender identity
@@ -2170,6 +2256,18 @@ impl Skrepka {
21702256 Contact::new(sender.clone(), String::new(), ts),
21712257 );
21722258 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;
21732271 }
21742272 let convo = model.messages.entry(sender.clone()).or_default();
21752273 // Q18: Use the persistent per-peer dedup set from
@@ -2204,7 +2302,6 @@ impl Skrepka {
22042302 },
22052303 );
22062304 id_set.insert(msg_id.clone());
2207− events_stored += 1;
22082305 // N7: Cap the dedup set so it doesn't grow without
22092306 // bound. The set is rewritten to kv on every message
22102307 // receipt, so an unbounded set makes every receipt
@@ -2256,7 +2353,6 @@ impl Skrepka {
22562353 .or_default()
22572354 .ids
22582355 .extend(ack_ids);
2259− events_stored += 1;
22602356 }
22612357 Payload::Profile {
22622358 display_name,
@@ -2283,12 +2379,19 @@ impl Skrepka {
22832379 // A replayed profile at the same `ts` overwrites with identical
22842380 // content, which is a no-op in practice.
22852381 if ts >= c.last_profile_ts {
2286− c.display_name = display_name;
2287− c.bio = bio;
2288− c.photo = photo;
2382+ // D13: Only update fields that are present (Some).
2383+ // An absent field (None) means "don't change".
2384+ if let Some(name) = display_name {
2385+ c.display_name = name;
2386+ }
2387+ if let Some(b) = bio {
2388+ c.bio = b;
2389+ }
2390+ if let Some(p) = photo {
2391+ c.photo = Some(p);
2392+ }
22892393 c.last_profile_ts = ts.min(now);
22902394 contacts_dirty = true;
2291− events_stored += 1;
22922395 }
22932396 }
22942397 }
@@ -2352,6 +2455,7 @@ impl Skrepka {
23522455 Arc::new(protocol::serialize_payload(&payload, ack_ts)),
23532456 ));
23542457 }
2458+ outbox_dirty = true;
23552459 }
23562460
23572461 // Persist everything touched.
@@ -2366,7 +2470,9 @@ impl Skrepka {
23662470 if contacts_dirty {
23672471 cmds.push(self.persist_contacts(model));
23682472 }
2369− cmds.push(self.persist_outbox(model));
2473+ if outbox_dirty {
2474+ cmds.push(self.persist_outbox(model));
2475+ }
23702476 cmds.push(Command::event(Event::StartFlush));
23712477 Command::all(cmds)
23722478 }
@@ -2439,7 +2545,7 @@ mod tests {
24392545 use crate::model::{MAX_OUTBOX_RETRIES, OUTBOX_TTL_MS};
24402546
24412547 fn with_identity() -> Model {
2442− let id = Identity::from_seed(&[3u8; 32]);
2548+ let id = Identity::from_seed(&[3u8; 32]).unwrap();
24432549 Model {
24442550 secret_key: Some(Zeroizing::new(id.secret_key.to_vec())),
24452551 my_pubkey: id.public_key_hex(),
@@ -2448,7 +2554,7 @@ mod tests {
24482554 }
24492555
24502556 fn peer_hex(seed: u8) -> String {
2451− Identity::from_seed(&[seed; 32]).public_key_hex()
2557+ Identity::from_seed(&[seed; 32]).unwrap().public_key_hex()
24522558 }
24532559
24542560 /// `Command::event` *queues* an event; it does not run it. The runtime drains
@@ -2471,7 +2577,7 @@ mod tests {
24712577
24722578 /// A poll page carrying one payload, encrypted from `sender_seed` to `me`.
24732579 fn page_from(sender_seed: u8, me: &Model, payload: &Payload, ts: i64) -> PollResp {
2474− let sender = Identity::from_seed(&[sender_seed; 32]);
2580+ let sender = Identity::from_seed(&[sender_seed; 32]).unwrap();
24752581 let recipient = Identity::from_secret_bytes(me.secret_key.as_ref().unwrap()).unwrap();
24762582 let json = protocol::serialize_payload(payload, ts);
24772583 let blob = crate::crypto::encrypt(
@@ -3098,7 +3204,7 @@ mod tests {
30983204 let parsed = protocol::parse_payload(item.envelope_json.as_bytes()).unwrap();
30993205 match parsed.payload {
31003206 Payload::Profile { display_name, .. } => {
3101− assert_eq!(display_name, "Three", "and it is the latest one");
3207+ assert_eq!(display_name, Some("Three".to_string()), "and it is the latest one");
31023208 }
31033209 _ => panic!("expected a profile"),
31043210 }
@@ -3146,7 +3252,7 @@ mod tests {
31463252 let parsed = protocol::parse_payload(m.outbox[0].envelope_json.as_bytes()).unwrap();
31473253 match parsed.payload {
31483254 Payload::Profile { display_name, .. } => assert_eq!(
3149− display_name, "New",
3255+ display_name, Some("New".to_string()),
31503256 "and the newer profile survives to be sent"
31513257 ),
31523258 _ => panic!("expected a profile"),
@@ -3328,8 +3434,8 @@ mod tests {
33283434 .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
33293435
33303436 let profile = |name: &str| Payload::Profile {
3331− display_name: name.into(),
3332− bio: String::new(),
3437+ display_name: Some(name.to_string()),
3438+ bio: Some(String::new()),
33333439 photo: None,
33343440 };
33353441 let page = page_from(9, &m, &profile("Far"), i64::MAX);
@@ -3426,8 +3532,11 @@ mod tests {
34263532 let (m, peer) = ingest_one_text_from_an_unblocked_peer();
34273533 assert_eq!(m.messages[&peer].len(), 1);
34283534 assert_eq!(m.messages[&peer][0].body, "hi");
3429− assert_eq!(m.outbox.len(), 1, "one delivery.ack is queued");
3535+ // D6-app: A profile broadcast is queued alongside the delivery.ack
3536+ // so the new contact sees our display name and avatar.
3537+ assert_eq!(m.outbox.len(), 2, "one delivery.ack + one profile queued");
34303538 assert_eq!(m.outbox[0].recipient, peer);
3539+ assert_eq!(m.outbox[1].recipient, peer);
34313540 }
34323541
34333542 /// A peer sending `ts = i64::MAX` used to pin itself to the top of the
@@ -4133,7 +4242,7 @@ mod tests {
41334242 );
41344243
41354244 // The recovery is a relaunch, which re-runs the loads and clears the latch.
4136− let sk = Identity::from_seed(&[3u8; 32]).secret_key.to_vec();
4245+ let sk = Identity::from_seed(&[3u8; 32]).unwrap().secret_key.to_vec();
41374246 let _ = app.update(Event::IdentityLoaded(sk), &mut m);
41384247 assert!(
41394248 !m.kv_load_failed,
@@ -4240,8 +4349,8 @@ mod tests {
42404349 );
42414350
42424351 let profile = Payload::Profile {
4243− display_name: "Mallory".into(),
4244− bio: "spam".into(),
4352+ display_name: Some("Mallory".into()),
4353+ bio: Some("spam".into()),
42454354 photo: Some("aGk=".into()),
42464355 };
42474356 let page = page_from(9, &m, &profile, now_ms());
@@ -4322,8 +4431,8 @@ mod tests {
43224431 let peer = peer_hex(9);
43234432
43244433 let profile = Payload::Profile {
4325− display_name: "Mallory".into(),
4326− bio: String::new(),
4434+ display_name: Some("Mallory".into()),
4435+ bio: Some(String::new()),
43274436 photo: Some("aGk=".into()),
43284437 };
43294438 let page = page_from(9, &m, &profile, now_ms());
@@ -4535,7 +4644,7 @@ mod tests {
45354644
45364645 // One page holding more messages from one sender than fits in a single ack.
45374646 let count = protocol::MAX_ACK_IDS + 5;
4538− let sender = Identity::from_seed(&[9u8; 32]);
4647+ let sender = Identity::from_seed(&[9u8; 32]).unwrap();
45394648 let me = Identity::from_secret_bytes(m.secret_key.as_ref().unwrap()).unwrap();
45404649 let events = (0..count)
45414650 .map(|i| {
core/src/crypto.rs+120−95
@@ -31,6 +31,13 @@ const HKDF_INFO: &[u8] = b"skrepka-v2";
3131/// Domain-separation tag for the auth challenge signature (PROTOCOL.md §6).
3232const AUTH_TAG: &str = "skrepka-auth-v1:";
3333
34+/// Domain-separation tag for per-message signatures (PROTOCOL.md §3).
35+/// Prevents cross-protocol signature reuse: an auth challenge signature
36+/// begins with "skrepka-auth-v1:" while a message signature begins with
37+/// this tag, so the same key cannot sign in one context and have it
38+/// validate in another.
39+const MSG_TAG: &[u8] = b"skrepka-msg-v2:";
40+
3441/// Wire-format version byte prepended to every encrypted blob (PROTOCOL.md §3).
3542///
3643/// Version 0x02 adds AEAD associated data (AAD): the cleartext header
@@ -58,9 +65,10 @@ fn aad(version: u8, eph_pub: &[u8; 32], nonce: &[u8; NONCE_LEN], recipient_pub:
5865pub const ED25519_SECRET_LEN: usize = 64;
5966pub const ED25519_PUBLIC_LEN: usize = 32;
6067pub const NONCE_LEN: usize = 24;
61−/// 1 (version) + 32 (eph) + 24 (nonce) + 32 (sender pub) + 64 (sig) + 4 (compressed_len)
62−/// + 16 (AEAD tag). The 4-byte compressed_len field sits inside the AEAD ciphertext.
63−/// The version byte, ephemeral pubkey, nonce, and recipient pubkey are AEAD AAD (v0x02).
68+/// `1+32+24+32+64+4+16` bytes = version, eph pubkey, nonce, sender pub, sig,
69+/// compressed_len, AEAD tag. The 4-byte compressed_len field sits inside the AEAD
70+/// ciphertext. The version byte, ephemeral pubkey, nonce, and recipient pubkey are
71+/// AEAD AAD (v0x02).
6472pub const MIN_BLOB_LEN: usize = 1 + 32 + NONCE_LEN + 32 + 64 + 4 + 16;
6573
6674/// Blob sizes are padded up to these boundaries so the on-wire length does not
@@ -82,7 +90,10 @@ fn padded_len(actual: usize) -> usize {
8290 // Above 65536: round to the next multiple of 65536.
8391 let remainder = actual % 65536;
8492 if remainder == 0 {
85− actual
93+ // D6: When actual is an exact multiple of 65536 above 65536,
94+ // returning `actual` would give zero padding, revealing the exact
95+ // compressed size. Always add at least one bucket of padding.
96+ actual + 65536
8697 } else {
8798 actual + 65536 - remainder
8899 }
@@ -186,18 +197,18 @@ impl Identity {
186197 return Err(CryptoError::BadKeyLength);
187198 }
188199
189− Ok(Identity::with_x25519(secret_key))
200+ Identity::with_x25519(secret_key)
190201 }
191202
192203 /// Generate a fresh identity from a CSPRNG.
193− pub fn generate(rng: &mut (impl RngCore + CryptoRng)) -> Self {
204+ pub fn generate(rng: &mut (impl RngCore + CryptoRng)) -> Result<Self, CryptoError> {
194205 let mut seed = Zeroizing::new([0u8; 32]);
195206 rng.fill_bytes(&mut *seed);
196207 Self::from_seed(&seed)
197208 }
198209
199210 /// Build the 64-byte secret key (`seed || pub`) from a 32-byte seed.
200− pub fn from_seed(seed: &[u8; 32]) -> Self {
211+ pub fn from_seed(seed: &[u8; 32]) -> Result<Self, CryptoError> {
201212 let signing = SigningKey::from_bytes(seed);
202213 let public = signing.verifying_key();
203214 let mut secret_key = [0u8; ED25519_SECRET_LEN];
@@ -230,19 +241,24 @@ impl Identity {
230241 /// `InvalidEphemeralKey` and the user would be unable to receive messages
231242 /// with no indication of why. Catching it at construction time gives a
232243 /// clear error instead.
233− fn with_x25519(secret_key: [u8; ED25519_SECRET_LEN]) -> Self {
244+ fn with_x25519(secret_key: [u8; ED25519_SECRET_LEN]) -> Result<Self, CryptoError> {
234245 let x25519_priv = *ed25519_sk_to_x25519(&secret_key);
235246 let x25519_pub = x25519_dalek::x25519(x25519_priv, x25519_dalek::X25519_BASEPOINT_BYTES);
236− debug_assert!(
237− !x25519_pub.iter().all(|b| *b == 0),
238− "derived X25519 public key is all-zero (low-order point) — \
239− this identity cannot receive messages"
240− );
241− Identity {
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.
254+ if x25519_pub.iter().all(|b| *b == 0) {
255+ return Err(CryptoError::InvalidPublicKey);
256+ }
257+ Ok(Identity {
242258 secret_key,
243259 x25519_priv,
244260 x25519_pub,
245− }
261+ })
246262 }
247263
248264 /// Cached X25519 private key derived from the Ed25519 seed (libsodium
@@ -278,7 +294,14 @@ fn ed25519_pk_to_x25519(ed_pub: &[u8; 32]) -> Result<[u8; 32], CryptoError> {
278294 let point = compressed
279295 .decompress()
280296 .ok_or(CryptoError::InvalidPublicKey)?;
281− Ok(point.to_montgomery().to_bytes())
297+ let montgomery = point.to_montgomery().to_bytes();
298+ // D3: A 32-byte all-zero input is the Ed25519 identity point, which
299+ // produces an all-zero Montgomery u-coordinate — a low-order point.
300+ // Reject it explicitly, separate from the x25519 shared-secret check.
301+ if montgomery.iter().all(|&b| b == 0) {
302+ return Err(CryptoError::InvalidPublicKey);
303+ }
304+ Ok(montgomery)
282305}
283306
284307/// libsodium `crypto_sign_ed25519_sk_to_curve25519`: clamp(SHA-512(seed)[..32]).
@@ -309,7 +332,7 @@ fn x25519(scalar: &[u8; 32], point: &[u8; 32]) -> Result<Zeroizing<[u8; 32]>, Cr
309332 Ok(shared)
310333}
311334
312−/// HKDF-SHA256(ikm=raw_secret, salt=eph_pub || recip_x_pub, info="skrepka-v1", 32).
335+/// HKDF-SHA256(ikm=raw_secret, salt=eph_pub || recip_x_pub, info="skrepka-v2", 32).
313336fn derive_key(
314337 raw_secret: &[u8; 32],
315338 eph_pub: &[u8; 32],
@@ -346,7 +369,7 @@ fn decompress(data: &[u8]) -> Result<Vec<u8>, CryptoError> {
346369 decoder
347370 .window_log_max(21)
348371 .map_err(|_| CryptoError::Decompress)?;
349− let mut out = Vec::new();
372+ let mut out = Vec::with_capacity(4096);
350373 (&mut decoder)
351374 .take(MAX_PLAINTEXT_LEN as u64 + 1)
352375 .read_to_end(&mut out)
@@ -365,6 +388,12 @@ pub fn encrypt(
365388 recipient_ed_pub: &[u8; 32],
366389 plaintext: &[u8],
367390) -> 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+ }
368397 let sender_pub = sender.public_key();
369398
370399 // Ephemeral raw X25519 keypair (libsodium crypto_box_keypair).
@@ -394,7 +423,8 @@ pub fn encrypt(
394423
395424 // Sign recipient_pub || compressed_len_bytes || compressed || padding
396425 // (i.e. recipient_pub || everything after the 96-byte header in inner).
397− let mut signed = Zeroizing::new(Vec::with_capacity(32 + 4 + compressed.len() + padding.len()));
426+ let mut signed = Zeroizing::new(Vec::with_capacity(MSG_TAG.len() + 32 + 4 + compressed.len() + padding.len()));
427+ signed.extend_from_slice(MSG_TAG);
398428 signed.extend_from_slice(recipient_ed_pub);
399429 signed.extend_from_slice(&compressed_len_bytes);
400430 signed.extend_from_slice(&compressed);
@@ -505,7 +535,7 @@ pub fn decrypt(recipient: &Identity, blob: &[u8]) -> Result<Decrypted, CryptoErr
505535 inner[98],
506536 inner[99],
507537 ]) as usize;
508− if 100 + compressed_len > inner.len() {
538+ if compressed_len > inner.len().saturating_sub(100) {
509539 inner.zeroize();
510540 return Err(CryptoError::Decrypt);
511541 }
@@ -515,7 +545,8 @@ pub fn decrypt(recipient: &Identity, blob: &[u8]) -> Result<Decrypted, CryptoErr
515545 // (covers compressed_len_bytes || compressed || padding).
516546 let verifying = VerifyingKey::from_bytes(&sender_pub).map_err(|_| CryptoError::BadSignature)?;
517547 let signature = ed25519_dalek::Signature::from_bytes(&sig_bytes);
518− let mut signed = Zeroizing::new(Vec::with_capacity(32 + (inner.len() - 96)));
548+ let mut signed = Zeroizing::new(Vec::with_capacity(MSG_TAG.len() + 32 + (inner.len() - 96)));
549+ signed.extend_from_slice(MSG_TAG);
519550 signed.extend_from_slice(&recipient.public_key());
520551 signed.extend_from_slice(&inner[96..]);
521552 verifying
@@ -547,12 +578,12 @@ mod tests {
547578
548579 #[test]
549580 fn identity_pub_is_last_32_bytes_and_consistent() {
550− let id = Identity::generate(&mut rng(1));
581+ let id = Identity::generate(&mut rng(1)).unwrap();
551582 assert_eq!(&id.secret_key[32..], &id.public_key());
552583 // Re-deriving from the seed yields the same public key.
553584 let mut seed = [0u8; 32];
554585 seed.copy_from_slice(&id.secret_key[..32]);
555− let id2 = Identity::from_seed(&seed);
586+ let id2 = Identity::from_seed(&seed).unwrap();
556587 assert_eq!(id.public_key(), id2.public_key());
557588 }
558589
@@ -560,7 +591,7 @@ mod tests {
560591 /// under one identity while claiming another.
561592 #[test]
562593 fn from_secret_bytes_rejects_a_seed_pubkey_mismatch() {
563− let id = Identity::from_seed(&[3u8; 32]);
594+ let id = Identity::from_seed(&[3u8; 32]).unwrap();
564595 assert!(Identity::from_secret_bytes(&id.secret_key).is_ok());
565596
566597 let mut tampered = id.secret_key;
@@ -573,8 +604,8 @@ mod tests {
573604
574605 #[test]
575606 fn round_trip_recovers_plaintext_and_sender() {
576− let alice = Identity::generate(&mut rng(10));
577− let bob = Identity::generate(&mut rng(20));
607+ let alice = Identity::generate(&mut rng(10)).unwrap();
608+ let bob = Identity::generate(&mut rng(20)).unwrap();
578609 let msg = br#"{"type":"text","id":"abc","body":"hello bob","ts":1700000000000}"#;
579610
580611 let blob = encrypt(&mut rng(99), &alice, &bob.public_key(), msg).unwrap();
@@ -587,17 +618,17 @@ mod tests {
587618
588619 #[test]
589620 fn wrong_recipient_cannot_decrypt() {
590− let alice = Identity::generate(&mut rng(1));
591− let bob = Identity::generate(&mut rng(2));
592− let eve = Identity::generate(&mut rng(3));
621+ let alice = Identity::generate(&mut rng(1)).unwrap();
622+ let bob = Identity::generate(&mut rng(2)).unwrap();
623+ let eve = Identity::generate(&mut rng(3)).unwrap();
593624 let blob = encrypt(&mut rng(4), &alice, &bob.public_key(), b"secret").unwrap();
594625 assert_eq!(decrypt(&eve, &blob).unwrap_err(), CryptoError::Decrypt);
595626 }
596627
597628 #[test]
598629 fn tampered_ciphertext_is_rejected() {
599− let alice = Identity::generate(&mut rng(1));
600− let bob = Identity::generate(&mut rng(2));
630+ let alice = Identity::generate(&mut rng(1)).unwrap();
631+ let bob = Identity::generate(&mut rng(2)).unwrap();
601632 let mut blob = encrypt(&mut rng(4), &alice, &bob.public_key(), b"secret").unwrap();
602633 let last = blob.len() - 1;
603634 blob[last] ^= 0xff;
@@ -606,8 +637,8 @@ mod tests {
606637
607638 #[test]
608639 fn blob_layout_offsets() {
609− let alice = Identity::generate(&mut rng(1));
610− let bob = Identity::generate(&mut rng(2));
640+ let alice = Identity::generate(&mut rng(1)).unwrap();
641+ let bob = Identity::generate(&mut rng(2)).unwrap();
611642 let blob = encrypt(&mut rng(7), &alice, &bob.public_key(), b"x").unwrap();
612643 // Version byte at offset 0.
613644 assert_eq!(blob[0], WIRE_VERSION);
@@ -624,7 +655,7 @@ mod tests {
624655
625656 #[test]
626657 fn too_short_blob_errors() {
627− let bob = Identity::generate(&mut rng(2));
658+ let bob = Identity::generate(&mut rng(2)).unwrap();
628659 assert_eq!(
629660 decrypt(&bob, &[0u8; 10]).unwrap_err(),
630661 CryptoError::BlobTooShort
@@ -635,7 +666,7 @@ mod tests {
635666 /// scalar mult, an AEAD pass, or a decompression.
636667 #[test]
637668 fn too_long_blob_errors() {
638− let bob = Identity::generate(&mut rng(2));
669+ let bob = Identity::generate(&mut rng(2)).unwrap();
639670 let huge = vec![0u8; MAX_BLOB_LEN + 1];
640671 assert_eq!(decrypt(&bob, &huge).unwrap_err(), CryptoError::BlobTooLong);
641672 }
@@ -652,25 +683,27 @@ mod tests {
652683 );
653684 assert_eq!(decompress(&bomb).unwrap_err(), CryptoError::Decompress);
654685
655− // ...and the same bomb wrapped in a well-formed, correctly signed blob
656− // (i.e. from a real peer) dies at the same wall.
657− let alice = Identity::generate(&mut rng(1));
658− let bob = Identity::generate(&mut rng(2));
659− let blob = encrypt(
660− &mut rng(3),
661− &alice,
662− &bob.public_key(),
663− &vec![0u8; MAX_PLAINTEXT_LEN + 1],
664− )
665− .unwrap();
666− assert_eq!(decrypt(&bob, &blob).unwrap_err(), CryptoError::Decompress);
686+ // ...and the same bomb sent through encrypt is rejected before
687+ // compression/encryption work is done (D28).
688+ let alice = Identity::generate(&mut rng(1)).unwrap();
689+ let bob = Identity::generate(&mut rng(2)).unwrap();
690+ assert_eq!(
691+ encrypt(
692+ &mut rng(3),
693+ &alice,
694+ &bob.public_key(),
695+ &vec![0u8; MAX_PLAINTEXT_LEN + 1],
696+ )
697+ .unwrap_err(),
698+ CryptoError::Encrypt,
699+ );
667700 }
668701
669702 /// A plaintext right at the cap still round-trips: the bound is inclusive.
670703 #[test]
671704 fn a_plaintext_at_the_cap_still_round_trips() {
672− let alice = Identity::generate(&mut rng(1));
673− let bob = Identity::generate(&mut rng(2));
705+ let alice = Identity::generate(&mut rng(1)).unwrap();
706+ let bob = Identity::generate(&mut rng(2)).unwrap();
674707 let msg = vec![7u8; MAX_PLAINTEXT_LEN];
675708 let blob = encrypt(&mut rng(3), &alice, &bob.public_key(), &msg).unwrap();
676709 assert_eq!(decrypt(&bob, &blob).unwrap().plaintext.as_slice(), msg.as_slice());
@@ -680,7 +713,7 @@ mod tests {
680713 /// secret, so the sender's "encryption" is forgeable. Reject it.
681714 #[test]
682715 fn a_low_order_ephemeral_point_is_rejected() {
683− let bob = Identity::generate(&mut rng(2));
716+ let bob = Identity::generate(&mut rng(2)).unwrap();
684717 // eph_pub = 0 is the canonical low-order point; x25519 with it yields
685718 // an all-zero shared secret for any scalar.
686719 let mut blob = vec![0u8; MIN_BLOB_LEN + 1];
@@ -695,8 +728,8 @@ mod tests {
695728 /// A short message must be padded so the blob size lands on a bucket boundary.
696729 #[test]
697730 fn blob_is_padded_to_bucket_size() {
698− let alice = Identity::generate(&mut rng(1));
699− let bob = Identity::generate(&mut rng(2));
731+ let alice = Identity::generate(&mut rng(1)).unwrap();
732+ let bob = Identity::generate(&mut rng(2)).unwrap();
700733
701734 // Short message → should round up to the smallest bucket (256).
702735 let blob_small = encrypt(&mut rng(7), &alice, &bob.public_key(), b"x").unwrap();
@@ -721,8 +754,8 @@ mod tests {
721754 /// same blob size (same bucket) but different blob contents (random padding).
722755 #[test]
723756 fn padding_is_random_per_message() {
724− let alice = Identity::generate(&mut rng(1));
725− let bob = Identity::generate(&mut rng(2));
757+ let alice = Identity::generate(&mut rng(1)).unwrap();
758+ let bob = Identity::generate(&mut rng(2)).unwrap();
726759 let msg = b"hello bob, this is a test message";
727760
728761 let blob1 = encrypt(&mut rng(100), &alice, &bob.public_key(), msg).unwrap();
@@ -743,8 +776,8 @@ mod tests {
743776 /// never sees trailing padding bytes.
744777 #[test]
745778 fn compressed_len_field_is_parsed_correctly() {
746− let alice = Identity::generate(&mut rng(1));
747− let bob = Identity::generate(&mut rng(2));
779+ let alice = Identity::generate(&mut rng(1)).unwrap();
780+ let bob = Identity::generate(&mut rng(2)).unwrap();
748781
749782 // Normal round-trip.
750783 let msg = b"round trip test with some content";
@@ -766,7 +799,7 @@ mod tests {
766799 /// error says so rather than blaming decryption.
767800 #[test]
768801 fn encrypting_to_an_off_curve_key_reports_an_invalid_public_key() {
769− let alice = Identity::generate(&mut rng(1));
802+ let alice = Identity::generate(&mut rng(1)).unwrap();
770803 let not_a_point = [0xabu8; 32];
771804 assert_eq!(
772805 encrypt(&mut rng(2), &alice, &not_a_point, b"x").unwrap_err(),
@@ -776,7 +809,7 @@ mod tests {
776809
777810 #[test]
778811 fn sk_to_x25519_clamps() {
779− let id = Identity::generate(&mut rng(5));
812+ let id = Identity::generate(&mut rng(5)).unwrap();
780813 let scalar = ed25519_sk_to_x25519(&id.secret_key);
781814 assert_eq!(scalar[0] & 7, 0);
782815 assert_eq!(scalar[31] & 64, 64);
@@ -785,7 +818,7 @@ mod tests {
785818
786819 #[test]
787820 fn challenge_signature_is_deterministic_and_host_bound() {
788− let id = Identity::from_seed(&[7u8; 32]);
821+ let id = Identity::from_seed(&[7u8; 32]).unwrap();
789822 let a = id.sign_challenge("relay.example.com", "deadbeef").unwrap();
790823 let b = id.sign_challenge("relay.example.com", "deadbeef").unwrap();
791824 let c = id.sign_challenge("other.example.com", "deadbeef").unwrap();
@@ -797,7 +830,7 @@ mod tests {
797830 /// We sign whatever a not-yet-trusted relay hands us, so its size is bounded.
798831 #[test]
799832 fn an_oversized_challenge_is_not_signed() {
800− let id = Identity::from_seed(&[7u8; 32]);
833+ let id = Identity::from_seed(&[7u8; 32]).unwrap();
801834 let huge = "a".repeat(MAX_CHALLENGE_LEN + 1);
802835 assert_eq!(
803836 id.sign_challenge("relay.example.com", &huge).unwrap_err(),
@@ -808,61 +841,53 @@ mod tests {
808841 assert!(id.sign_challenge("relay.example.com", &at_cap).is_ok());
809842 }
810843
811− /// N1: AAD prevents silent `to`-swap censorship. A blob encrypted to Bob
812− /// cannot be decrypted by Charlie even if a relay changes the envelope's
813− /// `to` field to Charlie's pubkey — the AAD binds the recipient identity,
814− /// so the AEAD tag check fails before any inner content is revealed.
844+ /// A blob encrypted to Bob cannot be decrypted by Charlie — the key
845+ /// derivation itself binds the recipient (the AEAD key is a function
846+ /// of the recipient's private key and the ephemeral public key). The
847+ /// AAD additionally authenticates the recipient pubkey, but the tag
848+ /// check would fail even without it, because Charlie derives a
849+ /// different key. The AAD is defense-in-depth: it makes the binding
850+ /// explicit rather than implicit in the KDF.
815851 #[test]
816− fn aad_prevents_to_swap_censorship() {
817− let alice = Identity::generate(&mut rng(1));
818− let bob = Identity::generate(&mut rng(2));
819− let charlie = Identity::generate(&mut rng(3));
852+ 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();
820856
821− // Alice encrypts a message to Bob.
822857 let blob = encrypt(&mut rng(4), &alice, &bob.public_key(), b"hi bob").unwrap();
823−
824− // Charlie tries to decrypt the blob meant for Bob. The AAD includes
825− // Bob's pubkey (from the blob's perspective the recipient is Bob),
826− // but Charlie's identity derives a different key. This must fail at
827− // AEAD decryption, not at signature verification.
828858 assert_eq!(
829859 decrypt(&charlie, &blob).unwrap_err(),
830860 CryptoError::Decrypt,
831− "wrong recipient must fail AEAD, not reach signature check"
861+ "wrong recipient must fail AEAD — key derivation binds the recipient"
832862 );
833863 }
834864
835− /// N1: Tampering with the version byte causes AEAD failure (the version
836− /// is part of the AAD, not just checked separately).
865+ /// Tampering with the version byte causes failure via the explicit
866+ /// version check (`blob[0] != WIRE_VERSION`) before the AEAD even runs.
867+ /// The AAD includes the version as defense-in-depth, but the explicit
868+ /// check fires first.
837869 #[test]
838− fn aad_binds_version_byte() {
839− let alice = Identity::generate(&mut rng(1));
840− let bob = Identity::generate(&mut rng(2));
870+ fn tampered_version_byte_is_rejected() {
871+ let alice = Identity::generate(&mut rng(1)).unwrap();
872+ let bob = Identity::generate(&mut rng(2)).unwrap();
841873 let mut blob = encrypt(&mut rng(3), &alice, &bob.public_key(), b"hi").unwrap();
842−
843− // Flip the version byte. The separate check (`blob[0] != WIRE_VERSION`)
844− // would catch this, but the AAD also includes it, so even if the
845− // version check were removed, AEAD would still fail.
846874 blob[0] ^= 0xff;
847875 assert_eq!(
848876 decrypt(&bob, &blob).unwrap_err(),
849877 CryptoError::Decrypt,
850− "version byte tampering must fail AEAD"
878+ "version byte tampering is rejected"
851879 );
852880 }
853881
854− /// N1: Tampering with the ephemeral pubkey causes AEAD failure (it's
855− /// part of the AAD, not just used for key derivation).
882+ /// Tampering with the ephemeral pubkey causes key derivation to produce
883+ /// a different key, so the AEAD tag check fails. The AAD additionally
884+ /// authenticates the eph_pub, but the KDF mismatch is what actually
885+ /// catches this.
856886 #[test]
857− fn aad_binds_ephemeral_pubkey() {
858− let alice = Identity::generate(&mut rng(1));
859− let bob = Identity::generate(&mut rng(2));
887+ fn tampered_ephemeral_pubkey_is_rejected() {
888+ let alice = Identity::generate(&mut rng(1)).unwrap();
889+ let bob = Identity::generate(&mut rng(2)).unwrap();
860890 let mut blob = encrypt(&mut rng(3), &alice, &bob.public_key(), b"hi").unwrap();
861−
862− // Corrupt one byte of the ephemeral pubkey. Even though the key
863− // derivation would produce a different key (causing AEAD failure
864− // anyway), the AAD also includes the eph_pub, so this is
865− // cryptographically bound to the ciphertext.
866891 blob[5] ^= 0x01;
867892 assert!(decrypt(&bob, &blob).is_err());
868893 }
core/src/ffi.rs+5
@@ -39,6 +39,11 @@ impl Default for CoreFFI {
3939/// `AssertUnwindSafe` is sound here because a poisoned `Bridge` is never read
4040/// again for correctness: the worst case is a dropped effect batch, and the next
4141/// event starts a fresh `update`.
42+///
43+/// NOTE: A panic mid-`update` leaves the `Model` in a partially-mutated state.
44+/// The next `update` call operates on this potentially-corrupted state. A
45+/// proper fix requires snapshotting the Model before `update` and restoring on
46+/// panic, or marking the Bridge as poisoned. This is an accepted limitation.
4247fn guard(f: impl FnOnce() -> Vec<u8>) -> Vec<u8> {
4348 catch_unwind(AssertUnwindSafe(f)).unwrap_or_default()
4449}
core/src/phonemic.rs+8−3
@@ -96,6 +96,11 @@ fn suffix_index(s: &str) -> Option<u8> {
9696/// Keys are always 32 bytes, so no caller is affected — but a display helper
9797/// must not quietly invent a byte.
9898pub fn to_ob(bytes: &[u8]) -> Option<String> {
99+ // P1: Empty input produces an empty string, which is a valid but
100+ // meaningless @p — return None instead so the round-trip is symmetric.
101+ if bytes.is_empty() {
102+ return None;
103+ }
99104 if !bytes.len().is_multiple_of(2) {
100105 return None;
101106 }
@@ -151,7 +156,7 @@ pub fn try_parse_pubkey(input: &str) -> Option<String> {
151156 if let Some(hex) = from_ob(trimmed).as_deref().and_then(valid_pubkey_hex) {
152157 return Some(hex);
153158 }
154− if let Ok(bytes) = hex::decode(trimmed.to_lowercase()) {
159+ if let Ok(bytes) = hex::decode(trimmed) {
155160 return valid_pubkey_hex(&bytes);
156161 }
157162 None
@@ -207,13 +212,13 @@ mod tests {
207212 fn odd_length_input_has_no_spelling() {
208213 assert_eq!(to_ob(&[7u8]), None);
209214 assert_eq!(to_ob(&[7u8, 0]), Some("hidzod".to_string()));
210− assert_eq!(to_ob(&[]), Some(String::new()));
215+ assert_eq!(to_ob(&[]), None);
211216 }
212217
213218 /// A real Ed25519 public key. `try_parse_pubkey` now checks that a key is a
214219 /// curve point, so tests can't use an arbitrary byte pattern.
215220 fn real_key() -> [u8; 32] {
216− crate::crypto::Identity::from_seed(&[7u8; 32]).public_key()
221+ crate::crypto::Identity::from_seed(&[7u8; 32]).unwrap().public_key()
217222 }
218223
219224 /// 32 bytes that decode fine but are *not* on the curve — the case that used
core/src/protocol.rs+64−31
@@ -35,8 +35,8 @@ pub enum Payload {
3535 ack_ids: Vec<String>,
3636 },
3737 Profile {
38− display_name: String,
39− bio: String,
38+ display_name: Option<String>,
39+ bio: Option<String>,
4040 photo: Option<String>,
4141 },
4242}
@@ -77,10 +77,16 @@ pub fn serialize_payload(payload: &Payload, ts: i64) -> String {
7777 } => {
7878 let mut obj = serde_json::json!({
7979 "type": "profile",
80− "display_name": display_name,
81− "bio": bio,
8280 "ts": ts,
8381 });
82+ // D13: Only include the field when present — absent means
83+ // "don't change", while present (even empty) means "set to this".
84+ if let Some(name) = display_name {
85+ obj["display_name"] = serde_json::Value::String(name.clone());
86+ }
87+ if let Some(b) = bio {
88+ obj["bio"] = serde_json::Value::String(b.clone());
89+ }
8490 // Omit `photo` entirely when there isn't one, rather than sending an
8591 // explicit null: "no photo" and "field absent" mean the same thing to
8692 // a reader, and the null costs bytes in every broadcast.
@@ -93,6 +99,38 @@ pub fn serialize_payload(payload: &Payload, ts: i64) -> String {
9399 value.to_string()
94100}
95101
102+/// D14: Filter out control characters, bidirectional control characters, and
103+/// zero-width characters from a received string. Applied on the receiving side
104+/// (parse_payload) so the sender's input is preserved as-is on send, but the
105+/// recipient is protected from invisible character tricks.
106+///
107+/// Filters:
108+/// - Control characters U+0000–U+001F (except U+0009 tab, U+000A newline,
109+/// U+000D carriage return)
110+/// - Bidirectional control characters (U+200E LRM, U+200F RLM, U+202A–U+202E,
111+/// U+2066–U+2069)
112+/// - Zero-width characters (U+200B, U+200C, U+200D, U+FEFF)
113+fn sanitize_text(s: &str) -> String {
114+ s.chars()
115+ .filter(|c| {
116+ let cp = *c as u32;
117+ // Control characters U+0000–U+001F, except tab/newline/CR
118+ if cp <= 0x001F && cp != 0x0009 && cp != 0x000A && cp != 0x000D {
119+ return false;
120+ }
121+ // Bidirectional control characters
122+ if matches!(cp, 0x200E | 0x200F | 0x202A..=0x202E | 0x2066..=0x2069) {
123+ return false;
124+ }
125+ // Zero-width characters
126+ if matches!(cp, 0x200B | 0x200C | 0x200D | 0xFEFF) {
127+ return false;
128+ }
129+ true
130+ })
131+ .collect()
132+}
133+
96134/// Parse a plaintext payload. Returns `None` for unknown/malformed types so the
97135/// caller silently ignores them (forward compatibility, PROTOCOL.md §4).
98136pub fn parse_payload(bytes: &[u8]) -> Option<ParsedPayload> {
@@ -102,7 +140,7 @@ pub fn parse_payload(bytes: &[u8]) -> Option<ParsedPayload> {
102140 let payload = match ty {
103141 "text" => {
104142 let id = v.get("id")?.as_str()?.to_string();
105− let body = v.get("body")?.as_str()?.to_string();
143+ let body = sanitize_text(v.get("body")?.as_str()?);
106144 if id.len() > MAX_ID_LEN || body.len() > MAX_BODY_LEN {
107145 return None;
108146 }
@@ -127,22 +165,17 @@ pub fn parse_payload(bytes: &[u8]) -> Option<ParsedPayload> {
127165 Payload::DeliveryAck { ack_ids }
128166 }
129167 "profile" => {
130− let display_name = v
131− .get("display_name")
132− .and_then(|x| x.as_str())
133− .unwrap_or("")
134− .to_string();
135− let bio = v
136− .get("bio")
137− .and_then(|x| x.as_str())
138− .unwrap_or("")
139− .to_string();
168+ // D13: Use Option<String> — None when the field is absent (don't
169+ // change the recipient's cached value), Some(value) when present
170+ // (even if empty, meaning "set to this value").
171+ let display_name = v.get("display_name").and_then(|x| x.as_str()).map(sanitize_text);
172+ let bio = v.get("bio").and_then(|x| x.as_str()).map(sanitize_text);
140173 let photo = v
141174 .get("photo")
142175 .and_then(|x| x.as_str())
143176 .map(str::to_string);
144− if display_name.chars().count() > MAX_DISPLAY_NAME_LEN
145− || bio.chars().count() > MAX_BIO_LEN
177+ if display_name.as_ref().is_some_and(|d| d.chars().count() > MAX_DISPLAY_NAME_LEN)
178+ || bio.as_ref().is_some_and(|b| b.chars().count() > MAX_BIO_LEN)
146179 || photo.as_ref().is_some_and(|p| p.len() > MAX_PHOTO_LEN)
147180 || photo.as_ref().is_some_and(|p| {
148181 // Reject a photo that isn't valid base64 — a peer can put
@@ -195,14 +228,14 @@ mod tests {
195228 #[test]
196229 fn profile_round_trip_with_and_without_photo() {
197230 let with = Payload::Profile {
198− display_name: "Alice".into(),
199− bio: "writing".into(),
231+ display_name: Some("Alice".into()),
232+ bio: Some("writing".into()),
200233 photo: Some("aGVsbG8=".into()),
201234 };
202235 assert_eq!(reparse(&with, 1).payload, with);
203236 let without = Payload::Profile {
204− display_name: "Bob".into(),
205− bio: "".into(),
237+ display_name: Some("Bob".into()),
238+ bio: Some("".into()),
206239 photo: None,
207240 };
208241 assert_eq!(reparse(&without, 1).payload, without);
@@ -242,8 +275,8 @@ mod tests {
242275
243276 let long_bio = serialize_payload(
244277 &Payload::Profile {
245− display_name: "A".into(),
246− bio: "b".repeat(MAX_BIO_LEN + 1),
278+ display_name: Some("A".into()),
279+ bio: Some("b".repeat(MAX_BIO_LEN + 1)),
247280 photo: None,
248281 },
249282 1,
@@ -252,8 +285,8 @@ mod tests {
252285
253286 let long_name = serialize_payload(
254287 &Payload::Profile {
255− display_name: "n".repeat(MAX_DISPLAY_NAME_LEN + 1),
256− bio: String::new(),
288+ display_name: Some("n".repeat(MAX_DISPLAY_NAME_LEN + 1)),
289+ bio: Some(String::new()),
257290 photo: None,
258291 },
259292 1,
@@ -262,8 +295,8 @@ mod tests {
262295
263296 let big_photo = serialize_payload(
264297 &Payload::Profile {
265− display_name: String::new(),
266− bio: String::new(),
298+ display_name: Some(String::new()),
299+ bio: Some(String::new()),
267300 photo: Some("p".repeat(MAX_PHOTO_LEN + 1)),
268301 },
269302 1,
@@ -297,8 +330,8 @@ mod tests {
297330 fn a_photoless_profile_omits_the_field() {
298331 let json = serialize_payload(
299332 &Payload::Profile {
300− display_name: "A".into(),
301− bio: "B".into(),
333+ display_name: Some("A".into()),
334+ bio: Some("B".into()),
302335 photo: None,
303336 },
304337 1,
@@ -309,8 +342,8 @@ mod tests {
309342 #[test]
310343 fn snake_case_field_names_on_wire() {
311344 let p = Payload::Profile {
312− display_name: "A".into(),
313− bio: "B".into(),
345+ display_name: Some("A".into()),
346+ bio: Some("B".into()),
314347 photo: None,
315348 };
316349 let json = serialize_payload(&p, 1);
server.knot+226−110
@@ -102,6 +102,14 @@ maxPollEvents = 50
102102-- Per-peer ceiling on outstanding forward attempts.
103103maxForwardsPerServer : Int
104104maxForwardsPerServer = 2000
105+-- S9: Cap on distinct remote servers a single message is immediately forwarded
106+-- to at send time. Separate from `maxForwardsPerServer` (which bounds outstanding
107+-- forwards *per peer*), this bounds the fan-out breadth. A recipient is unlikely
108+-- to be visible on more than 100 servers; the previous reuse of
109+-- `maxForwardsPerServer` (2000) for this purpose was an overload that could
110+-- spawn up to 2000 immediate HTTP forwards per message.
111+maxRemoteServersPerMessage : Int
112+maxRemoteServersPerMessage = 100
105113-- Global ceiling on the `*forwards` outbox. `maxForwardsPerServer` bounds any one
106114-- peer, but a recipient gossiped as present on many servers fans one blob out to
107115-- all of them, so the total is bounded only by (peers x per-peer cap). This caps
@@ -189,6 +197,15 @@ maxPresencePerServer = 5000
189197-- the same argument for sessions on `maxSessions`, and PROTOCOL.md §10.
190198maxMessagesPerRecipient : Int
191199maxMessagesPerRecipient = 10000
200+-- Global ceiling on total stored messages across all recipients (S6). Every
201+-- other state ref has a global cap (*sessions, *challenges, *presence,
202+-- *forwards); *messages had only a per-recipient cap, so total storage was
203+-- bounded only by (distinct recipient keys x maxMessagesPerRecipient), and an
204+-- attacker minting fresh invalid pubkeys could fill the disk (bounded only by
205+-- rate limits and the 30-day TTL). On overflow this evicts the globally-oldest
206+-- rows, symmetric with the other refs. See PROTOCOL.md §10.
207+maxMessagesTotal : Int
208+maxMessagesTotal = 1000000
192209-- Validity window for an auth challenge before it must be re-issued (1 min).
193210challengeExpiry : Int<Ms>
194211challengeExpiry = 60000
@@ -950,14 +967,32 @@ appendMessage = \toKey encryptedBlob t -> do
950967 -- for this recipient so the post-append count lands exactly on the ceiling.
951968 -- `- 1` reserves the slot the new row is about to take.
952969 --
953− -- Eviction keys on `encryptedBlob` rather than the row itself because
954− -- (toKey, encryptedBlob) is unique by this function's own dedup invariant
955− -- (the `when` guard above), so a blob identifies at most one row per key.
970+ -- S4: Instead of building an `evicted` list and checking membership (which
971+ -- is O(|messages| × |evicted|)), compute the eviction threshold — the
972+ -- receivedAt of the (maxMessagesPerRecipient - 1)th oldest message for this
973+ -- recipient — and keep only messages newer than it. This is O(|messages|)
974+ -- with no inner scan.
956975 let forRecipient = filter (\m -> m.toKey == toKey) msgs
957976 let excess = max 0 (count forRecipient - (maxMessagesPerRecipient - 1))
958− let evicted = map (\m -> m.encryptedBlob) (take excess (sortBy (\m -> m.receivedAt) forRecipient))
959− let kept = filter (\m -> not (m.toKey == toKey && elem m.encryptedBlob evicted)) msgs
960− *messages = union kept [{toKey, encryptedBlob, receivedAt: seqTs, deliveredTo: []}])
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
987+ -- Global cap (S6): after the per-recipient eviction, bound the total mailbox
988+ -- across all recipients by dropping the globally-oldest rows. Only runs on
989+ -- overflow, so the O(n log n) sort is off the hot path. Dropped rows may
990+ -- belong to other recipients; their orphaned *forwards are reclaimed by
991+ -- `pruneExpired`, and their lost `deliveredTo` is harmless (the blob is gone).
992+ let globallyCapped = if count kept > maxMessagesTotal
993+ then drop (count kept - maxMessagesTotal) (sortBy (\m -> m.receivedAt) kept)
994+ else kept
995+ *messages = union globallyCapped [{toKey, encryptedBlob, receivedAt: seqTs, deliveredTo: []}])
961996
962997-- Record that `server` has accepted a federated forward of this message, so a
963998-- later presence re-announce of `toKey` doesn't re-forward the same blob to a peer
@@ -1011,6 +1046,12 @@ pruneExpired = \t -> atomic do
10111046 -- Prune forwards whose message was removed by the TTL sweep above.
10121047 -- ForwardEntry carries no timestamp, so match against the surviving
10131048 -- 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.
10141055 fw <- *forwards
10151056 *forwards = filter (\f -> any (\m -> m.toKey == f.toKey && m.encryptedBlob == f.encryptedBlob) surviving) fw
10161057 ff <- *forwardFailures
@@ -1073,56 +1114,70 @@ sendBatchedGossip = \peers events -> case localServerName of
10731114-- comes online: any messages already buffered for that pubkey are enqueued
10741115-- as forwards to the newly-known server.
10751116
1076−-- Attempt every forward in `batch` to one peer, in order, and report whether any
1077−-- attempt was a genuine peer failure. Drops successful entries from `*forwards` and
1078−-- records the peer on the message's `deliveredTo`; on failure bumps the retry count
1079−-- and gives up after `maxForwardRetries`.
1117+-- Attempt every forward in `batch` to one peer, in order, and report both whether
1118+-- any attempt was a genuine peer failure (`anyFailed`) and whether any attempt
1119+-- genuinely succeeded (`anySucceeded`). Drops successful entries from `*forwards`
1120+-- and records the peer on the message's `deliveredTo`; every attempt (success,
1121+-- failure, or 404) bumps the retry count and gives up after `maxForwardRetries`.
10801122--
10811123-- INVARIANT — this must not touch `*forwardFailures`. The caller records at most one
1082−-- failure per peer per sweep, from the Bool returned here; recording per failing
1083−-- *message* would let one unreachable peer with N buffered messages take N backoff
1084−-- steps in a single sweep and pin itself at `retryBackoffMax` for `forwardFailureMaxAge`.
1085−-- The counter counts consecutive failed *sweeps*.
1124+-- failure per peer per sweep, from the `anyFailed` flag returned here; recording per
1125+-- failing *message* would let one unreachable peer with N buffered messages take N
1126+-- backoff steps in a single sweep and pin itself at `retryBackoffMax` for
1127+-- `forwardFailureMaxAge`. The counter counts consecutive failed *sweeps*.
10861128--
10871129-- A 404 is NOT a peer failure. `handleForwardMessage` answers 404 `no_presence`
10881130-- whenever the recipient is not *currently* online at that peer, which is an
10891131-- expected, retryable outcome (PROTOCOL.md §7) — the peer is healthy and answering.
1090−-- But `fetch` returns `Err` for every status >= 400, so treating any `Err` as a
1091−-- failure would feed a routine 404 into `recordFailure`, and `*forwardFailures` is
1092−-- shared with gossip: one stale-presence 404 would suppress all forwards *and* all
1093−-- gossip to that peer for a full backoff step (30 s–8 min). So a 404 leaves the peer
1094−-- unpenalized and the forward row untouched — no retry bump either, since the row is
1095−-- waiting on the recipient's presence, not on a delivery that went wrong.
1132+-- `fetch` returns `Err` for every status >= 400, so treating any `Err` as a failure
1133+-- would feed a routine 404 into `recordFailure`, and `*forwardFailures` is shared
1134+-- with gossip: one stale-presence 404 would suppress all forwards *and* all gossip to
1135+-- that peer for a full backoff step (30 s–8 min). So a 404 leaves the peer's backoff
1136+-- untouched (`anyFailed` stays false for it) and never clears it (`anySucceeded`
1137+-- stays false too).
1138+--
1139+-- But a 404 DOES bump the forward's retry count now (S1). It previously left the row
1140+-- entirely untouched, so a forward whose recipient never came online at the peer was
1141+-- retried every sweep for the full 30-day message TTL — the counter never advanced,
1142+-- so `maxForwardRetries` was never reached. Counting the attempt (without charging
1143+-- the peer's backoff) lets the forward hit the cap and be dropped after
1144+-- `maxForwardRetries` sweeps while still not penalizing a healthy peer.
10961145--
10971146-- Recursive rather than `forEach` because the result has to be accumulated across
10981147-- iterations; `sortBy` fixes the iteration order that `head`/`drop 1` then walk.
1099−retryForwardsTo : Text -> [ForwardEntry] -> IO {rw *forwards, rw *messages, network} Bool
1148+retryForwardsTo : Text -> [ForwardEntry] -> IO {rw *forwards, rw *messages, network} {anyFailed: Bool, anySucceeded: Bool}
11001149retryForwardsTo = \server batch -> case head batch of
1101− Nothing {} -> yield (False {})
1150+ Nothing {} -> yield {anyFailed: False {}, anySucceeded: False {}}
11021151 Just {value: f} -> do
11031152 result <- fetch (federationUrl server) (ForwardMessage {f.toKey, f.encryptedBlob})
11041153 let status = case result of
11051154 Err {error: e} -> e.status
11061155 Ok _ -> 200
11071156 let success = status < 400
1108− -- Recipient simply isn't online at the peer yet: hold the row, blame no one.
1157+ -- Recipient simply isn't online at the peer yet: hold the peer blameless, but
1158+ -- still count the attempt so the row can't outlive the message TTL (S1).
11091159 let deferred = status == 404
11101160 let isMatch = forwardMatches f.toKey f.encryptedBlob server
11111161 let isDone = \x -> isMatch x && (success || x.retries + 1 >= maxForwardRetries)
11121162 atomic (do
1113− when (not deferred) (do
1114− cur <- *forwards
1115− *forwards = map (\x -> if isMatch x
1116− then {x.toServer, x.toKey, x.encryptedBlob, retries: x.retries + 1}
1117− else x) (filter (\x -> not isDone x) cur))
1163+ cur <- *forwards
1164+ -- Count the attempt regardless of outcome: a 404 still consumes a retry slot
1165+ -- so a forward that never finds the recipient online is eventually dropped
1166+ -- (S1). On success or after `maxForwardRetries`, `isDone` filters the row out
1167+ -- first, so the increment then no-ops on an absent row.
1168+ *forwards = map (\x -> if isMatch x
1169+ then {x.toServer, x.toKey, x.encryptedBlob, retries: x.retries + 1}
1170+ else x) (filter (\x -> not isDone x) cur)
11181171 when success (markDelivered f.toKey f.encryptedBlob server))
1119− restFailed <- retryForwardsTo server (drop 1 batch)
1120− yield ((not success && not deferred) || restFailed)
1172+ rest <- retryForwardsTo server (drop 1 batch)
1173+ yield {anyFailed: (not success && not deferred) || rest.anyFailed, anySucceeded: success || rest.anySucceeded}
11211174
11221175-- Walk pending forwards grouped by destination and retry each peer's backlog (up
11231176-- to maxBatchSize per peer) via `/federation/forward`. One failure record per peer
1124−-- per sweep: a sweep in which every attempt succeeded clears the peer's backoff, a
1125−-- sweep with any failure takes exactly one backoff step (see `retryForwardsTo`).
1177+-- per sweep: a sweep with any genuine failure takes exactly one backoff step; a
1178+-- sweep with at least one genuine success clears the peer's backoff; an all-404
1179+-- sweep leaves the backoff untouched, neither charging nor clearing it (S1). See
1180+-- `retryForwardsTo`.
11261181retryPendingForwards : IO {rw *forwardFailures, rw *forwards, rw *messages, network, clock} {}
11271182retryPendingForwards = do
11281183 t <- now
@@ -1144,13 +1199,17 @@ retryPendingForwards = do
11441199 -- `maxForwardRetries` (dropped). `ForwardEntry` carries no timestamp, so key on
11451200 -- the unique blob.
11461201 let batch = take maxBatchSize (sortBy (\f -> f.encryptedBlob) (filter (\f -> f.toServer == server) fwds))
1147− anyFailed <- retryForwardsTo server batch
1148− -- `count batch > 0` guards the clear: `maxBatchSize` is startup-overridable,
1149− -- and at 0 every batch is empty — an empty sweep must not be read as a
1150− -- success and reset a failing peer's backoff.
1151− if anyFailed
1202+ result <- retryForwardsTo server batch
1203+ -- Clear the peer's backoff only on a sweep with at least one genuine success
1204+ -- (S1). An all-404 sweep (recipient not online at the peer) is neither a
1205+ -- failure nor a success: clearing backoff on it would reset the peer to
1206+ -- "fresh" every sweep, firing a retry every `backgroundPruneInterval` for the
1207+ -- full 30-day message TTL. `count batch > 0` also guards against reading an
1208+ -- empty sweep (possible with a startup-overridden `maxBatchSize` of 0) as
1209+ -- meaningful.
1210+ if result.anyFailed
11521211 then recordFailure server t
1153− else when (count batch > 0) (clearFailure server t)))
1212+ else when (count batch > 0 && result.anySucceeded) (clearFailure server t)))
11541213
11551214-- Forward queued messages to a server when a remote key comes online.
11561215-- Skips messages already delivered to `server` (tracked via Message.deliveredTo)
@@ -1173,6 +1232,32 @@ forwardQueuedToServer = \pubkey serverName -> atomic (do
11731232 let newFwds = map (\m -> {toServer: server, m.toKey, m.encryptedBlob, retries: 0}) (take budget (sortBy (\m -> m.receivedAt) candidates))
11741233 *forwards = capForwards (union fwds newFwds))
11751234
1235+-- Batched version of `forwardQueuedToServer`: stage forwards for *all* newly-online
1236+-- keys in a single mailbox scan (S4). Calling `forwardQueuedToServer` per key scans
1237+-- `*messages` once per key, which is why `handleRecvGossip` previously capped the
1238+-- number of keys at `maxNewlyOnlineForwards` (N13) — but that cap silently deferred
1239+-- delivery past the 10th key, and a re-announce within the TTL never recovered it
1240+-- (the `deliveredTo`/presence state made the key look already-handled). A single
1241+-- pass bounds the cost to O(|messages|) regardless of how many keys came online.
1242+--
1243+-- SECURITY: shares `forwardQueuedToServer`'s exposure — it routes on gossip-written
1244+-- presence. The same `deliveredTo` / `notYetForwarded` / per-peer budget guards
1245+-- apply; do not widen them without revisiting the gossip-redirect note above.
1246+forwardQueuedToAll : [PubkeyHex] -> ServerName -> IO {rw *forwards, r *messages} {}
1247+forwardQueuedToAll = \pubkeys serverName -> atomic (do
1248+ let server = (serverName : Text)
1249+ msgs <- *messages
1250+ fwds <- *forwards
1251+ let existing = countWhere (\f -> f.toServer == server) fwds
1252+ let budget = max 0 (maxForwardsPerServer - existing)
1253+ let notYetForwarded = \m -> not any (forwardMatches m.toKey m.encryptedBlob server) fwds
1254+ let notYetDelivered = \m -> not any (\d -> d.server == server) m.deliveredTo
1255+ -- `elem m.toKey pubkeys` is O(|pubkeys|), but `pubkeys` is bounded by
1256+ -- `maxGossipEvents` (100), so the scan stays O(|messages|) overall.
1257+ let candidates = filter (\m -> elem m.toKey pubkeys && notYetForwarded m && notYetDelivered m) msgs
1258+ let newFwds = map (\m -> {toServer: server, m.toKey, m.encryptedBlob, retries: 0}) (take budget (sortBy (\m -> m.receivedAt) candidates))
1259+ *forwards = capForwards (union fwds newFwds))
1260+
11761261-- ============================================
11771262-- Handler: challenge / verify
11781263-- ============================================
@@ -1223,68 +1308,82 @@ handleVerifyAuth = \pubkey challenge signature revokeOthers clientIp -> do
12231308 -- relay server cannot replay the signature to a different server. We verify
12241309 -- against our own configured hostname (port stripped), which a relay cannot forge.
12251310 --
1226− -- Signature verification is deferred until *after* the challenge lookup confirms
1227− -- a matching challenge exists (inside the atomic below). This avoids spending
1228− -- expensive Ed25519 CPU on /auth/verify calls that have no matching challenge,
1229− -- which would otherwise be a cheap CPU-DoS vector. The trade-off is that
1230− -- `verify` runs inside the STM transaction and may re-run on retries — but
1231− -- retries only happen on concurrent writes to *challenges/*sessions, which
1232− -- are rare, so the expected retry count is ~1.
1311+ -- Signature verification is split out of the session-install transaction (S7).
1312+ -- Phase 1 is a cheap read-only atomic that confirms a matching, unexpired,
1313+ -- IP-bound challenge exists — the original CPU-DoS guard, which still spends no
1314+ -- Ed25519 CPU on /auth/verify calls that have no matching challenge. Phase 2 runs
1315+ -- the expensive `verify` *outside* any atomic, so an STM retry on the phase-3
1316+ -- install transaction (triggered by concurrent writes to *challenges/*sessions)
1317+ -- never re-executes it — previously `verify` sat inside that transaction and every
1318+ -- retry re-ran the Ed25519 check. Phase 3 re-checks the challenge before consuming
1319+ -- it, closing the TOCTOU window opened between phases 1 and 3.
12331320 let signedText = "skrepka-auth-v1:" ++ authHost serverHost ++ ":" ++ challenge
1234− result <- atomic (do
1321+ challengeOk <- atomic (do
12351322 ch <- *challenges
12361323 let same = \c -> c.pubkey == pubkey && c.challenge == challenge
1237− let matchingChallenge = findFirst same ch
1238− case matchingChallenge of
1239− Nothing {} -> yield (Nothing {})
1240− Just {value: c} -> do
1241− if not (c.expiresAt > t && matchIp clientIp c.ip)
1242− then yield (Nothing {})
1243− else do
1244− -- Verify the signature only now that a matching, unexpired, IP-bound
1245− -- challenge exists.
1246− let sigValid = case bytesFromHex pubkey of
1247− Just {value: pk} -> case bytesFromHex signature of
1248− Just {value: sigBytes} -> verify pk (textToBytes signedText) sigBytes
1249− _ -> False {}
1250− _ -> False {}
1251− if not sigValid
1252− then yield (Nothing {})
1253− else do
1254− -- Consume the challenge and optionally revoke other sessions + challenges.
1255− -- When `revokeOthers` is set, drop all other sessions AND outstanding
1256− -- challenges for this pubkey, so a captured challenge cannot be
1257− -- redeemed for a new session after revocation (Q35).
1258− *challenges = if revokeOthers
1259− then filter (\x -> not (x.pubkey == pubkey)) ch
1260− else filter (\x -> not same x) ch
1261− ses <- *sessions
1262− let pruned = if revokeOthers
1263− then filter (\s -> s.pubkey != pubkey) ses
1264− else capPerKey ses pubkey maxSessionsPerKey
1265− let newSession = {token, pubkey, expiresAt: expiry, ip: clientIp}
1266− -- LRU-evicts the soonest-to-expire session when the pool is full; this global
1267− -- eviction is Sybil-floodable, see the LIMITATION note on `maxSessions`.
1268− let pooled = union pruned [newSession]
1269− -- Count what `capList` is about to shed, so the handler can warn about it
1270− -- below. That flood has no other observable signature: evicted users just
1271− -- start seeing 401s and silently re-authenticate, which looks like normal
1272− -- traffic in the access log. A sustained nonzero rate here is the alert.
1273− let evicted = max 0 (count pooled - maxSessions)
1274− *sessions = capList pooled maxSessions
1275− peers <- refreshLocalPresence pubkey expiry
1276− yield (Just {value: {peers, evicted}}))
1277− case result of
1278− Nothing {} -> yield (Err {error: {status: 401, message: "invalid"}})
1279− Just {value: r} -> do
1280− -- Warn outside the `atomic` block, never inside: an STM transaction can
1281− -- retry, and a console write is not rolled back with it — logging inside
1282− -- would emit one line per retry attempt rather than one per eviction.
1283− -- Include the requesting pubkey and IP for audit (Q38): an operator
1284− -- investigating a Sybil flood needs to know which identities are responsible.
1285− when (r.evicted > 0) (logWarn ("session pool full: evicted " ++ show r.evicted ++ " session(s) to seat a new login (pubkey=" ++ pubkey ++ ", ip=" ++ clientIp ++ ", maxSessions=" ++ show maxSessions ++ ")"))
1286− when federationEnabled (fork (sendBatchedGossip r.peers [{eventType: "online", pubkey: Just {value: pubkey}}]))
1287− yield (Ok {value: {token, expiresAt: expiry}})
1324+ case findFirst ch same of
1325+ Nothing {} -> yield (False {})
1326+ Just {value: c} -> yield (c.expiresAt > t && matchIp clientIp c.ip))
1327+ if not challengeOk
1328+ then yield (Err {error: {status: 401, message: "invalid"}})
1329+ else do
1330+ -- Phase 2: verify OUTSIDE any atomic. Expensive, and must not be re-executed on
1331+ -- an STM retry of the install transaction below (S7).
1332+ let sigValid = case bytesFromHex pubkey of
1333+ Just {value: pk} -> case bytesFromHex signature of
1334+ Just {value: sigBytes} -> verify pk (textToBytes signedText) sigBytes
1335+ _ -> False {}
1336+ _ -> False {}
1337+ if not sigValid
1338+ then yield (Err {error: {status: 401, message: "invalid"}})
1339+ else do
1340+ -- Phase 3: re-check the challenge (it may have expired or been consumed by a
1341+ -- concurrent verify since phase 1), consume it, and install the session. The
1342+ -- body is cheap, so an STM retry here costs almost nothing.
1343+ result <- atomic (do
1344+ ch <- *challenges
1345+ let same = \c -> c.pubkey == pubkey && c.challenge == challenge
1346+ let matchingChallenge = findFirst ch same
1347+ case matchingChallenge of
1348+ Nothing {} -> yield (Nothing {})
1349+ Just {value: c} -> do
1350+ if not (c.expiresAt > t && matchIp clientIp c.ip)
1351+ then yield (Nothing {})
1352+ else do
1353+ -- Consume the challenge and optionally revoke other sessions + challenges.
1354+ -- When `revokeOthers` is set, drop all other sessions AND outstanding
1355+ -- challenges for this pubkey, so a captured challenge cannot be
1356+ -- redeemed for a new session after revocation (Q35).
1357+ *challenges = if revokeOthers
1358+ then filter (\x -> not (x.pubkey == pubkey)) ch
1359+ else filter (\x -> not same x) ch
1360+ ses <- *sessions
1361+ let pruned = if revokeOthers
1362+ then filter (\s -> s.pubkey != pubkey) ses
1363+ else capPerKey ses pubkey maxSessionsPerKey
1364+ let newSession = {token, pubkey, expiresAt: expiry, ip: clientIp}
1365+ -- LRU-evicts the soonest-to-expire session when the pool is full; this global
1366+ -- eviction is Sybil-floodable, see the LIMITATION note on `maxSessions`.
1367+ let pooled = union pruned [newSession]
1368+ -- Count what `capList` is about to shed, so the handler can warn about it
1369+ -- below. That flood has no other observable signature: evicted users just
1370+ -- start seeing 401s and silently re-authenticate, which looks like normal
1371+ -- traffic in the access log. A sustained nonzero rate here is the alert.
1372+ let evicted = max 0 (count pooled - maxSessions)
1373+ *sessions = capList pooled maxSessions
1374+ peers <- refreshLocalPresence pubkey expiry
1375+ yield (Just {value: {peers, evicted}}))
1376+ case result of
1377+ Nothing {} -> yield (Err {error: {status: 401, message: "invalid"}})
1378+ Just {value: r} -> do
1379+ -- Warn outside the `atomic` block, never inside: an STM transaction can
1380+ -- retry, and a console write is not rolled back with it — logging inside
1381+ -- would emit one line per retry attempt rather than one per eviction.
1382+ -- Include the requesting pubkey and IP for audit (Q38): an operator
1383+ -- investigating a Sybil flood needs to know which identities are responsible.
1384+ when (r.evicted > 0) (logWarn ("session pool full: evicted " ++ show r.evicted ++ " session(s) to seat a new login (pubkey=" ++ pubkey ++ ", ip=" ++ clientIp ++ ", maxSessions=" ++ show maxSessions ++ ")"))
1385+ when federationEnabled (fork (sendBatchedGossip r.peers [{eventType: "online", pubkey: Just {value: pubkey}}]))
1386+ yield (Ok {value: {token, expiresAt: expiry}})
12881387
12891388-- ============================================
12901389-- Handler: poll
@@ -1323,11 +1422,16 @@ handlePoll = \cursor authorization clientIp -> do
13231422 -- so peers don't time us out mid-session.
13241423 let stale = not any (\p -> p.pubkey == pubkey && isLocalServer p.server && p.expiresAt - t >= sessionExpiry / 2) pres
13251424 refreshPeers <- if stale then refreshLocalPresence pubkey (t + sessionExpiry) else yield []
1326− yield (Just {value: {pubkey, clampedCursor, refreshPeers}}))
1425+ -- S3: Track whether we actually refreshed so the gossip line below
1426+ -- only fires on a refresh, not on every poll. Without this, every
1427+ -- poll gossips to all seed peers via gossipTargets [], creating
1428+ -- O(clients × seeds) federation traffic per poll cycle.
1429+ yield (Just {value: {pubkey, clampedCursor, refreshPeers, didRefresh: stale}}))
13271430 case setup of
13281431 Nothing {} -> yield (Err {error: {status: 401, message: "unauthorized"}})
13291432 Just {value: s} -> do
1330− when federationEnabled (fork (sendBatchedGossip s.refreshPeers [{eventType: "online", pubkey: Just {value: s.pubkey}}]))
1433+ -- S3: Only gossip when we actually refreshed presence — not on every poll.
1434+ when (federationEnabled && s.didRefresh) (fork (sendBatchedGossip s.refreshPeers [{eventType: "online", pubkey: Just {value: s.pubkey}}]))
13311435 pollResult <- doPoll s t
13321436 yield (Ok {value: pollResult})
13331437
@@ -1438,10 +1542,15 @@ handleSendMessages = \messages authorization clientIp -> do
14381542 Nothing {} -> do
14391543 committed <- atomic (do
14401544 pres <- *presence
1545+ -- S2: cap the immediate fan-out at `maxForwardsPerServer` servers per
1546+ -- recipient. The post-commit `forkForwardsTo` fires against this list
1547+ -- directly (bypassing the `*forwards` queue and its per-peer cap), so
1548+ -- without a bound here a recipient gossiped as present on thousands of
1549+ -- servers would spawn one immediate HTTP forward per server per message.
14411550 let staged = map (\m -> {
14421551 m.to,
14431552 m.encryptedBlob,
1444− remoteServers: collectPeers (filter (\p -> p.pubkey == m.to && p.expiresAt > t) pres) (\p -> p.server)
1553+ remoteServers: take maxRemoteServersPerMessage (collectPeers (filter (\p -> p.pubkey == m.to && p.expiresAt > t) pres) (\p -> p.server))
14451554 }) messages
14461555 forEach staged (\c -> do
14471556 -- Read `*messages` *before* the append: `delivered` below must see the
@@ -1546,7 +1655,7 @@ forkForwardsTo = \to encryptedBlob servers -> forEach servers (\server -> when (
15461655-- fromServer) row touched by this batch, then re-adds fresh-TTL rows for
15471656-- pubkeys with an `online` event (so Online wins on collision). For keys
15481657-- that transition offline → online, flushes any locally-buffered messages via
1549−-- `forwardQueuedToServer`. Events with an unrecognized `eventType` or no
1658+-- `forwardQueuedToAll`. Events with an unrecognized `eventType` or no
15501659-- `pubkey` are skipped (forward-compat) rather than rejecting the batch.
15511660--
15521661-- SECURITY: this is the gossip-redirect ingress described in the section header
@@ -1557,7 +1666,7 @@ handleRecvGossip : [GossipEvent] -> ServerName -> IO {r *messages, rw *forwards,
15571666handleRecvGossip = \events fromServer -> do
15581667 t <- now
15591668 -- Widen the peer-supplied `ServerName` once (see `type ServerName`);
1560− -- `forwardQueuedToServer` below still takes the refined name.
1669+ -- `forwardQueuedToAll` below still takes the refined name.
15611670 let from = (fromServer : Text)
15621671 if not federationEnabled
15631672 then yield (Err {error: {status: 403, message: "federation_disabled"}})
@@ -1598,21 +1707,28 @@ handleRecvGossip = \events fromServer -> do
15981707 let localRows = filter (\p -> isLocalServer p.server) merged
15991708 let remoteRows = filter (\p -> not isLocalServer p.server) merged
16001709 *presence = union localRows (capList (capPerServer remoteRows from maxPresencePerServer) maxPresence)
1710+ -- S8: Make online+offline for the same key mutually exclusive within one
1711+ -- batch. If a batch carries both events for a key, online wins — the presence
1712+ -- merge above already added the online row — so that key must NOT also travel
1713+ -- the offline path and cancel its own freshly-staged forwards.
1714+ let onlineKeys = map (\p -> p.pubkey) collected.added
1715+ let offlineOnly = filter (\pk -> not elem pk onlineKeys) collected.offline
16011716 -- N14: Cancel pending forwards to `from` for offline keys. Without this,
16021717 -- forwards to a peer that just went offline retry for up to 80 minutes,
16031718 -- wasting maxForwardsPerServer slots and generating failed HTTP requests.
16041719 -- The message stays in *messages, so it's re-forwarded if the key
16051720 -- re-appears on another peer.
1606− when (count collected.offline > 0) (do
1721+ when (count offlineOnly > 0) (do
16071722 fwds <- *forwards
1608− *forwards = filter (\f -> not (f.toServer == from && elem f.toKey collected.offline)) fwds)
1723+ *forwards = filter (\f -> not (f.toServer == from && elem f.toKey offlineOnly)) fwds)
16091724 yield (filter (\pk -> not any (\p -> p.pubkey == pk && p.server == from && p.expiresAt > t) pres) (map (\p -> p.pubkey) collected.added)))
1610− -- N13: Cap the number of newly-online keys that trigger forward sweeps.
1611− -- Without this, 100 online events = 100 mailbox scans. The remaining keys
1612− -- are still in `*presence` — their forwards will be staged when a poll
1613− -- refreshes presence or the next gossip re-announces them.
1614− let toForward = take maxNewlyOnlineForwards newlyOnline
1615− fork (forEach toForward (\pk -> forwardQueuedToServer pk fromServer))
1725+ -- S4: forward queued mail for ALL newly-online keys in one mailbox pass. The
1726+ -- old per-key cap (maxNewlyOnlineForwards, N13) bounded amplification but
1727+ -- silently deferred delivery past the 10th key, and a re-announce within the
1728+ -- TTL never recovered it (the deliveredTo/presence state made the key look
1729+ -- already-handled). `forwardQueuedToAll` bounds the cost to O(|messages|)
1730+ -- regardless of how many keys came online.
1731+ fork (forwardQueuedToAll newlyOnline fromServer)
16161732 yield (Ok {value: {}})
16171733
16181734-- POST /federation/forward: receive a single federated message destined for
server.onew file

Binary file not shown.