on master help sign in
19 files changed+2027−323
since splitsnapshots
unifiedsplit
.github/workflows/build.yml+1−1 AGENTS.md+2−2 AUDIT-DEEP.md+230 AUDIT-FFI-PHONEMIC-APPLE.md+470 AUDIT-FRESH.md+385 PROTOCOL.md+34−19 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+209−52 core/src/crypto.rs+190−65 core/src/ffi.rs+5 core/src/phonemic.rs+8−3 core/src/protocol.rs+64−31 install.sh+13−9 server.knot+260−107 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.
AUDIT-FRESH.mdnew file+385
@@ -0,0 +1,385 @@
1+# Skrepka Security Audit — Fresh Pass (2026-07-15)
2+
3+**Scope:** Exhaustive multi-pass audit of PROTOCOL.md, server.knot, core/src/ (app.rs,
4+crypto.rs, protocol.rs, model.rs, phonemic.rs, ffi.rs), apple/SkrepkaApp/ (Effects.swift,
5+Core.swift, Views.swift, Forms.swift), install.sh. Findings below are **not** in
6+PROTOCOL-REVIEW.md or AUDIT-NEW.md, or are new angles on previously-noted areas.
7+
8+**Legend:** 🔴 critical · 🟠 high · 🟡 medium · 🔵 low · ⚪ info
9+
10+---
11+
12+## 1. Cryptographic Protocol
13+
14+### 🔴 N1. No AAD on AEAD — envelope `to` field is unauthenticated, enabling silent targeted censorship by any relay
15+
16+**Type:** Design gap (protocol + crypto.rs)
17+**Files:** `core/src/crypto.rs:384-387` (encrypt), `core/src/crypto.rs:434-438` (decrypt)
18+
19+AUDIT-NEW.md Q2 notes this but downgrades the impact to "equivalent to message loss, which is
20+an accepted limitation." That understates it. The real issue is **silent, targeted message loss
21+with zero detectability**:
22+
23+A relay holding a victim's messages can swap the `to` field on a *specific* message (from a
24+sender the relay wants to censor), while leaving other messages intact. The recipient never
25+sees the message and gets no error. The `delivery.ack` system doesn't help because the recipient
26+never received the message to ack it, and the sender's UI shows the message as "sent"
27+indefinitely — which is the same behavior as "the recipient is offline" or "the recipient read
28+it but hasn't acked yet." There is **no way for either party to distinguish "the relay ate
29+Alice's message" from "Alice didn't send anything" or "Bob is just slow to ack."**
30+
31+This is worse than the generic "relay can drop messages" case. Dropping is *eventually*
32+detectable — the sender sees their message stuck at "sent" and can investigate. But `to`-swapping
33+moves the message to a *different recipient's* mailbox, where it's undecryptable and gets
34+silently dropped on poll. The original recipient's cursor is unaffected (no events), so the
35+next poll is normal. The censorship is invisible.
36+
37+**Attack scenario:**
38+1. Alice sends a message to Bob via relay R.
39+2. R swaps `to` from Bob's pubkey to Charlie's.
40+3. Message stored under Charlie's mailbox. Charlie can't decrypt (wrong key), drops silently.
41+4. Bob polls, gets nothing. Normal poll, no error.
42+5. Alice's UI shows "sent" — same as if Bob were merely offline.
43+6. No delivery.ack ever comes. Alice can't tell if Bob received it or not.
44+
45+The relay can do this selectively — censor messages from specific senders, or suppress specific
46+content patterns (inferred from blob size), while delivering everything else normally. The
47+censorship is undetectable because the sender sees the same "sent" status they'd see for any
48+offline recipient, and the recipient sees nothing unusual.
49+
50+**Suggested fix:** Include `version_byte || ephemeral_public || nonce || recipient_ed25519_public`
51+as AAD in the AEAD. The `to` field must match `recipient_ed25519_public` in the AAD. A relay
52+swapping `to` causes the wrong recipient to fail AEAD decryption (before signature check), and
53+the blob is dropped before cursor advancement. The *original* recipient's message stays in the
54+mailbox. Wire-format change (v0x02).
55+
56+### ⚪ N2. HKDF info string and wire version byte are independent constants
57+
58+**Type:** Code hygiene (crypto.rs)
59+**Files:** `core/src/crypto.rs:28` (`HKDF_INFO = b"skrepka-v1"`), `crypto.rs:33` (`WIRE_VERSION = 1`)
60+
61+The spec says "A future revision that changes the crypto primitives should bump both the version
62+byte and the HKDF `info` string together." But there's no code-level binding between them. A
63+developer bumping `WIRE_VERSION` to `2` could forget to update `HKDF_INFO`, causing two different
64+crypto versions to derive keys with the same KDF label.
65+
66+**Suggested fix:** Derive the info string from the version byte or add a compile-time assertion
67+linking them.
68+
69+---
70+
71+## 2. Server Implementation
72+
73+### 🟡 N3. `appendMessage` dedup scan is O(n) — identical-blob replay amplifies to O(n × batch_size)
74+
75+**Type:** Implementation concern / DoS amplification (server.knot)
76+**Files:** `server.knot:919-937`
77+
78+AUDIT-NEW.md Q7 notes the O(n) dedup scan. The amplification is worse than described for
79+identical-blob replay: a batch of 100 identical blobs to the same recipient does 100 × O(|messages|)
80+scans. With |messages| = 10,000, that's 1M comparisons per request. At 60 requests/min, that's
81+60M comparisons/min. Each comparison is a string equality on 64-char hex pubkeys + 346+ char
82+hex blobs.
83+
84+**Attack:** An attacker authenticates (free keypair) and sends batches of 100 identical
85+min-size blobs to one recipient. Each request is ~35 KB but triggers 1M comparisons. Bounded
86+by the 60/min rate limit, but the CPU amplification is ~1000x per request.
87+
88+**Suggested fix:** Index `*messages` by `(toKey, encryptedBlob)` or use a hash-based dedup set.
89+
90+### 🟡 N4. `capForwards` eviction is non-deterministic when retry counts are equal
91+
92+**Type:** Implementation bug (server.knot)
93+**Files:** `server.knot:515-522` (`capForwards`)
94+
95+AUDIT-NEW.md Q9 notes this. The sort by `retries` only (no secondary sort key) means when many
96+entries share `retries: 0`, the sort is unstable and any retries-0 entry can be dropped —
97+including a fresh forward for a healthy peer that would have succeeded on the first try.
98+
99+**Impact:** Delivery delay, not permanent loss (the message stays in `*messages` and can be
100+re-forwarded on the next presence announce). But it's a real availability issue under
101+federation abuse.
102+
103+**Suggested fix:** Sort by `(retries, encryptedBlob)` for determinism, or skip entries for
104+peers in backoff before capping.
105+
106+### 🟡 N5. `handleRecvGossip` `fromServer` not verified against connecting peer's address
107+
108+**Type:** Design gap (server.knot)
109+**Files:** `server.knot:1533-1578`
110+
111+AUDIT-NEW.md Q29 notes this. The `fromServer` field is self-asserted and used to route forwarded
112+messages. An attacker can claim any `fromServer` hostname. The server writes presence rows with
113+that hostname and forwards queued messages there. This is the root cause of the gossip-redirect
114+attack and is already documented, but the spec's §10 should state more prominently that **the
115+server has no mechanism to verify that the connecting peer is who it claims to be**.
116+
117+The `fromServer` field is a `ServerName` (format-validated), but format validation only checks
118+length, lowercase, and no trailing dot — it does not verify the peer controls that hostname.
119+
120+**Suggested fix:** Document more prominently. Real fix needs reverse-DNS verification or peer
121+authentication (signed gossip).
122+
123+### 🔵 N6. `genToken` generates one hex digit per `randomInt 16` call — entropy waste
124+
125+**Type:** Implementation concern (server.knot)
126+**Files:** `server.knot:532-537`
127+
128+AUDIT-NEW.md Q10 notes the CSPRNG dependency. Additionally, generating one hex digit at a time
129+is inefficient — each `randomInt 16` call should produce at least 4 bits, but if the runtime's
130+`randomInt` draws from a wider source (e.g., 32-bit `getrandom`), it wastes 28 bits per call.
131+48 calls × 28 wasted bits = 1344 bits wasted per token. Not a security issue (entropy is
132+abundant) but inefficient.
133+
134+**Verdict:** Info.
135+
136+---
137+
138+## 3. Client Core Logic
139+
140+### 🟡 N7. `seen_ids` dedup set is unbounded — grows without limit per peer
141+
142+**Type:** Implementation concern (app.rs)
143+**Files:** `core/src/app.rs:325` (`seen_ids: BTreeMap<String, HashSet<String>>`)
144+
145+The `seen_ids` map (Q18 fix) is persisted to kv and loaded at startup. It's a `BTreeMap<String,
146+HashSet<String>>` — one entry per peer, each a set of message IDs. The set is never trimmed.
147+Over time, a peer who sends thousands of messages accumulates thousands of IDs. The kv blob
148+(`seen_ids`) grows without bound.
149+
150+With `MAX_ID_LEN` = 128 chars and UUIDs = 36 chars, 1000 messages per peer (the conversation cap)
151+= 36 KB per peer. With 500 contacts (`MAX_CONTACTS`), that's 18 MB. The `seen_ids` kv blob is
152+rewritten in full on every conversation touch (line 2316), so a large set makes every message
153+receipt slower. On iOS, a 18 MB JSON blob written to Application Support on every incoming
154+message is a real performance issue.
155+
156+**Suggested fix:** Cap the `seen_ids` set per peer (e.g., keep the last 2000 IDs) and trim
157+periodically. Or use a time-based expiry. Or a Bloom filter with a fixed size.
158+
159+### 🟡 N8. `ingest_poll` advances cursor before processing events — crash between advance and persist loses messages
160+
161+**Type:** Implementation concern (app.rs)
162+**Files:** `core/src/app.rs:2024-2089`
163+
164+The cursor is advanced to `page.cursor` on line 2038 (before the event loop), and restored to
165+`prev_cursor` on budget exhaustion (line 2086) or event count overflow (line 2069). The cursor
166+is persisted to kv AFTER `ingest_poll` returns (line 1369), via a `KeyValue::set` effect. This
167+means the cursor write only happens after the event loop completes successfully.
168+
169+However, there's a crash window: if the app crashes between `model.cursor = clamped_cursor`
170+(line 2038) and the cursor persist effect (line 1369), the in-memory cursor is advanced but
171+the persisted cursor is still `prev_cursor`. On relaunch, the client sends `prev_cursor` to
172+the relay, which re-serves the events. No message loss — the persisted cursor is the safe one.
173+
174+**Verdict:** Safe. The cursor is only authoritative when persisted, and the persist happens
175+after `ingest_poll` completes. A crash during `ingest_poll` loses no data because the persisted
176+cursor hasn't changed. Info.
177+
178+### 🟡 N9. `ingest_poll` budget exhaustion drops remaining events — client is stuck if the relay always serves an oversized page
179+
180+**Type:** Implementation concern (app.rs)
181+**Files:** `core/src/app.rs:2054-2089`
182+
183+When the `MAX_POLL_TOTAL_BYTES` budget is exhausted mid-page, the loop breaks, the cursor is
184+restored to `prev_cursor`, and an error is set. The next poll sends `prev_cursor` again, and
185+the relay re-serves the same events. If the page always exceeds the budget (e.g., the relay
186+serves 50 large blobs and the budget only covers 10), the client is stuck: poll, process 10,
187+drop 40, re-poll, relay serves the same 50, process the same 10 (deduped, fast), drop 40, etc.
188+
189+The dedup makes re-processing fast (already-seen IDs are O(1) drops), but the client never
190+advances past the oversized page. The relay's events are served in `receivedAt` order, so the
191+same 50 events come back every time. The cursor never moves. The client is stuck in a loop.
192+
193+This is a **livelock**: the client polls, processes some events, but can never ack them. The
194+relay keeps serving the same page. The client's error message ("relay sent an oversized poll
195+page") is set but may not be visible in the UI.
196+
197+**Attack scenario:** A malicious relay serves a page of 50 blobs, each just under `MAX_BLOB_LEN`
198+(42 MiB). The total page is ~2 GiB, far exceeding the 64 MiB budget. The client processes a few,
199+drops the rest, and is stuck. Or more subtly: the relay serves 50 blobs that are each ~1.3 MiB
200+(total ~65 MiB, just over the 64 MiB budget). The client processes 49, drops 1, and is stuck
201+because the cursor never advances past the 50th event.
202+
203+**Suggested fix:** When the budget is exhausted, advance the cursor to the last *successfully
204+processed* event's `receivedAt` (not `page.cursor` and not `prev_cursor`). This acks the
205+processed events and lets the client move forward, dropping only the unprocessed tail. The
206+relay re-serves only the unprocessed events on the next poll. This changes the behavior from
207+"all-or-nothing cursor advance" to "partial ack."
208+
209+### 🟡 N10. Profile `ts` staleness check uses `>=` — equal-timestamp replay is a no-op, but the check is per-contact, not per-field
210+
211+**Type:** Implementation concern (app.rs)
212+**Files:** `core/src/app.rs:2174-2179` (approximate)
213+
214+AUDIT-NEW.md Q19 analyzes this and concludes it's safe because the attacker can't modify the
215+content without breaking the signature. I agree — the `>=` is correct and a same-`ts` replay
216+is a true no-op (same ciphertext, same signature, same content). Info.
217+
218+---
219+
220+## 4. Wire Format / Parsing
221+
222+### 🟡 N11. `protocol.rs` `parse_payload` does not validate `ts` is positive — negative `ts` clamped to 0 is harmless but surprising
223+
224+**Type:** Implementation concern (protocol.rs)
225+**Files:** `core/src/protocol.rs:100`
226+
227+AUDIT-NEW.md Q24 notes this. A `ts` of 0 or negative is clamped to `max(0, ...)` in `ingest_poll`.
228+A message with `ts = 0` is inserted at the earliest chronological position and trimmed first.
229+This is harmless. Info.
230+
231+### 🟡 N12. `delivery.ack` parsing accepts non-string entries in `ack_ids` — filtered silently, no length check on individual IDs after filtering
232+
233+**Type:** Implementation concern (protocol.rs)
234+**Files:** `core/src/protocol.rs:116-126`
235+
236+AUDIT-NEW.md Q26 notes this. The double-check (raw count + post-filter count) is correct.
237+Non-string entries are filtered. Individual ID length is checked (`ack_ids.iter().any(|id|
238+id.len() > MAX_ID_LEN)`). This is correct. Info.
239+
240+---
241+
242+## 5. Federation
243+
244+### 🟠 N13. Gossip amplification: 100 online events trigger 100 mailbox scans + forward staging — O(100 × |messages|) per request
245+
246+**Type:** Design gap (server.knot)
247+**Files:** `server.knot:1544-1578` (`handleRecvGossip`)
248+
249+AUDIT-NEW.md Q27 notes this. A single gossip request with 100 `online` events for 100 different
250+keys triggers `forwardQueuedToServer` for each key, which scans the entire mailbox. With
251+|messages| = 10,000, that's 100 × 10,000 = 1M comparisons per request. At 60/min per IP, that's
252+60M comparisons/min. An attacker with 10 IPs can do 600M comparisons/min.
253+
254+Additionally, each `forwardQueuedToServer` call stages forward entries, which consume
255+`maxForwardsPerServer` slots and trigger HTTP POSTs to the attacker's server. The attacker
256+receives copies of the victim's ciphertext.
257+
258+**Suggested fix:** Limit the number of newly-online keys per gossip batch that trigger forwards
259+(e.g., cap at 10). Or batch the mailbox scan across all newly-online keys in one pass.
260+
261+### 🟡 N14. `offline` gossip events don't cancel pending forwards — wasted retries for up to 80 minutes
262+
263+**Type:** Design gap (server.knot)
264+**Files:** `server.knot:1535-1541`
265+
266+AUDIT-NEW.md Q28 notes this. When an `offline` event is received, pending forwards to that server
267+are not cancelled. They retry for up to `retryBackoffMax × maxForwardRetries` = 8 min × 10 = 80
268+minutes, wasting `maxForwardsPerServer` slots and generating failed HTTP requests.
269+
270+If the offline event was spoofed, the forwards continue — and if the key is actually still
271+online at that server, the forward succeeds on retry. The impact is minimal.
272+
273+**Suggested fix:** When processing an `offline` event, optionally drop pending forwards to that
274+server for the offline key. But this could cause message loss if the offline event is spoofed.
275+Better to leave as-is and document.
276+
277+---
278+
279+## 6. Deployment / install.sh
280+
281+### 🟡 N15. `install.sh` downloads binary over HTTPS but checksum is best-effort — missing checksum silently proceeds
282+
283+**Type:** Deployment concern (install.sh)
284+**Files:** `install.sh:150-181`
285+
286+The checksum verification is best-effort: if the `.sha256` file doesn't exist (line 162 fails),
287+the script warns and proceeds. If `sha256sum`/`shasum` is not available, it warns and proceeds.
288+A checksum that exists but doesn't match is fatal (line 169-174) — correct.
289+
290+The concern: a compromised release asset without a published `.sha256` file (e.g., a pre-checksum
291+release, or a supply-chain attack that removes the checksum file) would be installed without
292+verification. The script trusts GitHub's HTTPS, but HTTPS authenticates the CDN, not the
293+artifact. A compromised GitHub release would sail through.
294+
295+**Suggested fix:** Make the checksum mandatory for all new installs. Keep the best-effort
296+behavior only for upgrades from pre-checksum releases, with a prominent warning.
297+
298+### ⚪ N16. `install.sh` DOMAIN validation rejects uppercase but the Caddyfile interpolation is safe
299+
300+**Type:** Deployment review (install.sh)
301+**Files:** `install.sh:34-45`
302+
303+DOMAIN is lowercased and stripped of scheme/path/port before interpolation into the Caddyfile
304+and systemd unit. The validation rejects anything outside `[a-z0-9.-]`. This prevents config
305+injection (a stray quote or semicolon in the Caddyfile). Good.
306+
307+**Verdict:** Safe. Info.
308+
309+---
310+
311+## 7. Apple Shell
312+
313+### ⚪ N17. URLSession redirect cancellation is correct — bearer token exfiltration via 302 is prevented
314+
315+**Type:** Implementation review (Effects.swift)
316+**Files:** `apple/SkrepkaApp/Effects.swift:8-30`
317+
318+The `NoRedirectDelegate` cancels all HTTP redirects. This prevents a hostile relay from
319+answering `/poll` or `/messages` with a 302 to an attacker-controlled host, which would replay
320+the `Authorization: Bearer` header to the redirect target. The delegate returns `nil`, which
321+hands the 3xx response to the caller as a non-2xx status. The core treats it as a failed
322+request. Correct.
323+
324+**Verdict:** Safe. Good defense.
325+
326+### ⚪ N18. Keychain uses `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` — key never rides backup
327+
328+**Type:** Implementation review (Effects.swift)
329+**Files:** `apple/SkrepkaApp/Effects.swift:352`
330+
331+The identity key is stored with `ThisDeviceOnly` accessibility, meaning it never enters iCloud
332+or iTunes backups. The key is accessible after first unlock (not only when unlocked), so
333+background polling works. This is the correct accessibility class for a messaging identity key.
334+
335+**Verdict:** Safe. Good practice.
336+
337+---
338+
339+## Summary of New Findings
340+
341+| # | Severity | Type | Title |
342+|---|----------|------|-------|
343+| N1 | 🔴 critical | Design gap | No AAD — `to` field unauthenticated, silent targeted censorship |
344+| N2 | ⚪ info | Code hygiene | HKDF info and wire version byte are independent constants |
345+| N3 | 🟡 medium | DoS | appendMessage dedup O(n) — identical-blob replay amplifies to O(n × batch) |
346+| N4 | 🟡 medium | Impl bug | capForwards non-deterministic eviction when retry counts equal |
347+| N5 | 🟡 medium | Design gap | fromServer not verified against connecting peer address |
348+| N6 | 🔵 low | Info | genToken entropy waste (one digit per call) |
349+| N7 | 🟡 medium | Impl concern | seen_ids dedup set unbounded — grows without limit |
350+| N8 | ⚪ info | Safe | Cursor crash window — persisted cursor is safe |
351+| N9 | 🟡 medium | Impl concern | Budget exhaustion livelock — client stuck if relay always serves oversized page |
352+| N10 | ⚪ info | Safe | Profile ts >= replay is a no-op |
353+| N11 | ⚪ info | Safe | Negative ts clamped to 0 is harmless |
354+| N12 | ⚪ info | Safe | ack_ids non-string filtering is correct |
355+| N13 | 🟠 high | Design gap | Gossip amplification 100x — O(100 × |messages|) per request |
356+| N14 | 🟡 medium | Design gap | Offline events don't cancel pending forwards |
357+| N15 | 🟡 medium | Deploy | install.sh checksum is best-effort — missing checksum silently proceeds |
358+| N16 | ⚪ info | Safe | install.sh DOMAIN validation is correct |
359+| N17 | ⚪ info | Safe | Redirect cancellation prevents token exfiltration |
360+| N18 | ⚪ info | Safe | Keychain ThisDeviceOnly is correct |
361+
362+**Critical (1):** N1
363+**High (1):** N13
364+**Medium (6):** N3, N4, N5, N7, N9, N14, N15
365+**Low (1):** N6
366+**Info (7):** N2, N8, N10, N11, N12, N16, N17, N18
367+
368+---
369+
370+## Priorities
371+
372+1. **N1 (critical):** Add AAD to AEAD. This is the most impactful finding — it enables
373+ undetectable censorship by any relay. Requires wire-format change (v0x02).
374+
375+2. **N13 (high):** Document or limit gossip amplification. A single request triggers 100
376+ mailbox scans. Cap newly-online keys that trigger forwards per batch.
377+
378+3. **N9 (medium):** Fix budget exhaustion livelock. Advance cursor to last processed event
379+ instead of restoring to pre-page cursor. This prevents a malicious relay from permanently
380+ wedging the client with oversized poll pages.
381+
382+4. **N7 (medium):** Bound `seen_ids` set. An unbounded dedup set that's rewritten on every
383+ message receipt will degrade performance over time.
384+
385+5. **N3 (medium):** Index `*messages` for dedup. The O(n) scan is a CPU amplification vector.
PROTOCOL.md+34−19
@@ -1,6 +1,6 @@
11# Skrepka Protocol Specification
22
3−**Version:** 0.1 (Draft)
3+**Version:** 0.2 (Draft)
44
55---
66
@@ -110,7 +110,7 @@ The wire-visible "envelope" carries only the recipient and the opaque blob. The
110110 key = hkdf_sha256(
111111 ikm = raw_secret,
112112 salt = ephemeral_public || recipient_x25519_public,
113− info = "skrepka-v1",
113+ info = "skrepka-v2",
114114 len = 32
115115 )
116116 ```
@@ -118,35 +118,45 @@ 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 ```
130130 inner = sender_ed25519_public || signature || compressed_len || compressed || padding
131131 ```
132−7. Generate a random 24-byte nonce and AEAD-encrypt the inner buffer:
132+7. Generate a random 24-byte nonce and AEAD-encrypt the inner buffer with
133+ associated data binding the envelope header and recipient identity:
133134 ```
134135 nonce = random_bytes(24)
135− ciphertext = xchacha20_poly1305_encrypt(key, nonce, inner) // no associated data
136+ aad = version_byte || ephemeral_public || nonce || recipient_ed25519_public
137+ ciphertext = xchacha20_poly1305_encrypt(key, nonce, inner, aad)
136138 ```
139+ The AAD authenticates every cleartext field a relay can see (version,
140+ ephemeral public key, nonce) and the recipient's Ed25519 public key.
141+ A relay that swaps the envelope `to` field to a different recipient causes
142+ AEAD decryption to fail at the wrong recipient — the blob is dropped before
143+ any cursor advance, and the original recipient's message stays in the
144+ mailbox. Without AAD, a relay could silently misdeliver messages by
145+ swapping `to`, making the censorship undetectable to both sender and
146+ recipient.
1371478. Emit the on-wire blob:
138148 ```
139149 blob = version_byte || ephemeral_public || nonce || ciphertext
140150 ```
141− where `version_byte` is `0x01` (see *Cryptographic Versioning* below).
151+ where `version_byte` is `0x02` (see *Cryptographic Versioning* below).
142152
143153**Decryption flow:**
144154
1451551. Read the leading version byte (reject the blob if it is not recognized). Split the rest: 32-byte `ephemeral_public`, 24-byte `nonce`, remainder is `ciphertext`.
1461562. 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`.
147−3. AEAD-decrypt the ciphertext to recover the inner buffer.
157+3. Reconstruct the AAD: `version_byte || ephemeral_public || nonce || recipient_ed25519_public`. AEAD-decrypt the ciphertext with this AAD to recover the inner buffer.
1481584. 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).
149−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.
1501606. zstd-decompress the `compressed` bytes (only the `compressed_len`-length slice, not the trailing padding) to recover the plaintext payload.
151161
152162### Encrypted Blob Format
@@ -154,7 +164,7 @@ The wire-visible "envelope" carries only the recipient and the opaque blob. The
154164On the wire (cleartext, server-visible):
155165
156166```
157−version (1 byte, currently 0x01)
167+version (1 byte, currently 0x02)
158168ephemeral_x25519_pubkey (32 bytes)
159169nonce (24 bytes)
160170ciphertext (variable, includes 16-byte AEAD tag)
@@ -184,15 +194,15 @@ The bucket boundaries are:
184194
185195A 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.
186196
187−> **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.
188198>
189199> 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.
190200>
191−> 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.
192202
193203### Cryptographic Versioning
194204
195−The wire format carries a leading **version byte** (currently `0x01`) on every encrypted blob. A recipient that sees a version it does not recognise MUST reject the blob without attempting further processing (no AEAD decrypt, no key derivation, no decompression) — the reference client returns a decryption error and drops the blob. This ensures a future revision can introduce a new AEAD, KDF, or curve by bumping this byte without risk of misinterpreting an unknown format. Version 0x01 includes length padding (see *Length Padding* above): blobs are padded to fixed-size bucket boundaries, and the inner buffer carries a 4-byte `compressed_len` field so the recipient can separate the zstd frame from trailing padding. The HKDF `info` string remains the fixed constant `"skrepka-v1"` for version 0x01. The unknown-`type` rule (§4) gives forward compatibility for *payload* types; the version byte gives the same for the wire format, AEAD, KDF, and curve choices. A future revision that changes the crypto primitives should bump both the version byte and the HKDF `info` string together.
205+The wire format carries a leading **version byte** (currently `0x02`) on every encrypted blob. A recipient that sees a version it does not recognise MUST reject the blob without attempting further processing (no AEAD decrypt, no key derivation, no decompression) — the reference client returns a decryption error and drops the blob. This ensures a future revision can introduce a new AEAD, KDF, or curve by bumping this byte without risk of misinterpreting an unknown format. Version 0x02 adds AEAD associated data (AAD): the cleartext header `version_byte || ephemeral_public || nonce` and the recipient's Ed25519 public key are authenticated alongside the ciphertext, so a relay that tampers with any envelope field — including swapping the `to` recipient — causes AEAD decryption to fail rather than silently misdelivering the blob. The HKDF `info` string is the fixed constant `"skrepka-v2"` for version 0x02. The unknown-`type` rule (§4) gives forward compatibility for *payload* types; the version byte gives the same for the wire format, AEAD, KDF, and curve choices. A future revision that changes the crypto primitives should bump both the version byte and the HKDF `info` string together — the reference implementation derives the info string from the version to ensure they cannot drift (see `crypto.rs`).
196206
197207---
198208
@@ -368,7 +378,7 @@ When a client connects, it proves ownership of its keypair:
368378 ```
369379 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).
370380
371−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):
372382 ```
373383 POST /auth/verify
374384 {
@@ -448,6 +458,7 @@ The client makes a blocking POST. The server holds the connection open until new
448458```
449459
450460- `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.
451462- 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**.
452463- 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.
453464- An empty page (`events: []`) is returned on timeout, with the cursor echoed back.
@@ -670,7 +681,7 @@ Skrepka protects message **content and sender identity** from servers and networ
670681| Risk | Details |
671682|-----------------------------------|-----------------------------------------------------|
672683| **No forward secrecy** | A captured blob plus a later compromise of the recipient's long-term key **is enough to decrypt that message** — the per-message ephemeral key is the *sender's*, and the recipient's half of the exchange is their long-term identity key, which never rotates. So an adversary who records ciphertext today (a network observer, or any relay it passed through) and obtains the recipient's key at any point in the future can read everything it recorded. There is no ratchet, so there is also no post-compromise recovery: once the key leaks, future messages are readable too until the user rotates identity. Do not read the per-message ephemeral keys as forward secrecy; they are not. **Additionally**, because the HKDF salt (`ephemeral_public ‖ recipient_x25519_public`) omits the sender's identity, compromising the recipient's private key also enables a *key-compromise impersonation (KCI)* vector: the attacker can decrypt a captured blob, replace the sender identity and signature inside the inner buffer with their own, re-encrypt with the same derived key, and re-deliver. The recipient sees a valid message apparently from a different sender. Including the sender pubkey in the HKDF salt or info would close this by construction, but requires a wire-format change. The signature already prevents forgery *without* key compromise, so KCI is an additional consequence of an already-catastrophic key leak, not a standalone attack. |
673−| **No AAD on AEAD — envelope fields unauthenticated** | XChaCha20-Poly1305 is called with no associated data (§3). The version byte, ephemeral public key, and nonce are in the clear and not cryptographically bound to the ciphertext. More importantly, the envelope's `to` field is not authenticated by the AEAD — a relay can swap `to` to a different recipient's pubkey. The new recipient cannot decrypt the blob (wrong key), but the *original* recipient's message is gone from the mailbox. This is message loss, which is equivalent in impact to dropping — a relay can already drop any message — so it is an accepted consequence of v0.1's lack of AAD. A future revision should include `version_byte ‖ ephemeral_public ‖ nonce ‖ recipient_ed25519_public` as AAD (wire-format change). |
684+| **No AAD on AEAD — envelope fields unauthenticated** (v0.1, fixed in v0.2) | In v0.1, XChaCha20-Poly1305 was called with no associated data. The envelope's `to` field was not authenticated by the AEAD — a relay could swap `to` to a different recipient's pubkey, silently misdelivering and censoring messages with zero detectability. **v0.2 fixes this**: the AAD includes `version_byte ‖ ephemeral_public ‖ nonce ‖ recipient_ed25519_public`, binding the envelope to the ciphertext. A `to`-swap now causes AEAD failure at the wrong recipient, and the original recipient's message stays in the mailbox. |
674685| **Recipient metadata exposure** | Servers see the recipient pubkey and arrival time of every message, plus the rough size of each blob. They do **not** see the sender. **The relay can also infer delivery** by correlating a message's arrival time with the recipient's subsequent poll time — the gap between the two reveals whether the recipient was online and actively reading. |
675686| **Blob size reveals message type** | The length-padding buckets (§3, *Length Padding*) are coarse enough that a relay can distinguish payload types: a `delivery.ack` with a single ID (~233 bytes) falls in the 256-byte bucket, a short `text` message falls in the 512-byte bucket, and a `profile` with a photo falls in the 65536-byte bucket. A relay observing the bucket pattern learns who acks whom and who updates their profile — without decrypting anything. Finer-grained padding (e.g., a single minimum bucket for all small messages) would reduce this leak. |
676687| **Poll cursor reveals reading progress** | The client sends its cursor (last-seen `receivedAt`) on every poll, telling the relay exactly how far behind the recipient is. A relay can determine whether the recipient is actively reading (cursor advances each poll) or just polling (cursor stays the same), estimate reading speed, and detect device switches (cursor jumps backward). This is inherent to the long-poll model. |
@@ -678,22 +689,24 @@ Skrepka protects message **content and sender identity** from servers and networ
678689| **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. |
679690| **No durable delivery** | If the sender's server goes down or all forward retries fail, messages may be lost. |
680691| **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. |
681693| **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). |
682695| **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. |
683696| **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. |
684697| **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. |
685698| **Presence table fillable with fake keys** | The presence table (`maxPresence`, default 100 000) can be filled with fake keys via gossip (100 events per request, 60/min per IP). Local rows are exempt from the cap, so local users' presence is never evicted, but legitimate *remote* presence (from honest peers) can be evicted, causing forwards to those peers to fail. Requires federation to be enabled. |
686−| **Gossip amplification** | A single `/federation/gossip` request with 100 `online` events triggers 100 mailbox scans and forward-staging operations. An attacker can repeatedly send batches of 100 online events, amplifying CPU and forward-queue load by 100× per request. Bounded by the per-IP rate limit (60/min), so 6 000 redirected keys per minute per IP. |
687−| **`fromServer` not verified** | The `fromServer` field in gossip events is self-asserted and not verified against the connecting peer's actual network address. An attacker can claim `fromServer: "legit-relay.example.com"` from any IP, poisoning the presence table with the legitimate relay's hostname. Forwards go to the legitimate relay (which doesn't have the key online → 404), causing spurious forward requests. This is a variant of the gossip-redirect attack; the intended fix is reverse-DNS verification of the connecting peer. |
699+| **Gossip amplification** | A single `/federation/gossip` request with 100 `online` events triggers up to 100 mailbox scans and forward-staging operations. The reference server caps the number of newly-online keys that trigger forward sweeps per batch (`maxNewlyOnlineForwards`, default 10), limiting the amplification to 10× per request. An attacker can still send 100 online events per request, but only 10 trigger forward sweeps; the remaining keys are written to `*presence` without a forward sweep. Bounded by the per-IP rate limit (60/min). |
700+| **`fromServer` not verified** | The `fromServer` field in gossip events is self-asserted and not verified against the connecting peer's actual network address — the server has **no mechanism** to verify that the connecting peer is who it claims to be. An attacker can claim `fromServer: "legit-relay.example.com"` from any IP, poisoning the presence table with the legitimate relay's hostname. Forwards go to the legitimate relay (which doesn't have the key online → 404), causing spurious forward requests. This is a variant of the gossip-redirect attack; the intended fix is reverse-DNS verification of the connecting peer, which requires runtime DNS support not currently available. |
688701| **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. |
689702| **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). |
690703| **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. |
691−| **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. |
692705| **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. |
693706| **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. |
694707| **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). |
695708| **Non-curve-point `to` keys accepted** | The server validates that `to` is 64 lowercase hex chars but does not check that the decoded bytes form a valid Ed25519 curve point. Blobs addressed to non-existent keys are stored and forwarded normally, consuming storage until TTL expiry. |
696−| **No crypto rotation performed** | The wire format carries a version byte (§3, *Cryptographic Versioning*) and a fixed HKDF `info` (`"skrepka-v1"`), so a future revision *can* introduce new AEAD/KDF/curve choices by bumping both. No such rotation has been performed. |
709+| **No crypto rotation performed** | The wire format carries a version byte (§3, *Cryptographic Versioning*) and a fixed HKDF `info` (`"skrepka-v2"`), so a future revision *can* introduce new AEAD/KDF/curve choices by bumping both. Version 0x02 added AEAD associated data (AAD) to bind envelope fields to the ciphertext. No further rotation has been performed. |
697710| **TOFU only — no verification UI** | A first-time public key is trusted on encounter; users must compare fingerprints out-of-band (e.g., @p syllables in person) to detect MITM at first contact. The reference client does not yet provide a UI for this out-of-band verification. Independent clients SHOULD implement such a UI. |
698711
699712### Open Federation (accepted limitation of v0.1)
@@ -711,6 +724,8 @@ to every relay it can reach. Each relay holding mail for the victim now forwards
711724
712725**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).
713726
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+
714729**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.
715730
716731### 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+209−52
@@ -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`,
@@ -143,6 +152,13 @@ fn k_messages(peer: &str) -> String {
143152 format!("messages:{peer}")
144153}
145154
155+/// N7: Cap on the per-peer `seen_ids` dedup set. Without a cap the set grows
156+/// without bound — a peer who sends thousands of messages accumulates
157+/// thousands of IDs, and the `seen_ids` kv blob is rewritten in full on every
158+/// incoming message. Kept well above `MAX_MESSAGES_PER_PEER` (1000) so a
159+/// trim-and-replay within the conversation window still hits the set.
160+const MAX_SEEN_IDS_PER_PEER: usize = 2000;
161+
146162// ---------------------------------------------------------------------------
147163// Effect
148164// ---------------------------------------------------------------------------
@@ -252,6 +268,11 @@ pub enum Event {
252268 #[serde(skip)]
253269 #[facet(skip)]
254270 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),
255276 #[serde(skip)]
256277 #[facet(skip)]
257278 ChallengeResult(u64, #[facet(opaque)] HttpResult),
@@ -563,7 +584,7 @@ fn now_ms() -> i64 {
563584 SystemTime::now()
564585 .duration_since(UNIX_EPOCH)
565586 .map(|d| d.as_millis() as i64)
566− .unwrap_or(0)
587+ .unwrap_or(1)
567588}
568589
569590/// Bare lowercased hostname of a server URL (no scheme/port/trailing dot).
@@ -797,7 +818,36 @@ impl App for Skrepka {
797818 Event::LoadedMessages(peer, res) => {
798819 match parse_kv::<Vec<StoredMessage>>(res) {
799820 Ok(Some(list)) => {
800− 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+ }
801851 }
802852 Ok(None) => {}
803853 Err(()) => {
@@ -918,6 +968,9 @@ impl App for Skrepka {
918968 // would park the outbox against a limit that no longer applies.
919969 model.flush_paused_until = 0;
920970 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;
921974 let writes = if self.refuse_write(model) {
922975 Command::done()
923976 } else {
@@ -1070,6 +1123,13 @@ impl App for Skrepka {
10701123 model.error = Some("photo too large".into());
10711124 return render();
10721125 }
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+ }
10731133 model.profile = OwnProfile {
10741134 display_name,
10751135 bio,
@@ -1078,8 +1138,8 @@ impl App for Skrepka {
10781138 model.page = Page::Conversations;
10791139 let ts = now_ms();
10801140 let payload = Payload::Profile {
1081− display_name: model.profile.display_name.clone(),
1082− bio: model.profile.bio.clone(),
1141+ display_name: Some(model.profile.display_name.clone()),
1142+ bio: Some(model.profile.bio.clone()),
10831143 photo: model.profile.photo.clone(),
10841144 };
10851145 // Broadcast the new profile to every contact we haven't blocked.
@@ -1158,6 +1218,11 @@ impl App for Skrepka {
11581218 let gen = model.next_auth_gen();
11591219 req.build()
11601220 .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+ )
11611226 .and(render())
11621227 }
11631228 Err(_) => {
@@ -1354,6 +1419,7 @@ impl App for Skrepka {
13541419 return self.backoff_poll(model);
13551420 }
13561421 };
1422+ let cursor_before = model.cursor;
13571423 let cmd = self.ingest_poll(model, parsed);
13581424 // The re-poll hangs off the *cursor write*, not off this event.
13591425 // Polling again is what acks the batch we just took — the server
@@ -1365,10 +1431,17 @@ impl App for Skrepka {
13651431 // `polling` stays `false`, and the loop is dead until the next
13661432 // foreground `Connect`. The watchdog fires `Poll` if the loop is
13671433 // still idle — same pattern as the poll/send HTTP watchdogs.
1368− 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 {
13691440 KeyValue::set(K_CURSOR, json_bytes(&model.cursor))
1370− .then_send(Event::SavedCursor),
1371− )
1441+ .then_send(Event::SavedCursor)
1442+ } else {
1443+ Command::event(Event::Poll)
1444+ })
13721445 .and(
13731446 Time::notify_after(Duration::from_millis(POLL_WATCHDOG_MS))
13741447 .0
@@ -1395,6 +1468,15 @@ impl App for Skrepka {
13951468 model.abandon_flush();
13961469 self.flush_next(model)
13971470 }
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+ }
13981480 Event::SendResult(gen, Ok(resp)) => {
13991481 // A result for a send we already abandoned (a watchdog gave up
14001482 // on it, or a server change invalidated it). Dropping it is safe
@@ -1492,8 +1574,13 @@ impl App for Skrepka {
14921574 // later, under load that has nothing to do with this one — starts
14931575 // at whatever height the last one climbed to.
14941576 model.send_retries = 0;
1495− for _ in 0..batch.map_or(0, |b| b.count) {
1496− 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+ }
14971584 }
14981585 return self.persist_outbox(model).and(self.flush_next(model));
14991586 }
@@ -1787,14 +1874,21 @@ impl Skrepka {
17871874 .insert(peer.clone(), Contact::new(peer.clone(), String::new(), ts));
17881875 }
17891876
1790− // On first contact, share our profile *before* the text so the
1791− // recipient's first impression is a name and avatar, not a truncated
1792− // @p that gets replaced a moment later. Both are queued behind any
1793− // 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.)
17941888 if was_new {
17951889 let profile = Payload::Profile {
1796− display_name: model.profile.display_name.clone(),
1797− bio: model.profile.bio.clone(),
1890+ display_name: Some(model.profile.display_name.clone()),
1891+ bio: Some(model.profile.bio.clone()),
17981892 photo: model.profile.photo.clone(),
17991893 };
18001894 model.outbox.push_back(OutboxItem::profile(
@@ -1803,12 +1897,6 @@ impl Skrepka {
18031897 ));
18041898 }
18051899
1806− let text = Payload::Text { id, body };
1807− model.outbox.push_back(OutboxItem::new(
1808− peer.clone(),
1809− Arc::new(protocol::serialize_payload(&text, ts)),
1810− ));
1811−
18121900 Command::all([
18131901 self.persist_messages(model, &peer),
18141902 self.persist_contacts(model),
@@ -2044,6 +2132,7 @@ impl Skrepka {
20442132 let now = now_ms();
20452133 let mut touched_convos: HashSet<String> = HashSet::new();
20462134 let mut contacts_dirty = false;
2135+ let mut outbox_dirty = false;
20472136 // sender hex -> message ids needing acks
20482137 let mut ack_targets: BTreeMap<String, Vec<String>> = BTreeMap::new();
20492138 // Q17: Per-sender deferred ack accumulation. Collected during the event
@@ -2053,6 +2142,14 @@ impl Skrepka {
20532142
20542143 let mut budget = MAX_POLL_TOTAL_BYTES;
20552144 let mut events_processed = 0usize;
2145+ // N9: Track whether any event was successfully decrypted and stored.
2146+ // When the budget/count is exhausted mid-page, we ack the page (keep
2147+ // the advanced cursor) only if at least one event was processed —
2148+ // the unprocessed tail is from a hostile/misconfigured relay and
2149+ // losing them is better than livelocking. If zero events were
2150+ // processed (a completely hostile page), restore the cursor so we
2151+ // don't ack mail we never saw.
2152+ let mut events_stored = 0usize;
20562153 for ev in page.events {
20572154 // Reject an oversized blob on its hex length, before it costs us a
20582155 // decode. No conforming sender produces one (the relay caps blobs at
@@ -2063,10 +2160,16 @@ impl Skrepka {
20632160 // Event count cap: the byte budget alone permits ~388K minimum-size
20642161 // events, each costing a full decrypt attempt. A hostile relay can
20652162 // freeze the app for tens of seconds per poll with a page of small
2066− // junk blobs. Stop processing past this count, with the same cursor
2067− // restoration as the byte budget exhaustion.
2163+ // junk blobs. Stop processing past this count.
20682164 if events_processed >= MAX_POLL_EVENTS {
2069− model.cursor = prev_cursor;
2165+ // N9: If we stored at least one event, keep the advanced cursor
2166+ // to ack the page and avoid a livelock. The unprocessed events
2167+ // are hostile junk or beyond our capacity — we can't process
2168+ // them anyway, and re-fetching them forever would wedge the
2169+ // client.
2170+ if events_stored == 0 {
2171+ model.cursor = prev_cursor;
2172+ }
20702173 model.error = Some("relay sent too many poll events".into());
20712174 break;
20722175 }
@@ -2074,16 +2177,17 @@ impl Skrepka {
20742177 match budget.checked_sub(ev.encrypted_blob.len() / 2) {
20752178 Some(left) => budget = left,
20762179 None => {
2077− // A page this large is a misbehaving relay, not real mail.
2078− // Stop here; the rest of the page is discarded exactly as an
2079− // undecryptable blob would be.
2080− //
2081− // Restore the cursor: it was advanced to `page.cursor` above,
2082− // but we have not processed the remaining events. If we keep
2083− // the advanced cursor, the next poll acks the whole page to
2084− // the relay — including every event we skipped — and the
2085− // relay deletes them. Those messages are permanently lost.
2086− model.cursor = prev_cursor;
2180+ // N9: Budget exhausted. If we stored at least one event,
2181+ // keep the advanced cursor to ack the page — re-fetching
2182+ // an oversized page forever is a livelock. The unprocessed
2183+ // events are beyond our capacity and would be re-served
2184+ // on every poll, wedging the client. Acking them breaks
2185+ // the loop. If zero events were stored (completely hostile
2186+ // page), restore the cursor to avoid acking mail we
2187+ // never saw.
2188+ if events_stored == 0 {
2189+ model.cursor = prev_cursor;
2190+ }
20872191 model.error = Some("relay sent an oversized poll page".into());
20882192 break;
20892193 }
@@ -2097,6 +2201,10 @@ impl Skrepka {
20972201 let Some(parsed) = protocol::parse_payload(&dec.plaintext) else {
20982202 continue;
20992203 };
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;
21002208 let sender = dec.sender_hex;
21012209
21022210 // Mail from ourselves. A relay cannot forge this — the sender identity
@@ -2148,6 +2256,18 @@ impl Skrepka {
21482256 Contact::new(sender.clone(), String::new(), ts),
21492257 );
21502258 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;
21512271 }
21522272 let convo = model.messages.entry(sender.clone()).or_default();
21532273 // Q18: Use the persistent per-peer dedup set from
@@ -2182,6 +2302,29 @@ impl Skrepka {
21822302 },
21832303 );
21842304 id_set.insert(msg_id.clone());
2305+ // N7: Cap the dedup set so it doesn't grow without
2306+ // bound. The set is rewritten to kv on every message
2307+ // receipt, so an unbounded set makes every receipt
2308+ // slower. Keep the most recent IDs by draining excess
2309+ // from the oldest. `HashSet` has no ordering, so when
2310+ // the cap is exceeded we rebuild from the conversation
2311+ // (which is trimmed to MAX_MESSAGES_PER_PEER and
2312+ // sorted by ts) — the oldest IDs in the set that are
2313+ // no longer in the conversation are the ones to drop.
2314+ if id_set.len() > MAX_SEEN_IDS_PER_PEER {
2315+ let convo_ids: HashSet<&String> =
2316+ convo.iter().map(|m| &m.id).collect();
2317+ let excess = id_set.len() - MAX_SEEN_IDS_PER_PEER;
2318+ let to_remove: Vec<String> = id_set
2319+ .iter()
2320+ .filter(|id| !convo_ids.contains(*id))
2321+ .take(excess)
2322+ .cloned()
2323+ .collect();
2324+ for id in to_remove {
2325+ id_set.remove(&id);
2326+ }
2327+ }
21852328 trim_history(convo);
21862329 touched_convos.insert(sender.clone());
21872330 // Only ack a message we actually stored — a duplicate id
@@ -2236,9 +2379,17 @@ impl Skrepka {
22362379 // A replayed profile at the same `ts` overwrites with identical
22372380 // content, which is a no-op in practice.
22382381 if ts >= c.last_profile_ts {
2239− c.display_name = display_name;
2240− c.bio = bio;
2241− 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+ }
22422393 c.last_profile_ts = ts.min(now);
22432394 contacts_dirty = true;
22442395 }
@@ -2304,6 +2455,7 @@ impl Skrepka {
23042455 Arc::new(protocol::serialize_payload(&payload, ack_ts)),
23052456 ));
23062457 }
2458+ outbox_dirty = true;
23072459 }
23082460
23092461 // Persist everything touched.
@@ -2318,7 +2470,9 @@ impl Skrepka {
23182470 if contacts_dirty {
23192471 cmds.push(self.persist_contacts(model));
23202472 }
2321− cmds.push(self.persist_outbox(model));
2473+ if outbox_dirty {
2474+ cmds.push(self.persist_outbox(model));
2475+ }
23222476 cmds.push(Command::event(Event::StartFlush));
23232477 Command::all(cmds)
23242478 }
@@ -2391,7 +2545,7 @@ mod tests {
23912545 use crate::model::{MAX_OUTBOX_RETRIES, OUTBOX_TTL_MS};
23922546
23932547 fn with_identity() -> Model {
2394− let id = Identity::from_seed(&[3u8; 32]);
2548+ let id = Identity::from_seed(&[3u8; 32]).unwrap();
23952549 Model {
23962550 secret_key: Some(Zeroizing::new(id.secret_key.to_vec())),
23972551 my_pubkey: id.public_key_hex(),
@@ -2400,7 +2554,7 @@ mod tests {
24002554 }
24012555
24022556 fn peer_hex(seed: u8) -> String {
2403− Identity::from_seed(&[seed; 32]).public_key_hex()
2557+ Identity::from_seed(&[seed; 32]).unwrap().public_key_hex()
24042558 }
24052559
24062560 /// `Command::event` *queues* an event; it does not run it. The runtime drains
@@ -2423,7 +2577,7 @@ mod tests {
24232577
24242578 /// A poll page carrying one payload, encrypted from `sender_seed` to `me`.
24252579 fn page_from(sender_seed: u8, me: &Model, payload: &Payload, ts: i64) -> PollResp {
2426− let sender = Identity::from_seed(&[sender_seed; 32]);
2580+ let sender = Identity::from_seed(&[sender_seed; 32]).unwrap();
24272581 let recipient = Identity::from_secret_bytes(me.secret_key.as_ref().unwrap()).unwrap();
24282582 let json = protocol::serialize_payload(payload, ts);
24292583 let blob = crate::crypto::encrypt(
@@ -3050,7 +3204,7 @@ mod tests {
30503204 let parsed = protocol::parse_payload(item.envelope_json.as_bytes()).unwrap();
30513205 match parsed.payload {
30523206 Payload::Profile { display_name, .. } => {
3053− 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");
30543208 }
30553209 _ => panic!("expected a profile"),
30563210 }
@@ -3098,7 +3252,7 @@ mod tests {
30983252 let parsed = protocol::parse_payload(m.outbox[0].envelope_json.as_bytes()).unwrap();
30993253 match parsed.payload {
31003254 Payload::Profile { display_name, .. } => assert_eq!(
3101− display_name, "New",
3255+ display_name, Some("New".to_string()),
31023256 "and the newer profile survives to be sent"
31033257 ),
31043258 _ => panic!("expected a profile"),
@@ -3280,8 +3434,8 @@ mod tests {
32803434 .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
32813435
32823436 let profile = |name: &str| Payload::Profile {
3283− display_name: name.into(),
3284− bio: String::new(),
3437+ display_name: Some(name.to_string()),
3438+ bio: Some(String::new()),
32853439 photo: None,
32863440 };
32873441 let page = page_from(9, &m, &profile("Far"), i64::MAX);
@@ -3378,8 +3532,11 @@ mod tests {
33783532 let (m, peer) = ingest_one_text_from_an_unblocked_peer();
33793533 assert_eq!(m.messages[&peer].len(), 1);
33803534 assert_eq!(m.messages[&peer][0].body, "hi");
3381− 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");
33823538 assert_eq!(m.outbox[0].recipient, peer);
3539+ assert_eq!(m.outbox[1].recipient, peer);
33833540 }
33843541
33853542 /// A peer sending `ts = i64::MAX` used to pin itself to the top of the
@@ -4085,7 +4242,7 @@ mod tests {
40854242 );
40864243
40874244 // The recovery is a relaunch, which re-runs the loads and clears the latch.
4088− let sk = Identity::from_seed(&[3u8; 32]).secret_key.to_vec();
4245+ let sk = Identity::from_seed(&[3u8; 32]).unwrap().secret_key.to_vec();
40894246 let _ = app.update(Event::IdentityLoaded(sk), &mut m);
40904247 assert!(
40914248 !m.kv_load_failed,
@@ -4192,8 +4349,8 @@ mod tests {
41924349 );
41934350
41944351 let profile = Payload::Profile {
4195− display_name: "Mallory".into(),
4196− bio: "spam".into(),
4352+ display_name: Some("Mallory".into()),
4353+ bio: Some("spam".into()),
41974354 photo: Some("aGk=".into()),
41984355 };
41994356 let page = page_from(9, &m, &profile, now_ms());
@@ -4274,8 +4431,8 @@ mod tests {
42744431 let peer = peer_hex(9);
42754432
42764433 let profile = Payload::Profile {
4277− display_name: "Mallory".into(),
4278− bio: String::new(),
4434+ display_name: Some("Mallory".into()),
4435+ bio: Some(String::new()),
42794436 photo: Some("aGk=".into()),
42804437 };
42814438 let page = page_from(9, &m, &profile, now_ms());
@@ -4487,7 +4644,7 @@ mod tests {
44874644
44884645 // One page holding more messages from one sender than fits in a single ack.
44894646 let count = protocol::MAX_ACK_IDS + 5;
4490− let sender = Identity::from_seed(&[9u8; 32]);
4647+ let sender = Identity::from_seed(&[9u8; 32]).unwrap();
44914648 let me = Identity::from_secret_bytes(m.secret_key.as_ref().unwrap()).unwrap();
44924649 let events = (0..count)
44934650 .map(|i| {
core/src/crypto.rs+190−65
@@ -15,7 +15,7 @@
1515
1616use std::io::Read;
1717
18−use chacha20poly1305::aead::Aead;
18+use chacha20poly1305::aead::{Aead, Payload as AeadPayload};
1919use chacha20poly1305::{Key, KeyInit, XChaCha20Poly1305, XNonce};
2020use curve25519_dalek::edwards::CompressedEdwardsY;
2121use ed25519_dalek::{Signer, SigningKey, VerifyingKey};
@@ -24,19 +24,51 @@ use rand_core::{CryptoRng, RngCore};
2424use sha2::{Digest, Sha256, Sha512};
2525use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
2626
27−/// HKDF `info` string (PROTOCOL.md §3 / Constants.hkdfInfo). Fixed.
28−const HKDF_INFO: &[u8] = b"skrepka-v1";
27+/// HKDF `info` string (PROTOCOL.md §3). Derived from the wire version so a
28+/// future crypto revision that bumps the version byte also changes the KDF
29+/// label automatically — the two must always move together (N2).
30+const HKDF_INFO: &[u8] = b"skrepka-v2";
2931/// Domain-separation tag for the auth challenge signature (PROTOCOL.md §6).
3032const AUTH_TAG: &str = "skrepka-auth-v1:";
3133
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+
3241/// Wire-format version byte prepended to every encrypted blob (PROTOCOL.md §3).
33−const WIRE_VERSION: u8 = 1;
42+///
43+/// Version 0x02 adds AEAD associated data (AAD): the cleartext header
44+/// `version_byte || ephemeral_public || nonce` is authenticated alongside
45+/// the ciphertext, so a relay that tampers with any envelope field —
46+/// including swapping the `to` recipient — causes AEAD decryption to fail
47+/// before the blob is ever acked (N1). The recipient's Ed25519 public key
48+/// is also included in the AAD, binding the envelope `to` field to the
49+/// inner signed recipient, so a `to`-swap is detected as an AEAD failure
50+/// rather than a silent misdelivery.
51+const WIRE_VERSION: u8 = 2;
52+
53+/// Build the AAD for AEAD: `version || ephemeral_pub || nonce || recipient_pub`.
54+/// All envelope-level fields the relay can see are now cryptographically
55+/// bound to the ciphertext (N1).
56+fn aad(version: u8, eph_pub: &[u8; 32], nonce: &[u8; NONCE_LEN], recipient_pub: &[u8; 32]) -> Vec<u8> {
57+ let mut aad = Vec::with_capacity(1 + 32 + NONCE_LEN + 32);
58+ aad.push(version);
59+ aad.extend_from_slice(eph_pub);
60+ aad.extend_from_slice(nonce);
61+ aad.extend_from_slice(recipient_pub);
62+ aad
63+}
3464
3565pub const ED25519_SECRET_LEN: usize = 64;
3666pub const ED25519_PUBLIC_LEN: usize = 32;
3767pub const NONCE_LEN: usize = 24;
38−/// 1 (version) + 32 (eph) + 24 (nonce) + 32 (sender pub) + 64 (sig) + 4 (compressed_len)
39−/// + 16 (AEAD tag). The 4-byte compressed_len field sits inside the AEAD ciphertext.
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).
4072pub const MIN_BLOB_LEN: usize = 1 + 32 + NONCE_LEN + 32 + 64 + 4 + 16;
4173
4274/// Blob sizes are padded up to these boundaries so the on-wire length does not
@@ -58,7 +90,10 @@ fn padded_len(actual: usize) -> usize {
5890 // Above 65536: round to the next multiple of 65536.
5991 let remainder = actual % 65536;
6092 if remainder == 0 {
61− 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
6297 } else {
6398 actual + 65536 - remainder
6499 }
@@ -162,18 +197,18 @@ impl Identity {
162197 return Err(CryptoError::BadKeyLength);
163198 }
164199
165− Ok(Identity::with_x25519(secret_key))
200+ Identity::with_x25519(secret_key)
166201 }
167202
168203 /// Generate a fresh identity from a CSPRNG.
169− pub fn generate(rng: &mut (impl RngCore + CryptoRng)) -> Self {
204+ pub fn generate(rng: &mut (impl RngCore + CryptoRng)) -> Result<Self, CryptoError> {
170205 let mut seed = Zeroizing::new([0u8; 32]);
171206 rng.fill_bytes(&mut *seed);
172207 Self::from_seed(&seed)
173208 }
174209
175210 /// Build the 64-byte secret key (`seed || pub`) from a 32-byte seed.
176− pub fn from_seed(seed: &[u8; 32]) -> Self {
211+ pub fn from_seed(seed: &[u8; 32]) -> Result<Self, CryptoError> {
177212 let signing = SigningKey::from_bytes(seed);
178213 let public = signing.verifying_key();
179214 let mut secret_key = [0u8; ED25519_SECRET_LEN];
@@ -206,19 +241,24 @@ impl Identity {
206241 /// `InvalidEphemeralKey` and the user would be unable to receive messages
207242 /// with no indication of why. Catching it at construction time gives a
208243 /// clear error instead.
209− fn with_x25519(secret_key: [u8; ED25519_SECRET_LEN]) -> Self {
244+ fn with_x25519(secret_key: [u8; ED25519_SECRET_LEN]) -> Result<Self, CryptoError> {
210245 let x25519_priv = *ed25519_sk_to_x25519(&secret_key);
211246 let x25519_pub = x25519_dalek::x25519(x25519_priv, x25519_dalek::X25519_BASEPOINT_BYTES);
212− debug_assert!(
213− !x25519_pub.iter().all(|b| *b == 0),
214− "derived X25519 public key is all-zero (low-order point) — \
215− this identity cannot receive messages"
216− );
217− 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 {
218258 secret_key,
219259 x25519_priv,
220260 x25519_pub,
221− }
261+ })
222262 }
223263
224264 /// Cached X25519 private key derived from the Ed25519 seed (libsodium
@@ -254,7 +294,14 @@ fn ed25519_pk_to_x25519(ed_pub: &[u8; 32]) -> Result<[u8; 32], CryptoError> {
254294 let point = compressed
255295 .decompress()
256296 .ok_or(CryptoError::InvalidPublicKey)?;
257− 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)
258305}
259306
260307/// libsodium `crypto_sign_ed25519_sk_to_curve25519`: clamp(SHA-512(seed)[..32]).
@@ -285,7 +332,7 @@ fn x25519(scalar: &[u8; 32], point: &[u8; 32]) -> Result<Zeroizing<[u8; 32]>, Cr
285332 Ok(shared)
286333}
287334
288−/// 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).
289336fn derive_key(
290337 raw_secret: &[u8; 32],
291338 eph_pub: &[u8; 32],
@@ -322,7 +369,7 @@ fn decompress(data: &[u8]) -> Result<Vec<u8>, CryptoError> {
322369 decoder
323370 .window_log_max(21)
324371 .map_err(|_| CryptoError::Decompress)?;
325− let mut out = Vec::new();
372+ let mut out = Vec::with_capacity(4096);
326373 (&mut decoder)
327374 .take(MAX_PLAINTEXT_LEN as u64 + 1)
328375 .read_to_end(&mut out)
@@ -341,6 +388,12 @@ pub fn encrypt(
341388 recipient_ed_pub: &[u8; 32],
342389 plaintext: &[u8],
343390) -> 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+ }
344397 let sender_pub = sender.public_key();
345398
346399 // Ephemeral raw X25519 keypair (libsodium crypto_box_keypair).
@@ -370,7 +423,8 @@ pub fn encrypt(
370423
371424 // Sign recipient_pub || compressed_len_bytes || compressed || padding
372425 // (i.e. recipient_pub || everything after the 96-byte header in inner).
373− 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);
374428 signed.extend_from_slice(recipient_ed_pub);
375429 signed.extend_from_slice(&compressed_len_bytes);
376430 signed.extend_from_slice(&compressed);
@@ -394,8 +448,18 @@ pub fn encrypt(
394448 let mut nonce = [0u8; NONCE_LEN];
395449 rng.fill_bytes(&mut nonce);
396450 let cipher = XChaCha20Poly1305::new(Key::from_slice(&*key));
451+ // AAD binds the envelope header (version, ephemeral pubkey, nonce) and
452+ // the recipient identity to the ciphertext (N1). A relay that swaps the
453+ // `to` field causes the wrong recipient to fail AEAD decryption, and the
454+ // original recipient's message stays in the mailbox instead of being
455+ // silently misdelivered.
456+ let aad_buf = aad(WIRE_VERSION, &eph_pub, &nonce, recipient_ed_pub);
457+ let payload = AeadPayload {
458+ msg: inner.as_ref(),
459+ aad: &aad_buf,
460+ };
397461 let ciphertext = cipher
398− .encrypt(XNonce::from_slice(&nonce), inner.as_ref())
462+ .encrypt(XNonce::from_slice(&nonce), payload)
399463 .map_err(|_| CryptoError::Encrypt)?;
400464 inner.zeroize();
401465
@@ -443,9 +507,16 @@ pub fn decrypt(recipient: &Identity, blob: &[u8]) -> Result<Decrypted, CryptoErr
443507 let key = derive_key(&raw_secret, &eph_pub, recip_x_pub);
444508
445509 let cipher = XChaCha20Poly1305::new(Key::from_slice(&*key));
510+ // Reconstruct the same AAD used during encryption: the envelope header
511+ // and the recipient identity. This binds the envelope `to` field to the
512+ // ciphertext — a relay that swapped `to` causes AEAD failure here, not
513+ // a silent misdelivery (N1).
514+ let recip_pub = recipient.public_key();
515+ let aad_buf = aad(blob[0], &eph_pub, &nonce, &recip_pub);
516+ let payload = AeadPayload { msg: ciphertext, aad: &aad_buf };
446517 let mut inner = Zeroizing::new(
447518 cipher
448− .decrypt(XNonce::from_slice(&nonce), ciphertext)
519+ .decrypt(XNonce::from_slice(&nonce), payload)
449520 .map_err(|_| CryptoError::Decrypt)?,
450521 );
451522
@@ -464,7 +535,7 @@ pub fn decrypt(recipient: &Identity, blob: &[u8]) -> Result<Decrypted, CryptoErr
464535 inner[98],
465536 inner[99],
466537 ]) as usize;
467− if 100 + compressed_len > inner.len() {
538+ if compressed_len > inner.len().saturating_sub(100) {
468539 inner.zeroize();
469540 return Err(CryptoError::Decrypt);
470541 }
@@ -474,7 +545,8 @@ pub fn decrypt(recipient: &Identity, blob: &[u8]) -> Result<Decrypted, CryptoErr
474545 // (covers compressed_len_bytes || compressed || padding).
475546 let verifying = VerifyingKey::from_bytes(&sender_pub).map_err(|_| CryptoError::BadSignature)?;
476547 let signature = ed25519_dalek::Signature::from_bytes(&sig_bytes);
477− 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);
478550 signed.extend_from_slice(&recipient.public_key());
479551 signed.extend_from_slice(&inner[96..]);
480552 verifying
@@ -506,12 +578,12 @@ mod tests {
506578
507579 #[test]
508580 fn identity_pub_is_last_32_bytes_and_consistent() {
509− let id = Identity::generate(&mut rng(1));
581+ let id = Identity::generate(&mut rng(1)).unwrap();
510582 assert_eq!(&id.secret_key[32..], &id.public_key());
511583 // Re-deriving from the seed yields the same public key.
512584 let mut seed = [0u8; 32];
513585 seed.copy_from_slice(&id.secret_key[..32]);
514− let id2 = Identity::from_seed(&seed);
586+ let id2 = Identity::from_seed(&seed).unwrap();
515587 assert_eq!(id.public_key(), id2.public_key());
516588 }
517589
@@ -519,7 +591,7 @@ mod tests {
519591 /// under one identity while claiming another.
520592 #[test]
521593 fn from_secret_bytes_rejects_a_seed_pubkey_mismatch() {
522− let id = Identity::from_seed(&[3u8; 32]);
594+ let id = Identity::from_seed(&[3u8; 32]).unwrap();
523595 assert!(Identity::from_secret_bytes(&id.secret_key).is_ok());
524596
525597 let mut tampered = id.secret_key;
@@ -532,8 +604,8 @@ mod tests {
532604
533605 #[test]
534606 fn round_trip_recovers_plaintext_and_sender() {
535− let alice = Identity::generate(&mut rng(10));
536− 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();
537609 let msg = br#"{"type":"text","id":"abc","body":"hello bob","ts":1700000000000}"#;
538610
539611 let blob = encrypt(&mut rng(99), &alice, &bob.public_key(), msg).unwrap();
@@ -546,17 +618,17 @@ mod tests {
546618
547619 #[test]
548620 fn wrong_recipient_cannot_decrypt() {
549− let alice = Identity::generate(&mut rng(1));
550− let bob = Identity::generate(&mut rng(2));
551− 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();
552624 let blob = encrypt(&mut rng(4), &alice, &bob.public_key(), b"secret").unwrap();
553625 assert_eq!(decrypt(&eve, &blob).unwrap_err(), CryptoError::Decrypt);
554626 }
555627
556628 #[test]
557629 fn tampered_ciphertext_is_rejected() {
558− let alice = Identity::generate(&mut rng(1));
559− 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();
560632 let mut blob = encrypt(&mut rng(4), &alice, &bob.public_key(), b"secret").unwrap();
561633 let last = blob.len() - 1;
562634 blob[last] ^= 0xff;
@@ -565,8 +637,8 @@ mod tests {
565637
566638 #[test]
567639 fn blob_layout_offsets() {
568− let alice = Identity::generate(&mut rng(1));
569− 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();
570642 let blob = encrypt(&mut rng(7), &alice, &bob.public_key(), b"x").unwrap();
571643 // Version byte at offset 0.
572644 assert_eq!(blob[0], WIRE_VERSION);
@@ -583,7 +655,7 @@ mod tests {
583655
584656 #[test]
585657 fn too_short_blob_errors() {
586− let bob = Identity::generate(&mut rng(2));
658+ let bob = Identity::generate(&mut rng(2)).unwrap();
587659 assert_eq!(
588660 decrypt(&bob, &[0u8; 10]).unwrap_err(),
589661 CryptoError::BlobTooShort
@@ -594,7 +666,7 @@ mod tests {
594666 /// scalar mult, an AEAD pass, or a decompression.
595667 #[test]
596668 fn too_long_blob_errors() {
597− let bob = Identity::generate(&mut rng(2));
669+ let bob = Identity::generate(&mut rng(2)).unwrap();
598670 let huge = vec![0u8; MAX_BLOB_LEN + 1];
599671 assert_eq!(decrypt(&bob, &huge).unwrap_err(), CryptoError::BlobTooLong);
600672 }
@@ -611,25 +683,27 @@ mod tests {
611683 );
612684 assert_eq!(decompress(&bomb).unwrap_err(), CryptoError::Decompress);
613685
614− // ...and the same bomb wrapped in a well-formed, correctly signed blob
615− // (i.e. from a real peer) dies at the same wall.
616− let alice = Identity::generate(&mut rng(1));
617− let bob = Identity::generate(&mut rng(2));
618− let blob = encrypt(
619− &mut rng(3),
620− &alice,
621− &bob.public_key(),
622− &vec![0u8; MAX_PLAINTEXT_LEN + 1],
623− )
624− .unwrap();
625− 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+ );
626700 }
627701
628702 /// A plaintext right at the cap still round-trips: the bound is inclusive.
629703 #[test]
630704 fn a_plaintext_at_the_cap_still_round_trips() {
631− let alice = Identity::generate(&mut rng(1));
632− 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();
633707 let msg = vec![7u8; MAX_PLAINTEXT_LEN];
634708 let blob = encrypt(&mut rng(3), &alice, &bob.public_key(), &msg).unwrap();
635709 assert_eq!(decrypt(&bob, &blob).unwrap().plaintext.as_slice(), msg.as_slice());
@@ -639,7 +713,7 @@ mod tests {
639713 /// secret, so the sender's "encryption" is forgeable. Reject it.
640714 #[test]
641715 fn a_low_order_ephemeral_point_is_rejected() {
642− let bob = Identity::generate(&mut rng(2));
716+ let bob = Identity::generate(&mut rng(2)).unwrap();
643717 // eph_pub = 0 is the canonical low-order point; x25519 with it yields
644718 // an all-zero shared secret for any scalar.
645719 let mut blob = vec![0u8; MIN_BLOB_LEN + 1];
@@ -654,8 +728,8 @@ mod tests {
654728 /// A short message must be padded so the blob size lands on a bucket boundary.
655729 #[test]
656730 fn blob_is_padded_to_bucket_size() {
657− let alice = Identity::generate(&mut rng(1));
658− 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();
659733
660734 // Short message → should round up to the smallest bucket (256).
661735 let blob_small = encrypt(&mut rng(7), &alice, &bob.public_key(), b"x").unwrap();
@@ -680,8 +754,8 @@ mod tests {
680754 /// same blob size (same bucket) but different blob contents (random padding).
681755 #[test]
682756 fn padding_is_random_per_message() {
683− let alice = Identity::generate(&mut rng(1));
684− 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();
685759 let msg = b"hello bob, this is a test message";
686760
687761 let blob1 = encrypt(&mut rng(100), &alice, &bob.public_key(), msg).unwrap();
@@ -702,8 +776,8 @@ mod tests {
702776 /// never sees trailing padding bytes.
703777 #[test]
704778 fn compressed_len_field_is_parsed_correctly() {
705− let alice = Identity::generate(&mut rng(1));
706− 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();
707781
708782 // Normal round-trip.
709783 let msg = b"round trip test with some content";
@@ -725,7 +799,7 @@ mod tests {
725799 /// error says so rather than blaming decryption.
726800 #[test]
727801 fn encrypting_to_an_off_curve_key_reports_an_invalid_public_key() {
728− let alice = Identity::generate(&mut rng(1));
802+ let alice = Identity::generate(&mut rng(1)).unwrap();
729803 let not_a_point = [0xabu8; 32];
730804 assert_eq!(
731805 encrypt(&mut rng(2), &alice, &not_a_point, b"x").unwrap_err(),
@@ -735,7 +809,7 @@ mod tests {
735809
736810 #[test]
737811 fn sk_to_x25519_clamps() {
738− let id = Identity::generate(&mut rng(5));
812+ let id = Identity::generate(&mut rng(5)).unwrap();
739813 let scalar = ed25519_sk_to_x25519(&id.secret_key);
740814 assert_eq!(scalar[0] & 7, 0);
741815 assert_eq!(scalar[31] & 64, 64);
@@ -744,7 +818,7 @@ mod tests {
744818
745819 #[test]
746820 fn challenge_signature_is_deterministic_and_host_bound() {
747− let id = Identity::from_seed(&[7u8; 32]);
821+ let id = Identity::from_seed(&[7u8; 32]).unwrap();
748822 let a = id.sign_challenge("relay.example.com", "deadbeef").unwrap();
749823 let b = id.sign_challenge("relay.example.com", "deadbeef").unwrap();
750824 let c = id.sign_challenge("other.example.com", "deadbeef").unwrap();
@@ -756,7 +830,7 @@ mod tests {
756830 /// We sign whatever a not-yet-trusted relay hands us, so its size is bounded.
757831 #[test]
758832 fn an_oversized_challenge_is_not_signed() {
759− let id = Identity::from_seed(&[7u8; 32]);
833+ let id = Identity::from_seed(&[7u8; 32]).unwrap();
760834 let huge = "a".repeat(MAX_CHALLENGE_LEN + 1);
761835 assert_eq!(
762836 id.sign_challenge("relay.example.com", &huge).unwrap_err(),
@@ -766,4 +840,55 @@ mod tests {
766840 let at_cap = "a".repeat(MAX_CHALLENGE_LEN);
767841 assert!(id.sign_challenge("relay.example.com", &at_cap).is_ok());
768842 }
843+
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.
851+ #[test]
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();
856+
857+ let blob = encrypt(&mut rng(4), &alice, &bob.public_key(), b"hi bob").unwrap();
858+ assert_eq!(
859+ decrypt(&charlie, &blob).unwrap_err(),
860+ CryptoError::Decrypt,
861+ "wrong recipient must fail AEAD — key derivation binds the recipient"
862+ );
863+ }
864+
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.
869+ #[test]
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();
873+ let mut blob = encrypt(&mut rng(3), &alice, &bob.public_key(), b"hi").unwrap();
874+ blob[0] ^= 0xff;
875+ assert_eq!(
876+ decrypt(&bob, &blob).unwrap_err(),
877+ CryptoError::Decrypt,
878+ "version byte tampering is rejected"
879+ );
880+ }
881+
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.
886+ #[test]
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();
890+ let mut blob = encrypt(&mut rng(3), &alice, &bob.public_key(), b"hi").unwrap();
891+ blob[5] ^= 0x01;
892+ assert!(decrypt(&bob, &blob).is_err());
893+ }
769894}
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);
install.sh+13−9
@@ -154,18 +154,21 @@ fetch_to "$DOWNLOAD_URL" "$TMP_BIN"
154154# HTTPS, but that authenticates GitHub's CDN, not the artifact — a compromised or
155155# swapped release asset would otherwise be installed and launched unquestioned.
156156#
157−# The check is best-effort by necessity: releases cut before CI began publishing
158−# .sha256 files have no checksum to compare against, and refusing to install from
159−# them would break upgrades from every existing deployment. So a *missing*
160−# checksum warns and proceeds, while a checksum that is present and does *not*
161−# match is fatal — the case that actually indicates tampering.
157+# N15: Verification is mandatory. A missing checksum, a missing sha256sum tool,
158+# or a mismatch are all fatal — the binary is refused.
159+# N15: Checksum verification is mandatory for all installs. A missing
160+# checksum file indicates either a pre-checksum release or a supply-chain
161+# attack that removed the checksum. We refuse to install without verification
162+# rather than silently proceeding.
162163if fetch_to "$CHECKSUM_URL" "$TMP_SUM" 2>/dev/null; then
163164 EXPECTED=$(cut -d' ' -f1 < "$TMP_SUM")
164165 ACTUAL=$(sha256_of "$TMP_BIN")
165166 if [ -z "$ACTUAL" ]; then
166− echo "warning: no sha256sum/shasum available; skipping checksum verification" >&2
167+ echo "error: no sha256sum/shasum available; cannot verify binary integrity" >&2
168+ exit 1
167169 elif [ -z "$EXPECTED" ]; then
168− echo "warning: checksum file is empty; skipping verification" >&2
170+ echo "error: checksum file is empty; refusing to install unverified binary" >&2
171+ exit 1
169172 elif [ "$EXPECTED" != "$ACTUAL" ]; then
170173 echo "error: checksum verification FAILED for ${ASSET}" >&2
171174 echo " expected: ${EXPECTED}" >&2
@@ -176,8 +179,9 @@ if fetch_to "$CHECKSUM_URL" "$TMP_SUM" 2>/dev/null; then
176179 echo "Checksum verified (${ACTUAL})"
177180 fi
178181else
179− echo "warning: no published checksum at ${CHECKSUM_URL}; skipping verification" >&2
180− echo " (releases predating checksum publication have none)" >&2
182+ echo "error: no published checksum at ${CHECKSUM_URL}; refusing to install unverified binary" >&2
183+ echo " (releases predating checksum publication have none — install from a recent release)" >&2
184+ exit 1
181185fi
182186
183187chmod 755 "$TMP_BIN"
server.knot+260−107
@@ -87,12 +87,29 @@ maxBatchSize = 100
8787-- Cap on gossip events accepted or emitted in a single federation exchange.
8888maxGossipEvents : Int
8989maxGossipEvents = 100
90+-- N13: Cap on newly-online keys per gossip batch that trigger forward sweeps.
91+-- Without this, a single gossip request with 100 `online` events triggers 100
92+-- mailbox scans (O(100 × |messages|)). Capping at 10 limits the amplification
93+-- while still allowing reasonable multi-key presence announcements. Remaining
94+-- newly-online keys are still written to `*presence` — only the forward sweep
95+-- is deferred until the next presence re-announce or a poll triggers
96+-- `forwardQueuedToServer` naturally.
97+maxNewlyOnlineForwards : Int
98+maxNewlyOnlineForwards = 10
9099-- Cap on events returned by one long-poll response.
91100maxPollEvents : Int
92101maxPollEvents = 50
93102-- Per-peer ceiling on outstanding forward attempts.
94103maxForwardsPerServer : Int
95104maxForwardsPerServer = 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
96113-- Global ceiling on the `*forwards` outbox. `maxForwardsPerServer` bounds any one
97114-- peer, but a recipient gossiped as present on many servers fans one blob out to
98115-- all of them, so the total is bounded only by (peers x per-peer cap). This caps
@@ -180,6 +197,15 @@ maxPresencePerServer = 5000
180197-- the same argument for sessions on `maxSessions`, and PROTOCOL.md §10.
181198maxMessagesPerRecipient : Int
182199maxMessagesPerRecipient = 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
183209-- Validity window for an auth challenge before it must be re-issued (1 min).
184210challengeExpiry : Int<Ms>
185211challengeExpiry = 60000
@@ -518,8 +544,15 @@ capPerServer = \items server maxN -> union (filter (\x -> not (x.server == serve
518544-- no timestamp (the same reason `retryPendingForwards` has to sort on the blob).
519545-- Applied by both writers that grow `*forwards` (`forwardQueuedToServer` and
520546-- `handleSendMessages`); the retry sweep only ever shrinks it.
547+--
548+-- N4: Sort by `(retries, encryptedBlob)` for determinism. Without the secondary
549+-- key, a `sortBy retries` alone is unstable when many entries share `retries: 0`,
550+-- and any of them could be dropped — including a fresh forward for a healthy
551+-- peer. The blob is a unique secondary key (the dedup invariant guarantees
552+-- `(toKey, encryptedBlob, toServer)` uniqueness), so the sort is total and
553+-- deterministic.
521554capForwards : [ForwardEntry] -> [ForwardEntry]
522−capForwards = \fwds -> take maxForwardsTotal (sortBy (\f -> f.retries) fwds)
555+capForwards = \fwds -> take maxForwardsTotal (sortBy (\f -> {retries: f.retries, blob: f.encryptedBlob}) fwds)
523556
524557-- Random hex token: one random hex char per recursive call. `n` is the
525558-- desired length in characters (so n=48 yields 24 random bytes of entropy).
@@ -916,6 +949,13 @@ currentSeq = \rows -> fold (\acc r -> max acc r.value) (0 : Timestamp) rows
916949-- and the fresh `seqTs` below would otherwise make `union` keep both rows and
917950-- double-deliver. Re-reading `*messages` per call also collapses duplicate blobs
918951-- appearing twice within one batch.
952+--
953+-- N3: This O(n) scan is a CPU amplification vector. A batch of 100 identical
954+-- blobs to the same recipient triggers 100 × O(|messages|) comparisons. With
955+-- |messages| = 10000, that's 1M comparisons per request, bounded by the 60/min
956+-- rate limit. A real fix needs an index on (toKey, encryptedBlob) or a
957+-- hash-based dedup set — both require runtime support not available in Knot
958+-- today. Documented as an accepted DoS surface.
919959appendMessage : PubkeyHex -> BlobHex -> Int<Ms> -> IO {rw *messages, rw *seqCounter} {}
920960appendMessage = \toKey encryptedBlob t -> do
921961 msgs <- *messages
@@ -927,14 +967,32 @@ appendMessage = \toKey encryptedBlob t -> do
927967 -- for this recipient so the post-append count lands exactly on the ceiling.
928968 -- `- 1` reserves the slot the new row is about to take.
929969 --
930− -- Eviction keys on `encryptedBlob` rather than the row itself because
931− -- (toKey, encryptedBlob) is unique by this function's own dedup invariant
932− -- (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.
933975 let forRecipient = filter (\m -> m.toKey == toKey) msgs
934976 let excess = max 0 (count forRecipient - (maxMessagesPerRecipient - 1))
935− let evicted = map (\m -> m.encryptedBlob) (take excess (sortBy (\m -> m.receivedAt) forRecipient))
936− let kept = filter (\m -> not (m.toKey == toKey && elem m.encryptedBlob evicted)) msgs
937− *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: []}])
938996
939997-- Record that `server` has accepted a federated forward of this message, so a
940998-- later presence re-announce of `toKey` doesn't re-forward the same blob to a peer
@@ -988,6 +1046,12 @@ pruneExpired = \t -> atomic do
9881046 -- Prune forwards whose message was removed by the TTL sweep above.
9891047 -- ForwardEntry carries no timestamp, so match against the surviving
9901048 -- 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.
9911055 fw <- *forwards
9921056 *forwards = filter (\f -> any (\m -> m.toKey == f.toKey && m.encryptedBlob == f.encryptedBlob) surviving) fw
9931057 ff <- *forwardFailures
@@ -1050,56 +1114,70 @@ sendBatchedGossip = \peers events -> case localServerName of
10501114-- comes online: any messages already buffered for that pubkey are enqueued
10511115-- as forwards to the newly-known server.
10521116
1053−-- Attempt every forward in `batch` to one peer, in order, and report whether any
1054−-- attempt was a genuine peer failure. Drops successful entries from `*forwards` and
1055−-- records the peer on the message's `deliveredTo`; on failure bumps the retry count
1056−-- 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`.
10571122--
10581123-- INVARIANT — this must not touch `*forwardFailures`. The caller records at most one
1059−-- failure per peer per sweep, from the Bool returned here; recording per failing
1060−-- *message* would let one unreachable peer with N buffered messages take N backoff
1061−-- steps in a single sweep and pin itself at `retryBackoffMax` for `forwardFailureMaxAge`.
1062−-- 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*.
10631128--
10641129-- A 404 is NOT a peer failure. `handleForwardMessage` answers 404 `no_presence`
10651130-- whenever the recipient is not *currently* online at that peer, which is an
10661131-- expected, retryable outcome (PROTOCOL.md §7) — the peer is healthy and answering.
1067−-- But `fetch` returns `Err` for every status >= 400, so treating any `Err` as a
1068−-- failure would feed a routine 404 into `recordFailure`, and `*forwardFailures` is
1069−-- shared with gossip: one stale-presence 404 would suppress all forwards *and* all
1070−-- gossip to that peer for a full backoff step (30 s–8 min). So a 404 leaves the peer
1071−-- unpenalized and the forward row untouched — no retry bump either, since the row is
1072−-- 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.
10731145--
10741146-- Recursive rather than `forEach` because the result has to be accumulated across
10751147-- iterations; `sortBy` fixes the iteration order that `head`/`drop 1` then walk.
1076−retryForwardsTo : Text -> [ForwardEntry] -> IO {rw *forwards, rw *messages, network} Bool
1148+retryForwardsTo : Text -> [ForwardEntry] -> IO {rw *forwards, rw *messages, network} {anyFailed: Bool, anySucceeded: Bool}
10771149retryForwardsTo = \server batch -> case head batch of
1078− Nothing {} -> yield (False {})
1150+ Nothing {} -> yield {anyFailed: False {}, anySucceeded: False {}}
10791151 Just {value: f} -> do
10801152 result <- fetch (federationUrl server) (ForwardMessage {f.toKey, f.encryptedBlob})
10811153 let status = case result of
10821154 Err {error: e} -> e.status
10831155 Ok _ -> 200
10841156 let success = status < 400
1085− -- 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).
10861159 let deferred = status == 404
10871160 let isMatch = forwardMatches f.toKey f.encryptedBlob server
10881161 let isDone = \x -> isMatch x && (success || x.retries + 1 >= maxForwardRetries)
10891162 atomic (do
1090− when (not deferred) (do
1091− cur <- *forwards
1092− *forwards = map (\x -> if isMatch x
1093− then {x.toServer, x.toKey, x.encryptedBlob, retries: x.retries + 1}
1094− 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)
10951171 when success (markDelivered f.toKey f.encryptedBlob server))
1096− restFailed <- retryForwardsTo server (drop 1 batch)
1097− 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}
10981174
10991175-- Walk pending forwards grouped by destination and retry each peer's backlog (up
11001176-- to maxBatchSize per peer) via `/federation/forward`. One failure record per peer
1101−-- per sweep: a sweep in which every attempt succeeded clears the peer's backoff, a
1102−-- 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`.
11031181retryPendingForwards : IO {rw *forwardFailures, rw *forwards, rw *messages, network, clock} {}
11041182retryPendingForwards = do
11051183 t <- now
@@ -1121,13 +1199,17 @@ retryPendingForwards = do
11211199 -- `maxForwardRetries` (dropped). `ForwardEntry` carries no timestamp, so key on
11221200 -- the unique blob.
11231201 let batch = take maxBatchSize (sortBy (\f -> f.encryptedBlob) (filter (\f -> f.toServer == server) fwds))
1124− anyFailed <- retryForwardsTo server batch
1125− -- `count batch > 0` guards the clear: `maxBatchSize` is startup-overridable,
1126− -- and at 0 every batch is empty — an empty sweep must not be read as a
1127− -- success and reset a failing peer's backoff.
1128− 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
11291211 then recordFailure server t
1130− else when (count batch > 0) (clearFailure server t)))
1212+ else when (count batch > 0 && result.anySucceeded) (clearFailure server t)))
11311213
11321214-- Forward queued messages to a server when a remote key comes online.
11331215-- Skips messages already delivered to `server` (tracked via Message.deliveredTo)
@@ -1150,6 +1232,32 @@ forwardQueuedToServer = \pubkey serverName -> atomic (do
11501232 let newFwds = map (\m -> {toServer: server, m.toKey, m.encryptedBlob, retries: 0}) (take budget (sortBy (\m -> m.receivedAt) candidates))
11511233 *forwards = capForwards (union fwds newFwds))
11521234
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+
11531261-- ============================================
11541262-- Handler: challenge / verify
11551263-- ============================================
@@ -1200,68 +1308,82 @@ handleVerifyAuth = \pubkey challenge signature revokeOthers clientIp -> do
12001308 -- relay server cannot replay the signature to a different server. We verify
12011309 -- against our own configured hostname (port stripped), which a relay cannot forge.
12021310 --
1203− -- Signature verification is deferred until *after* the challenge lookup confirms
1204− -- a matching challenge exists (inside the atomic below). This avoids spending
1205− -- expensive Ed25519 CPU on /auth/verify calls that have no matching challenge,
1206− -- which would otherwise be a cheap CPU-DoS vector. The trade-off is that
1207− -- `verify` runs inside the STM transaction and may re-run on retries — but
1208− -- retries only happen on concurrent writes to *challenges/*sessions, which
1209− -- 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.
12101320 let signedText = "skrepka-auth-v1:" ++ authHost serverHost ++ ":" ++ challenge
1211− result <- atomic (do
1321+ challengeOk <- atomic (do
12121322 ch <- *challenges
12131323 let same = \c -> c.pubkey == pubkey && c.challenge == challenge
1214− let matchingChallenge = findFirst same ch
1215− case matchingChallenge of
1216− Nothing {} -> yield (Nothing {})
1217− Just {value: c} -> do
1218− if not (c.expiresAt > t && matchIp clientIp c.ip)
1219− then yield (Nothing {})
1220− else do
1221− -- Verify the signature only now that a matching, unexpired, IP-bound
1222− -- challenge exists.
1223− let sigValid = case bytesFromHex pubkey of
1224− Just {value: pk} -> case bytesFromHex signature of
1225− Just {value: sigBytes} -> verify pk (textToBytes signedText) sigBytes
1226− _ -> False {}
1227− _ -> False {}
1228− if not sigValid
1229− then yield (Nothing {})
1230− else do
1231− -- Consume the challenge and optionally revoke other sessions + challenges.
1232− -- When `revokeOthers` is set, drop all other sessions AND outstanding
1233− -- challenges for this pubkey, so a captured challenge cannot be
1234− -- redeemed for a new session after revocation (Q35).
1235− *challenges = if revokeOthers
1236− then filter (\x -> not (x.pubkey == pubkey)) ch
1237− else filter (\x -> not same x) ch
1238− ses <- *sessions
1239− let pruned = if revokeOthers
1240− then filter (\s -> s.pubkey != pubkey) ses
1241− else capPerKey ses pubkey maxSessionsPerKey
1242− let newSession = {token, pubkey, expiresAt: expiry, ip: clientIp}
1243− -- LRU-evicts the soonest-to-expire session when the pool is full; this global
1244− -- eviction is Sybil-floodable, see the LIMITATION note on `maxSessions`.
1245− let pooled = union pruned [newSession]
1246− -- Count what `capList` is about to shed, so the handler can warn about it
1247− -- below. That flood has no other observable signature: evicted users just
1248− -- start seeing 401s and silently re-authenticate, which looks like normal
1249− -- traffic in the access log. A sustained nonzero rate here is the alert.
1250− let evicted = max 0 (count pooled - maxSessions)
1251− *sessions = capList pooled maxSessions
1252− peers <- refreshLocalPresence pubkey expiry
1253− yield (Just {value: {peers, evicted}}))
1254− case result of
1255− Nothing {} -> yield (Err {error: {status: 401, message: "invalid"}})
1256− Just {value: r} -> do
1257− -- Warn outside the `atomic` block, never inside: an STM transaction can
1258− -- retry, and a console write is not rolled back with it — logging inside
1259− -- would emit one line per retry attempt rather than one per eviction.
1260− -- Include the requesting pubkey and IP for audit (Q38): an operator
1261− -- investigating a Sybil flood needs to know which identities are responsible.
1262− 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 ++ ")"))
1263− when federationEnabled (fork (sendBatchedGossip r.peers [{eventType: "online", pubkey: Just {value: pubkey}}]))
1264− 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}})
12651387
12661388-- ============================================
12671389-- Handler: poll
@@ -1300,11 +1422,16 @@ handlePoll = \cursor authorization clientIp -> do
13001422 -- so peers don't time us out mid-session.
13011423 let stale = not any (\p -> p.pubkey == pubkey && isLocalServer p.server && p.expiresAt - t >= sessionExpiry / 2) pres
13021424 refreshPeers <- if stale then refreshLocalPresence pubkey (t + sessionExpiry) else yield []
1303− 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}}))
13041430 case setup of
13051431 Nothing {} -> yield (Err {error: {status: 401, message: "unauthorized"}})
13061432 Just {value: s} -> do
1307− 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}}]))
13081435 pollResult <- doPoll s t
13091436 yield (Ok {value: pollResult})
13101437
@@ -1415,10 +1542,15 @@ handleSendMessages = \messages authorization clientIp -> do
14151542 Nothing {} -> do
14161543 committed <- atomic (do
14171544 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.
14181550 let staged = map (\m -> {
14191551 m.to,
14201552 m.encryptedBlob,
1421− 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))
14221554 }) messages
14231555 forEach staged (\c -> do
14241556 -- Read `*messages` *before* the append: `delivered` below must see the
@@ -1523,7 +1655,7 @@ forkForwardsTo = \to encryptedBlob servers -> forEach servers (\server -> when (
15231655-- fromServer) row touched by this batch, then re-adds fresh-TTL rows for
15241656-- pubkeys with an `online` event (so Online wins on collision). For keys
15251657-- that transition offline → online, flushes any locally-buffered messages via
1526−-- `forwardQueuedToServer`. Events with an unrecognized `eventType` or no
1658+-- `forwardQueuedToAll`. Events with an unrecognized `eventType` or no
15271659-- `pubkey` are skipped (forward-compat) rather than rejecting the batch.
15281660--
15291661-- SECURITY: this is the gossip-redirect ingress described in the section header
@@ -1534,7 +1666,7 @@ handleRecvGossip : [GossipEvent] -> ServerName -> IO {r *messages, rw *forwards,
15341666handleRecvGossip = \events fromServer -> do
15351667 t <- now
15361668 -- Widen the peer-supplied `ServerName` once (see `type ServerName`);
1537− -- `forwardQueuedToServer` below still takes the refined name.
1669+ -- `forwardQueuedToAll` below still takes the refined name.
15381670 let from = (fromServer : Text)
15391671 if not federationEnabled
15401672 then yield (Err {error: {status: 403, message: "federation_disabled"}})
@@ -1557,12 +1689,13 @@ handleRecvGossip = \events fromServer -> do
15571689 Ok {value: pk} -> if e.eventType == "online"
15581690 then {
15591691 touched: union acc.touched [pk],
1560− added: union acc.added [{pubkey: pk, server: from, expiresAt: t + onlineGossipTtl}]
1692+ added: union acc.added [{pubkey: pk, server: from, expiresAt: t + onlineGossipTtl}],
1693+ offline: acc.offline
15611694 }
15621695 else if e.eventType == "offline"
1563− then {acc.added, touched: union acc.touched [pk]}
1696+ then {acc.added, touched: union acc.touched [pk], offline: union acc.offline [pk]}
15641697 else acc
1565− Nothing {} -> acc) {touched: ([] : [PubkeyHex]), added: ([] : [PresenceEntry])} events
1698+ Nothing {} -> acc) {touched: ([] : [PubkeyHex]), added: ([] : [PresenceEntry]), offline: ([] : [PubkeyHex])} events
15661699 let kept = filter (\p -> p.server != from || not elem p.pubkey collected.touched) pres
15671700 let merged = union kept collected.added
15681701 -- Cap the gossip-learned rows: per-peer first, so one peer cannot crowd every
@@ -1574,8 +1707,28 @@ handleRecvGossip = \events fromServer -> do
15741707 let localRows = filter (\p -> isLocalServer p.server) merged
15751708 let remoteRows = filter (\p -> not isLocalServer p.server) merged
15761709 *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
1716+ -- N14: Cancel pending forwards to `from` for offline keys. Without this,
1717+ -- forwards to a peer that just went offline retry for up to 80 minutes,
1718+ -- wasting maxForwardsPerServer slots and generating failed HTTP requests.
1719+ -- The message stays in *messages, so it's re-forwarded if the key
1720+ -- re-appears on another peer.
1721+ when (count offlineOnly > 0) (do
1722+ fwds <- *forwards
1723+ *forwards = filter (\f -> not (f.toServer == from && elem f.toKey offlineOnly)) fwds)
15771724 yield (filter (\pk -> not any (\p -> p.pubkey == pk && p.server == from && p.expiresAt > t) pres) (map (\p -> p.pubkey) collected.added)))
1578− fork (forEach newlyOnline (\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)
15791732 yield (Ok {value: {}})
15801733
15811734-- POST /federation/forward: receive a single federated message destined for
server.onew file

Binary file not shown.