on master help sign in
.github/workflows/build.yml+1−1
since splitsnapshots
unifiedsplit
@@ -157,7 +157,7 @@ jobs:
157
157
158 PK=$(printf '0%.0s' $(seq 1 64)) # 64 hex chars: a well-formed pubkey
158 PK=$(printf '0%.0s' $(seq 1 64)) # 64 hex chars: a well-formed pubkey
159 SIG=$(printf '0%.0s' $(seq 1 128)) # 128 hex chars: a well-formed signature
159 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
161
161
162 # $1 = expected status, $2 = label, rest = curl args
162 # $1 = expected status, $2 = label, rest = curl args
163 expect() {
163 expect() {
AGENTS.md+2−2
@@ -11,7 +11,7 @@ Decentralized, end-to-end encrypted messenger. No accounts, no home servers —
11- **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.
11- **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.
12- **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.
12- **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.
13- **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.
13- **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.
15
15
16## Project Layout
16## Project Layout
17
17
@@ -95,7 +95,7 @@ Two kinds of `Event` variants, and the distinction matters:
95
95
96The `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.
96The `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.
97
97
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.
99
99
100## Code Conventions
100## Code Conventions
101
101
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.
AUDIT-NEW.mdnew file+816
@@ -0,0 +1,816 @@
1+# Skrepka Security Audit — New Findings (2026-07-15)
2+
3+**Scope:** Exhaustive multi-pass audit of PROTOCOL.md, server.knot, and core/src/ (app.rs, crypto.rs, protocol.rs, model.rs, phonemic.rs, ffi.rs). Findings below are **not** documented in PROTOCOL-REVIEW.md.
4+
5+**Legend:** 🔴 critical · 🟠 high · 🟡 medium · 🔵 low · ⚪ info
6+
7+---
8+
9+## 1. Cryptographic Protocol Weaknesses
10+
11+### 🔴 Q1. HKDF salt omits sender identity — KCI/UKS not prevented by construction
12+
13+**Type:** Design gap (protocol + crypto.rs)
14+**Files:** `core/src/crypto.rs:276-290` (`derive_key`), PROTOCOL.md §3 step 3
15+
16+The HKDF salt is `ephemeral_public || recipient_x25519_public` — both public values, neither identifying the sender. The encryption key is derived entirely from the ephemeral X25519 exchange and the recipient's public key. While the Ed25519 signature inside the AEAD binds the sender, the *key derivation* itself has no sender contribution.
17+
18+This means anyone who knows the recipient's private key can compute the same shared secret from a captured ephemeral public key — there's no sender contribution to the KDF. The signature is the only thing binding the sender, and it's verified *after* AEAD decryption. An attacker who obtains the recipient's private key can decrypt all captured blobs (already noted as "no forward secrecy" in §10), but additionally, because the KDF has no sender binding, an attacker can *replace* the sender identity and signature inside a captured ciphertext by re-encrypting with the same ephemeral key — wait, no, they can't re-encrypt without the shared key. However, if the attacker *has* the recipient's private key, they can decrypt, strip the original sender, forge a new inner buffer with their own sender key + signature over the same `recipient || compressed_len || compressed || padding`, re-encrypt with the derived key, and re-send. The recipient sees a valid message from a different sender. This is a *key compromise impersonation (KCI)* vector: compromising the recipient's key lets an attacker re-attribute captured messages.
19+
20+**Attack scenario:** An attacker records ciphertext between Alice and Bob. Later they compromise Bob's private key. They decrypt the blobs, replace Alice's identity with Mallory's (signing under Mallory's key), re-encrypt, and deliver to Bob. Bob sees messages apparently from Mallory.
21+
22+**Exploitable in practice:** Requires recipient key compromise, which the protocol already considers catastrophic. The KCI vector is an *additional* consequence of that compromise, not a standalone attack. Still, including the sender's public key in the HKDF salt or info would close it by construction.
23+
24+**Suggested fix:** Include `sender_ed25519_public` in the HKDF salt or info: `salt = ephemeral_public || recipient_x25519_public || sender_ed25519_public`. Since the sender pubkey is inside the AEAD, a decryptor already has it before verification, but the key derivation would need it — so the sender pubkey would need to be *outside* the AEAD (as associated data or in the blob header), which changes the wire format. Alternatively, accept this as a documented limitation of v0.1 (the signature already prevents forgery without key compromise).
25+
26+### 🟡 Q2. No associated data (AAD) on AEAD — ciphertext is malleable across contexts
27+
28+**Type:** Design gap (protocol + crypto.rs)
29+**Files:** `core/src/crypto.rs:384-387` (encrypt), `core/src/crypto.rs:434-438` (decrypt)
30+
31+XChaCha20-Poly1305 is used with **no associated data**: `cipher.encrypt(nonce, inner)` and `cipher.decrypt(nonce, ciphertext)`. The version byte, ephemeral public key, and nonce are all in the clear and not authenticated. An attacker who can modify the blob in transit (a malicious relay, or a network attacker without TLS) can flip the version byte or corrupt the ephemeral key without any AEAD failure — the AEAD only protects the ciphertext body.
32+
33+While corrupting these fields will cause decryption/verification to fail downstream (wrong key derivation, wrong version), there's no *cryptographic binding* between the outer envelope fields and the inner ciphertext. A relay could, for example, swap the ephemeral public key from one blob with that of another (keeping the same nonce + ciphertext), and the recipient would get a decryption failure rather than an authentication failure — a distinction that matters for error handling and oracle analysis.
34+
35+More importantly, the `to` field in the envelope is **not** authenticated by the AEAD. A relay can change the `to` field on a stored message, redirecting it to a different recipient. That recipient can't decrypt it (wrong key), but the *original* recipient's message is now gone from the mailbox. This is a denial-of-service vector that doesn't require breaking encryption.
36+
37+**Attack scenario:** A malicious relay swaps the `to` field on Alice's message to Bob, replacing it with Charlie's pubkey. Bob never receives the message. Charlie receives garbage (can't decrypt). Neither side gets an error from the protocol.
38+
39+**Exploitable in practice:** Yes — any relay can do this. However, a relay can already drop messages, so this is equivalent in impact to message loss, which is an accepted limitation.
40+
41+**Suggested fix:** Include `version_byte || ephemeral_public || nonce` as AAD in the AEAD. For the envelope-level `to` field, consider including `recipient_ed25519_public` as AAD (it's already in the signed inner buffer, but AAD would make the envelope integrity-checked too). This is a wire-format change (v0x02).
42+
43+### 🟡 Q3. compressed_len field is attacker-controllable and trusted for slicing
44+
45+**Type:** Implementation concern (crypto.rs)
46+**Files:** `core/src/crypto.rs:449-459`
47+
48+During decryption, `compressed_len` is read from the AEAD-decrypted inner buffer and used to slice the `compressed` region: `let compressed = &inner[100..100 + compressed_len]`. The AEAD protects the integrity of the inner buffer, so this field is authenticated — but only *after* decryption. The check `100 + compressed_len > inner.len()` prevents an out-of-bounds read, but a `compressed_len` that points into the padding region (i.e., `compressed_len < actual_compressed_data_len`) would silently feed truncated zstd data to the decompressor, which would fail. Conversely, `compressed_len > actual_compressed_len` would include padding bytes in the zstd frame, which the decompressor might or might not reject.
49+
50+Since the signature is over `recipient_pub || compressed_len_bytes || compressed || padding`, the signed region includes the `compressed_len` field. A tampered `compressed_len` would break the signature. So this is actually well-protected. The concern is that the signature is verified *after* the `compressed_len` is already used for slicing — but since the slice is only used for decompression *after* signature verification (`crypto.rs:472`), this is safe.
51+
52+**Verdict:** Not a bug after careful analysis — the signature covers `compressed_len` and the decompression happens after verification. Downgraded to info.
53+
54+### ⚪ Q4. zstd decompression bomb window before content-size cap
55+
56+**Type:** Implementation defense-in-depth (crypto.rs)
57+**Files:** `core/src/crypto.rs:303-322`
58+
59+The decompressor caps `window_log_max(21)` (2 MiB window) and output at `MAX_PLAINTEXT_LEN + 1` (1 MiB + 1 byte). This is solid. However, a crafted zstd frame with a small window but very long output (many small blocks) could still cause significant CPU consumption before the 1 MiB + 1 byte limit triggers. The `take(MAX_PLAINTEXT_LEN + 1)` read will read one byte past the cap, forcing a full decompression attempt up to that point. This is bounded at ~1 MiB of output, so CPU is bounded too. This is fine.
60+
61+### 🟡 Q5. CRIME/BREACH surface is wider than documented for profile messages
62+
63+**Type:** Spec gap / design weakness (PROTOCOL.md §3)
64+**Files:** PROTOCOL.md §3, §4; `core/src/protocol.rs:73-91`
65+
66+The spec warns about CRIME/BREACH but says "an attacker cannot make the client re-encrypt a chosen variant of a secret on demand." However, `profile` messages compress `display_name`, `bio`, and `photo` together in one zstd frame. If an attacker can influence part of the profile (e.g., the user sets their display name based on something an attacker controls, like a contact's name echoed back), and the profile is broadcast to contacts, the compressed size could leak information. The attacker would need to:
67+
68+1. Influence part of the plaintext that is compressed alongside a secret
69+2. Observe the resulting blob size (which is bucket-padded, so only the bucket boundary leaks)
70+
71+The padding buckets (256, 512, 1024, ...) are coarse enough that the leak is minimal in practice. But the spec's claim that "an attacker cannot make the client re-encrypt a chosen variant of a secret on demand" is too strong: if an attacker sends messages that cause the user to change their display name or bio (e.g., "change your name to match mine"), the re-broadcast compresses attacker-influenced text alongside the user's photo (which is base64 and thus high-entropy/incompressible). The photo being base64 limits the CRIME surface since it's already nearly random.
72+
73+**Exploitable in practice:** Very unlikely given bucket padding and the difficulty of controlling what text gets compressed alongside secrets.
74+
75+**Suggested fix:** Clarify in the spec that the CRIME/BREACH surface extends to profile broadcasts (where attacker-influenced text and user-controlled secrets are compressed together), not just text messages.
76+
77+---
78+
79+## 2. Server Implementation Bugs
80+
81+### 🟠 Q6. `ackPolledMessages` is called before `doPoll` — races with concurrent sends to the same mailbox
82+
83+**Type:** Implementation bug (server.knot)
84+**Files:** `server.knot:1258-1287` (`handlePoll`), `server.knot:1343-1351` (`ackPolledMessages`)
85+
86+In `handlePoll`, the cursor-ack (`ackPolledMessages`) runs inside the setup atomic block, *before* `doPoll` collects events. If a concurrent `/messages` request arrives between the ack and the collect, the new message is appended with a fresh `receivedAt` that is strictly greater than the acked cursor — so it will be picked up by `doPoll`'s collect. This is actually correct behavior.
87+
88+However, there's a subtler issue: the cursor is clamped to `max(0 (min (max t curSeq) cursor))` before the ack. If the client sends a cursor that is *ahead* of `curSeq` (e.g., from a different relay), the clamp to `max t curSeq` could *lower* the cursor, which means `ackPolledMessages` acks less than the client intended. But this is intentional (the comment explains it).
89+
90+After deeper analysis, the ordering here is actually correct — the ack happens first (deleting old messages), then the collect picks up what's left that's newer than the cursor. The race with concurrent sends is handled by STM: the new message gets a fresh `receivedAt > cursor`, so it's picked up.
91+
92+**Verdict:** Not a bug after careful analysis. Downgraded to info.
93+
94+### 🟡 Q7. `appendMessage` dedup is O(n) per message — quadratic under burst
95+
96+**Type:** Implementation performance issue (server.knot)
97+**Files:** `server.knot:919-937` (`appendMessage`)
98+
99+`appendMessage` checks `any (\\m -> m.toKey == toKey && m.encryptedBlob == encryptedBlob) msgs` — a full scan of `*messages` — on every call. For a batch of 100 messages to different recipients, this is 100 × |messages| comparisons. With `maxMessagesPerRecipient = 10000` and many recipients, this can be tens of thousands of comparisons per message. Under sustained throughput this is quadratic.
100+
101+This is documented in the file header ("Everything is intentionally O(n) over flat lists; performance is sacrificed for clarity"), so it's a known design choice, not a security finding per se. But it creates a **DoS amplification** vector: an authenticated sender can send 100 messages per request (up to 60 requests/min = 6000/min), each triggering a full scan of the mailbox. With 10,000 messages per recipient and many recipients, each scan is O(total_messages), making the CPU cost per request proportional to the entire mailbox size.
102+
103+**Attack scenario:** An attacker authenticates (free — just generate a keypair) and sends 100-message batches of junk to many recipients. Each message in the batch triggers a full `*messages` scan for dedup. With a large mailbox, this can consume significant CPU per request.
104+
105+**Exploitable in practice:** Bounded by rate limits (60/min per IP) and the fact that the attacker must be authenticated. The CPU amplification is real but modest.
106+
107+**Suggested fix:** Index `*messages` by `(toKey, encryptedBlob)` or use a hash-based dedup set.
108+
109+### 🟠 Q8. `maxMessagesPerRecipient` eviction destroys genuine mail under flood
110+
111+**Type:** Design gap (server.knot)
112+**Files:** `server.knot:156-182`, `server.knot:926-937`
113+
114+This is documented in the code comments as a "trade, not a fix," but it constitutes a real attack: an authenticated sender (pubkeys are free to mint) can flood a recipient's mailbox with 10,000 junk messages, pushing out all genuine undelivered messages. The recipient's client only sees junk. The comment says "at least the newest messages always land" — but the newest messages are the *attacker's junk*, not the genuine messages that were already queued.
115+
116+The `MAX_POLL_TOTAL_BYTES` client-side budget (64 MiB) means the client processes a bounded amount per poll, but the server has already destroyed the genuine messages by eviction. The client never sees them.
117+
118+**Attack scenario:**
119+1. Victim is offline for a day. Attacker sends 10,000 messages to victim's pubkey.
120+2. Each message is a valid encrypted blob (attacker generates a keypair and encrypts real messages to the victim — they don't need to be decryptable by the victim, just valid blobs).
121+3. Victim comes online, polls, and gets a page of junk. Their genuine messages were evicted.
122+
123+**Exploitable in practice:** Yes — the attacker needs to be authenticated (free) and send 10,000 messages. At 60 requests/min × 100 messages = 6,000/min, this takes ~2 minutes. The rate limit is per-IP, so the attacker needs multiple IPs or time.
124+
125+**Suggested fix:** This is an accepted design tradeoff in the current implementation. A real fix requires per-sender quotas or pricing. Document it in PROTOCOL.md §10 as a DoS vector, not just a capacity concern.
126+
127+### 🟡 Q9. `capForwards` evicts by retry count, not by age — fresh forwards can be dropped
128+
129+**Type:** Implementation bug (server.knot)
130+**Files:** `server.knot:515-522` (`capForwards`), `server.knot:109-110` (`maxForwardsTotal`)
131+
132+`capForwards` keeps the lowest-retry entries (`take maxForwardsTotal (sortBy (\\f -> f.retries) fwds)`). When the outbox exceeds `maxForwardsTotal = 50000`, the *highest-retry* entries are dropped. The comment says these are "closest to `maxForwardRetries` (about to be abandoned anyway)."
133+
134+But there's a subtle issue: a fresh forward (retries: 0) for a *healthy* peer could be dropped if the outbox is full of fresh forwards (all retries: 0) for *failing* peers. The sort is by `retries` only, not by `retries` then by age or destination health. When many entries share `retries: 0`, the `sortBy` is unstable (Knot's sort may not be stable), so *any* retries-0 entry could be dropped — including one for a healthy peer that would have succeeded on the first try.
135+
136+**Attack scenario:** An attacker floods the forwards outbox by gossipping many fake "online" events, causing `forwardQueuedToServer` to stage forwards for many fake servers. Each fails and increments retries. When `maxForwardsTotal` is reached, genuine forwards for healthy peers may be evicted alongside the junk.
137+
138+**Exploitable in practice:** Requires federation to be enabled and the attacker to be able to reach `/federation/gossip`. The genuine forward would be re-staged on the next presence announce, so the impact is a delivery delay, not permanent loss.
139+
140+**Suggested fix:** Sort by `(retries, age)` or skip entries for peers in backoff before capping.
141+
142+### 🟡 Q10. `genToken` entropy is 4 bits per call — collision-resistant but timing-leaky
143+
144+**Type:** Implementation concern (server.knot)
145+**Files:** `server.knot:532-537`
146+
147+`genToken` generates one hex character per recursive call, drawing from `randomInt 16`. Each call to `randomInt` produces 4 bits. For 48 chars (192 bits), this is 48 calls to `randomInt`. The concern is that `randomInt` on the Knot runtime may not be a CSPRNG — the function signature is `IO {random}` which suggests a runtime-provided random source, but the implementation is not visible here.
148+
149+If `randomInt` uses a non-cryptographic PRNG (e.g., a LCG), the 192-bit tokens would be predictable. The auth challenge tokens and session tokens both use `genToken`. If predictable, an attacker could predict future session tokens after observing some.
150+
151+**Exploitable in practice:** Depends on the Knot runtime's `randomInt` implementation, which is not in this codebase. If it uses `/dev/urandom` or equivalent, this is fine. If it uses a userspace PRNG seeded from a low-entropy source, this is critical.
152+
153+**Suggested fix:** Verify that the Knot runtime's `randomInt` uses a CSPRNG. Document the assumption.
154+
155+### 🟡 Q11. `handleVerifyAuth` challenge lookup is O(n) — timing varies with challenge pool size
156+
157+**Type:** Implementation concern (server.knot)
158+**Files:** `server.knot:1210-1214`
159+
160+The challenge verification does `any (\\c -> same c && c.expiresAt > t && matchIp clientIp c.ip) ch` — a linear scan of `*challenges`. With `maxChallenges = 10000`, this is up to 10,000 comparisons per verify. The timing varies with the position of the matching challenge in the list, which could be observable.
161+
162+Combined with the already-documented token comparison timing side-channel (finding B), this adds another timing channel: the time to find the matching challenge leaks its position in the list, which leaks when it was inserted (roughly when the `/auth/challenge` was called).
163+
164+**Exploitable in practice:** Very low impact — the challenge is consumed immediately after verification, and the timing difference is small relative to network jitter.
165+
166+**Suggested fix:** Index challenges by `(pubkey, challenge)` for O(1) lookup.
167+
168+### 🟡 Q12. `handleVerifyAuth` computes signature verification outside atomic — TOCTOU on challenge
169+
170+**Type:** Implementation gap (server.knot)
171+**Files:** `server.knot:1196-1214`
172+
173+The `sigValid` computation is done outside the atomic block (line 1204-1209), but the challenge lookup is inside the atomic (line 1213). Between computing `sigValid` and the atomic block, the challenge could be consumed by a concurrent `/auth/verify` with the same challenge. But the atomic block re-checks: `any (\\c -> same c && ... && c.expiresAt > t && matchIp clientIp c.ip) ch && sigValid`. The `sigValid` is pre-computed, so the check is: challenge exists AND signature is valid. If the challenge is consumed between the pre-compute and the atomic, the `any` returns false, and the verify fails. This is safe — no TOCTOU.
174+
175+But there's a subtler issue: the `clientIp` for the challenge (set at `/auth/challenge` time) and the `clientIp` for the verify (set at `/auth/verify` time) must match (`matchIp clientIp c.ip`). If the client's IP changes between challenge and verify (mobile network change), the verify fails. This is documented as expected behavior. But if `trustForwardedFor` is true and the client can spoof `X-Forwarded-For`, they could set a different IP on the challenge and verify requests — but they'd need to match, so they'd just set the same spoofed IP on both. This is already documented (finding 8).
176+
177+**Verdict:** Not a new bug. The pre-compute is safe because the atomic block re-validates the challenge's existence.
178+
179+### 🟠 Q13. `revokeOthers` race — concurrent sessions for same pubkey can race on revoke
180+
181+**Type:** Implementation bug (server.knot)
182+**Files:** `server.knot:1219-1231`
183+
184+When `revokeOthers: true`, the handler does:
185+```
186+let pruned = if revokeOthers
187+then filter (\\s -> s.pubkey != pubkey) ses
188+else capPerKey ses pubkey maxSessionsPerKey
189+```
190+
191+This drops *all* other sessions for this pubkey. But if a concurrent `/auth/verify` for the same pubkey is in flight (without `revokeOthers`), it could install a new session between the `filter` and the `union`:
192+
193+1. Verify A (revokeOthers=true) reads `*sessions`, filters out all pubkey sessions → `pruned`
194+2. Verify B (revokeOthers=false) reads `*sessions` (still has the old sessions), adds a new one → commits
195+3. Verify A commits `pruned + newSession` → Verify B's session is lost
196+
197+But wait — both are inside `atomic` blocks, and STM guarantees serializability. If Verify A's atomic block retries after Verify B commits, Verify A re-reads `*sessions` (now including B's session) and re-applies the filter — so B's session would be dropped. This is actually the *intended* behavior of `revokeOthers`: it should drop all other sessions. The "race" is really just STM serializing correctly.
198+
199+**Verdict:** Not a bug. STM handles this correctly. Downgraded to info.
200+
201+### 🟡 Q14. `pruneExpired` presence offline gossip may miss peers that expired in a prior sweep
202+
203+**Type:** Implementation concern (server.knot)
204+**Files:** `server.knot:1614-1633` (`backgroundPrune`)
205+
206+The prune loop snapshots peers *before* the sweep (`prePeers`) and unions with *post-sweep* peers. But `pruneExpired` returns only locally-expired keys that have no other live local session. If a key expired in a *previous* sweep (and was already removed from `*presence`), the current sweep has nothing to return for it. The offline gossip for that key was already sent in the previous sweep. This is correct — each key gets exactly one offline event.
207+
208+**Verdict:** Not a bug. The pre/post union handles the edge case correctly.
209+
210+### 🟡 Q15. `forwardQueuedToServer` budget check reads `*forwards` count but doesn't reserve atomically
211+
212+**Type:** Implementation concern (server.knot)
213+**Files:** `server.knot:1140-1151`
214+
215+`forwardQueuedToServer` computes `budget = max 0 (maxForwardsPerServer - existing)` where `existing = countWhere (\\f -> f.toServer == server) fwds`, then takes `take budget` candidates and unions them into `*forwards`. The whole thing is inside one `atomic` block, so it's serializable — two concurrent calls would serialize, and the second would see the first's additions. This is correct.
216+
217+**Verdict:** Not a bug. STM handles concurrency correctly.
218+
219+---
220+
221+## 3. Client Core Logic Bugs
222+
223+### 🟠 Q16. `ingest_poll` cursor clamping uses `now_ms() + 60_000` — relay can advance cursor beyond actual messages
224+
225+**Type:** Implementation concern (app.rs)
226+**Files:** `core/src/app.rs:1967-1975`
227+
228+The cursor is clamped to `page.cursor.min(now_ms().saturating_add(60_000))`. If a hostile relay sends `cursor = now_ms() + 59_000`, the client accepts it, writes it to disk, and the next poll sends this cursor to the relay. If the client later switches to a *different* relay, the cursor is reset to 0 (line 868) — so this is per-relay and not cross-relay. But on the *same* relay, a hostile relay can send an artificially high cursor, causing the client to ack (delete) all messages up to that cursor on the next poll — even messages the client hasn't seen yet.
229+
230+Wait — the cursor is what the *client* sends to the *server*. The server uses it to ack (delete) messages ≤ cursor. If a relay sends a high cursor in the poll response, the client adopts it and sends it back on the next poll. The relay then deletes everything ≤ that cursor. If there were messages the client never received (because the relay withheld them), they're now permanently lost.
231+
232+But the relay already controls what messages it sends to the client — it could simply withhold messages and never show them. The cursor doesn't give the relay any new power it doesn't already have. The relay can always just not send events. So this is not a new attack vector.
233+
234+However, there's a subtler issue: the cursor is persisted to disk. If the relay sends a high cursor and the client adopts it, then the client reconnects to the same relay after a network interruption, the cursor is still high. Any messages that arrived at the relay during the interruption (with `receivedAt` ≤ the poisoned cursor) are automatically acked and deleted.
235+
236+**Attack scenario:** A malicious relay sends `cursor = now_ms() + 59000` in an empty poll response. The client adopts it and persists it. Later, legitimate messages arrive at the relay with `receivedAt` values below the poisoned cursor. The client's next poll sends the poisoned cursor, and the relay deletes those messages.
237+
238+But wait — the relay assigns `receivedAt` using its own monotonic sequence. If the relay is malicious, it can just not deliver messages. If it's honest but was briefly compromised, the poisoned cursor could cause message loss after recovery. This is a real concern for the "briefly compromised relay" scenario.
239+
240+**Exploitable in practice:** A compromised relay can cause permanent message loss for messages that arrive during the compromise window. The 60-second clamp limits the window, but the cursor persists across reconnections.
241+
242+**Suggested fix:** Only adopt the cursor if the poll response contained events. If `events` is empty and `cursor > model.cursor`, don't advance the cursor — the relay has nothing to tell us, and a cursor advance from an empty page is suspicious.
243+
244+### 🟡 Q17. Delivery ack staleness check uses `ts` not `last_ack_ts` watermark for ordering — race with clock skew
245+
246+**Type:** Implementation concern (app.rs)
247+**Files:** `core/src/app.rs:2116-2134`
248+
249+The ack staleness check compares `ts < c.last_ack_ts`. If two acks arrive in the same poll page from the same sender, with `ts_A = 100` and `ts_B = 200`, and `last_ack_ts = 50`:
250+- Ack A (ts=100): not stale (100 >= 50), applies, sets `last_ack_ts = min(100, now)`
251+- Ack B (ts=200): not stale (200 >= 100), applies, sets `last_ack_ts = min(200, now)`
252+
253+This is correct. But if the page order is reversed (B before A), and `last_ack_ts = 50`:
254+- Ack B (ts=200): not stale, applies, sets `last_ack_ts = min(200, now)`
255+- Ack A (ts=100): stale (100 < min(200, now)), dropped!
256+
257+This means an older ack in the same page as a newer one is dropped if it's processed after the newer one. Since the events are processed in page order (not sorted), a relay could reorder events to cause an older ack to be dropped. The impact is minimal — the older ack would have acked fewer or the same messages.
258+
259+**Exploitable in practice:** A malicious relay could reorder events to suppress a specific ack, but it could also just drop the event entirely. Low impact.
260+
261+**Suggested fix:** Process acks in `ts` order within a page, or collect all acks from a sender and apply the highest-ts one.
262+
263+### 🟡 Q18. `seen_ids` dedup set is built lazily from conversation — misses messages trimmed by `MAX_MESSAGES_PER_PEER`
264+
265+**Type:** Implementation bug (app.rs)
266+**Files:** `core/src/app.rs:2086-2092`
267+
268+The dedup set is built from the current conversation: `convo.iter().map(|m| m.id.clone()).collect()`. But `trim_history` may have aged out older messages (keeping only the most recent 1000). A replayed message whose `id` matches an aged-out message would pass the dedup check and be re-inserted into the conversation. The message would appear as a new message (with its original `ts`), potentially at the top of the conversation list if `ts` is high.
269+
270+**Attack scenario:** An attacker captures a message blob (by being a relay, or by gossip redirect), waits until the recipient's conversation has been trimmed past that message (1000+ messages later), then replays the blob. The dedup check misses it (the `id` is no longer in the trimmed conversation), and the message is re-inserted. The message appears as a new old-dated message.
271+
272+**Exploitable in practice:** Requires the attacker to have captured the blob and to wait for 1000+ messages. The replayed message would be inserted at its original `ts` position, so it wouldn't appear at the bottom of the chat — it would appear in its chronological position. The user might not notice. The impact is minimal — it's an old message reappearing, not new content.
273+
274+**Suggested fix:** Maintain a persistent dedup set (not derived from the trimmed conversation), or store the set of seen IDs separately from the message history.
275+
276+### 🟡 Q19. Profile `ts` staleness check uses `>=` — equal-timestamp replay overwrites with identical content (harmless) but also overwrites with *different* content if sender crafts it
277+
278+**Type:** Implementation concern (app.rs)
279+**Files:** `core/src/app.rs:2174-2179`
280+
281+The profile staleness check uses `ts >= c.last_profile_ts`, meaning a replayed profile at the *same* `ts` is accepted. The comment says "a replayed profile at the same `ts` overwrites with identical content, which is a no-op in practice." But this assumes the replayed blob has the same content. An attacker who captures a profile blob can modify the *inner* content (display_name, bio, photo) and re-encrypt it with the same `ts` — but they'd need to sign it, and they can only sign with their own key. The recipient would see it coming from the same sender (the original profile sender), but the signature would be from the original sender's key... wait, no. The attacker can't re-sign with the original sender's key unless they have it.
282+
283+Actually, the attacker *replays the exact same blob* — same ciphertext, same inner content, same signature. The `ts` is the same, and the content is the same. So it's truly a no-op. The attacker can't modify the content without breaking the signature. This is safe.
284+
285+**Verdict:** Not a bug. The `>=` is correct and the replay is a true no-op.
286+
287+### 🟡 Q20. `flush_next` batch-building skips items for different recipients — outbox may have interleaved items stuck behind a large batch
288+
289+**Type:** Implementation concern (app.rs)
290+**Files:** `core/src/app.rs:1818-1853`
291+
292+The batch stops at the first recipient change (`if recipient.as_ref().is_some_and(|r| r != &item.recipient) { break }`). If the outbox has items for recipient A followed by items for recipient B, and A's batch is being built, B's items wait. This is by design (FIFO fairness). But if A's send fails repeatedly, B's items are stuck behind A's retries. The `retry_batch` function charges retries and eventually drops A's items (after `MAX_OUTBOX_RETRIES` or `OUTBOX_TTL_MS`), unblocking B.
293+
294+This is correct behavior, not a bug. The FIFO ordering ensures fairness at the cost of head-of-line blocking, which is the right tradeoff for a messaging app (you don't want a failed send to one peer to reorder sends to another).
295+
296+**Verdict:** Not a bug. Downgraded to info.
297+
298+### 🟡 Q21. `encrypt_for` uses `OsRng` — no deterministic test path, but also no RNG failure handling
299+
300+**Type:** Implementation concern (app.rs)
301+**Files:** `core/src/app.rs:2227-2231`
302+
303+`encrypt_for` uses `let mut rng = rand_core::OsRng;` and passes it to `crypto::encrypt`. If `OsRng` fails (e.g., the OS entropy pool is exhausted on a freshly booted device), `rng.fill_bytes` would panic or return an error. `crypto::encrypt` calls `rng.fill_bytes` in multiple places (`eph_priv`, `padding`, `nonce`), and these would propagate the error up as `CryptoError::Encrypt` (since `fill_bytes` on `OsRng` infallibly fills — it uses `/dev/urandom` which never blocks on modern systems).
304+
305+**Verdict:** `OsRng` is infallible on iOS/macOS (it uses `SecRandomCopyBytes` which always succeeds). Not a bug.
306+
307+---
308+
309+## 4. Wire Format / Parsing Issues
310+
311+### 🟠 Q22. `isValidHex` uses `chars` (set semantics) — deduplication hides length-based attacks
312+
313+**Type:** Implementation bug (server.knot)
314+**Files:** `server.knot:542-545`
315+
316+```knot
317+isValidHex = \s ->
318+ let n = length s in
319+ n > 0 && n / 2 * 2 == n && all (\c -> contains c "0123456789abcdef") (chars s)
320+```
321+
322+`chars s` returns the **distinct** character set (relations dedupe). So for a string like `"aaaa"`, `chars` returns `{"a"}`, and the `all` check passes. The `length` check (`n / 2 * 2 == n`) ensures even length. But `chars` deduplication means the `all` check is O(distinct chars) not O(string length), which is a performance optimization, not a correctness issue — the set membership test covers all characters.
323+
324+This is actually correct: if all *distinct* characters are in the hex alphabet, then all characters are in the hex alphabet. The dedup just makes it faster.
325+
326+**Verdict:** Not a bug. The set semantics are correct for membership checking.
327+
328+### 🟡 Q23. `PubkeyHex` type accepts non-curve-point 32-byte hex values at the route boundary
329+
330+**Type:** Implementation gap (server.knot)
331+**Files:** `server.knot:322` (`type PubkeyHex = Text where isValidExactHex 64`)
332+
333+The `PubkeyHex` type only checks that the string is 64 lowercase hex characters — it doesn't verify the decoded bytes form a valid Ed25519 public key (a valid curve point). The client's `try_parse_pubkey` (`phonemic.rs:140-144`) does check this via `VerifyingKey::from_bytes`, but the server accepts any 64-hex-char string as a valid `PubkeyHex`.
334+
335+This means a client can send a message to a `to` address that is 32 bytes of hex but not a valid Ed25519 public key. The server stores and forwards the blob. The "recipient" can never decrypt it because `ed25519_pk_to_x25519` would fail on the invalid key. The blob sits in the mailbox until TTL expiry, consuming storage.
336+
337+**Attack scenario:** An attacker sends 100 messages per batch to invalid pubkeys. Each is stored for 30 days. With 60 requests/min × 100 = 6000/min, after 1 hour that's 360,000 junk messages. Each counts against `maxMessagesPerRecipient` for the target — but the target is a non-existent key, so nobody is affected. The real concern is storage consumption: 360,000 × minBlobLen bytes = ~62 MB per hour per attacker IP.
338+
339+**Exploitable in practice:** The rate limit (60/min per IP) bounds the rate. The 30-day TTL bounds the total. `maxMessagesPerRecipient = 10000` per non-existent key limits per-key. The total storage impact is bounded.
340+
341+**Suggested fix:** The server could optionally validate that `to` is a valid curve point, but this adds CPU cost and the spec doesn't require it (the server can't decrypt anyway). Document as accepted.
342+
343+### 🟡 Q24. `parse_payload` `ts` field defaults to 0 when missing — allows pre-epoch ordering manipulation
344+
345+**Type:** Implementation concern (protocol.rs)
346+**Files:** `core/src/protocol.rs:100`
347+
348+`let ts = v.get("ts").and_then(|t| t.as_i64()).unwrap_or(0);`
349+
350+If a peer omits the `ts` field (or sets it to a non-integer), it defaults to 0. In `ingest_poll`, `ts` is clamped: `parsed.ts.max(0).min(now.saturating_add(MAX_FUTURE_SKEW_MS))`. A `ts` of 0 passes the `max(0)` check and is used as-is. A message with `ts = 0` would be inserted at position 0 in the conversation (earliest), and `trim_history` would age it out first. This is harmless.
351+
352+But a peer could set `ts` to a very large negative number (e.g., `i64::MIN`), which would be clamped to 0. Same result. Or a peer could set `ts` to a float (e.g., `1.5`), which `as_i64()` returns `None` for, defaulting to 0. All of these are harmless because the clamping handles them.
353+
354+**Verdict:** Not a bug. The clamping in `ingest_poll` handles all edge cases.
355+
356+### 🟡 Q25. JSON parsing of poll response uses `unwrap_or_default()` — malformed JSON silently produces empty page
357+
358+**Type:** Implementation concern (app.rs)
359+**Files:** `core/src/app.rs:1309`
360+
361+`let parsed: PollResp = serde_json::from_slice(&bytes).unwrap_or_default();`
362+
363+If the relay sends malformed JSON (or a valid JSON with wrong types), this silently defaults to an empty `PollResp` (empty events, cursor 0). The cursor of 0 would be adopted (clamped), and the next poll would re-fetch from the beginning — re-receiving all messages the client has already seen. The client's dedup (by `id` for text, by `ts` for acks and profiles) would handle the duplicates, but it wastes bandwidth and CPU.
364+
365+A hostile relay could exploit this by sending malformed JSON instead of a real response, forcing the client to re-poll from cursor 0 repeatedly. But the client would just re-process old messages (all deduped) and eventually get the real cursor from a subsequent response. The impact is wasted bandwidth, not data loss.
366+
367+**Suggested fix:** If JSON parsing fails, don't adopt the cursor and back off instead of treating it as an empty page.
368+
369+### 🟡 Q26. `delivery.ack` ack_ids filtering silently drops non-string entries — count vs. filtered count mismatch
370+
371+**Type:** Implementation concern (protocol.rs)
372+**Files:** `core/src/protocol.rs:116-126`
373+
374+The ack_ids parsing filters non-string entries: `raw.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()`. The raw count check (`raw.len() > MAX_ACK_IDS`) happens before filtering, but the post-filter check (`ack_ids.len() > MAX_ACK_IDS`) also happens. However, between the two checks, a peer could pack an array with `MAX_ACK_IDS` entries, some of which are non-strings (e.g., numbers). The raw check passes (`MAX_ACK_IDS` entries), the filter drops some, and the post-check passes (fewer than `MAX_ACK_IDS` strings). This is correct — the filtered count is what matters.
375+
376+But there's a subtlety: a peer could pack `MAX_ACK_IDS + 1` raw entries, all non-strings. The raw check fails (`MAX_ACK_IDS + 1 > MAX_ACK_IDS`), and the payload is dropped. But if the peer packs `MAX_ACK_IDS` raw entries, all non-strings, the raw check passes, the filter drops all of them, and `ack_ids` is empty. The empty ack is then sent to the sender, who processes it (marking nothing as delivered). This is harmless.
377+
378+**Verdict:** Not a bug. The double-check handles this correctly.
379+
380+---
381+
382+## 5. Federation Protocol Issues
383+
384+### 🟠 Q27. Gossip amplification — single `online` event triggers forward of *all* queued messages for a key
385+
386+**Type:** Design gap (server.knot)
387+**Files:** `server.knot:1522-1556` (`handleRecvGossip`)
388+
389+When a `online` event arrives for a key, `forwardQueuedToServer` sweeps the entire mailbox for messages to that key and stages them for forwarding. An attacker can send a single gossip request with 100 `online` events for 100 different keys, each triggering a full mailbox scan and forward staging. This is O(100 × |messages|) per request.
390+
391+More importantly, the attacker can repeatedly send `online` events for the same key. Each time, `forwardQueuedToServer` checks `notYetForwarded` and `notYetDelivered` to avoid duplicates, so re-sending the same event is a no-op for already-forwarded messages. But if the forward failed (404 from the attacker's own server, which doesn't actually have the key online), the forward entry stays in `*forwards` with `retries: 0`. A re-announce doesn't re-stage it (it's already in `*forwards`), but `retryPendingForwards` will retry it on the next sweep.
392+
393+The amplification is: one gossip request (100 events) → 100 mailbox scans + potentially 100 forward entries. Each forward entry triggers an HTTP POST to the `fromServer`. If the `fromServer` is the attacker, they receive 100 forwarded blobs — each a copy of the victim's ciphertext. This is the same as the documented gossip-redirect attack (#2), but the amplification factor (100 events per request) is not documented.
394+
395+**Exploitable in practice:** Same as finding #2, but with 100× amplification per request. The rate limit (60/min per IP) bounds the rate to 6,000 redirected keys per minute per IP.
396+
397+**Suggested fix:** Document the amplification factor. Consider limiting the number of newly-online keys per gossip batch that trigger forwards.
398+
399+### 🟡 Q28. Federation `offline` events don't trigger cleanup of pending forwards
400+
401+**Type:** Design gap (server.knot)
402+**Files:** `server.knot:1535-1541`
403+
404+When an `offline` event is received, the presence row for that `(pubkey, fromServer)` is removed, but pending forwards to that server are *not* cancelled. The forwards stay in `*forwards` and will be retried by `retryPendingForwards` until they either succeed (unlikely — the key is offline at that server), fail 10 times, or the message TTL expires.
405+
406+If the offline event was legitimate (the key actually went offline at that server), the forwards will waste retry attempts for up to `retryBackoffMax × maxForwardRetries` = 8 min × 10 = 80 minutes. During that time, the forwards occupy slots in `maxForwardsPerServer` and `maxForwardsTotal`.
407+
408+If the offline event was spoofed by an attacker (to clear a presence row), the forwards are still retried — and if the key is actually still online at that server, the forward succeeds on retry (the `no_presence` gate would pass because the key has a local session). But the presence row was removed, so `hasLocalPresence` at the peer would fail... wait, the presence row is on *our* side. The peer's presence is independent. The forward goes to the peer, and the peer checks its own local presence. If the key is still online at the peer, the forward succeeds. If not, it gets 404 and is retried.
409+
410+**Exploitable in practice:** An attacker can send fake `offline` events to clear presence rows, but the forwards continue independently. The impact is minimal — presence rows and forwards are decoupled.
411+
412+**Suggested fix:** When processing an `offline` event, optionally drop pending forwards to that server for the offline key (they'll never succeed if the key is truly offline there). But this could cause message loss if the offline event is spoofed. Better to leave as-is.
413+
414+### 🟡 Q29. `fromServer` in gossip events is not verified against the connecting peer's actual address
415+
416+**Type:** Design gap (server.knot)
417+**Files:** `server.knot:1511-1556` (`handleRecvGossip`)
418+
419+The `fromServer` field is a `ServerName` (validated for format), but it's not verified against the actual IP address of the connecting peer. An attacker can connect from IP `1.2.3.4` and claim `fromServer: "legit-relay.example.com"`. The server writes presence rows with `server: "legit-relay.example.com"` and forwards queued messages there. This is the same as the documented gossip-redirect attack (#2), but with a twist: the attacker can claim to *be* a legitimate relay, poisoning the presence table with the legitimate relay's hostname.
420+
421+When the *real* `legit-relay.example.com` later gossips, its events update the presence rows. But the attacker's rows may still be present (different `expiresAt`), and the server has multiple presence rows for the same key on different servers — which is by design. The attacker's row points to `legit-relay.example.com`, so forwards go to the legitimate relay, which doesn't have the key online — and returns 404. The legitimate relay gets junk forward requests.
422+
423+**Exploitable in practice:** This is a variant of the documented gossip-redirect, but with the attacker impersonating a legitimate relay. The legitimate relay receives spurious forward requests (bounded by `forwardRateLimit`).
424+
425+**Suggested fix:** Verify that the connecting peer's IP resolves to the claimed `fromServer` hostname (reverse DNS + forward DNS). This is the standard check for SMTP-like protocols. Blocked on `fetch` not exposing DNS.
426+
427+---
428+
429+## 6. Rate Limiting / DoS Vectors
430+
431+### 🟠 Q30. `/auth/challenge` has no per-pubkey rate limit beyond `maxChallengesPerKey = 5`
432+
433+**Type:** Design gap (server.knot)
434+**Files:** `server.knot:1166-1188` (`handleChallenge`)
435+
436+The challenge pool is globally capped at `maxChallenges = 10000` and per-key at `maxChallengesPerKey = 5`, with per-IP rate limiting at `authRateLimit = 60/min`. An attacker with many IPs (or behind a proxy with XFF spoofing when `trustForwardedFor = true`) can fill the challenge pool with challenges for random pubkeys. Each challenge is 60 seconds, and the pool LRU-evicts. But 10,000 challenges × 60 seconds means the pool is always full, and legitimate challenges are evicted.
437+
438+The comment says this is handled by LRU eviction: "an abandoned challenge may be dropped before its 60 s window elapses, which costs the affected client one retry." So the attacker can cause clients to retry their challenge/verify flow, adding latency but not blocking authentication.
439+
440+**Exploitable in practice:** With XFF spoofing (finding 8), the attacker gets unlimited rate-limit buckets. 60/min per IP × many IPs = thousands of challenges per minute. The pool (10,000) fills in ~3 minutes. After that, legitimate challenges are evicted, and clients must retry. This is a soft DoS — authentication still works but with extra latency.
441+
442+**Suggested fix:** Add a global rate on challenge creation (not just per-IP). Or cap the challenge creation rate per second regardless of source.
443+
444+### 🟡 Q31. `/messages` batch of 100 × 40 MiB would be rejected by body cap, but the rate limit counts requests not messages
445+
446+**Type:** Design gap (server.knot)
447+**Files:** `server.knot:233-234` (`sendRateLimit`), `server.knot:454` (`sendLimit`)
448+
449+The send rate limit is 60 requests/min per IP. Each request can carry up to 100 messages (bounded by the body cap). So the effective message rate is 60 × (body_cap / avg_blob_size) per minute. With a 42 MiB body cap and 42 KiB blobs, that's ~1000 messages per request × 60 = 60,000 messages/min. With 173-byte minimum blobs, it's ~251,000 messages/min per IP.
450+
451+This is documented in the code comment ("the effective message ceiling is higher"), but it means the per-IP rate limit is much less protective than it appears. An attacker with 10 IPs can send millions of messages per minute.
452+
453+**Exploitable in practice:** Bounded by the body cap (42 MiB) and the rate limit (60/min). The total data rate per IP is ~42 MiB × 60 = 2.5 GiB/min, which is significant.
454+
455+**Suggested fix:** Consider a per-message rate limit in addition to the per-request rate limit. Or reduce `maxBatchSize` for unauthenticated senders (but all senders are "authenticated" — they just have free keypairs).
456+
457+### 🟡 Q32. `maxSessions = 10000` LRU eviction is Sybil-floodable — documented but not in PROTOCOL.md §10
458+
459+**Type:** Design gap (server.knot)
460+**Files:** `server.knot:117-126`
461+
462+The code comment documents this: "global LRU eviction is Sybil-floodable: an attacker with enough IPs can evict live users' sessions." But it's not mentioned in PROTOCOL.md §10 as a DoS vector. An attacker with enough IPs can:
463+1. Generate 10,000 keypairs
464+2. Authenticate each one (60/min per IP, so ~167 IPs needed for one batch)
465+3. Fill the session pool
466+4. Legitimate users' sessions are evicted (they get 401 and must re-authenticate)
467+
468+This is a soft DoS — users re-authenticate automatically. But it adds latency and load.
469+
470+**Suggested fix:** Document in PROTOCOL.md §10. Consider per-IP session caps (rejected in the code comment as "collapses without XFF, punishes carrier-NAT") or a global session creation rate limit.
471+
472+### 🟡 Q33. `maxPresence = 100000` — gossip can fill presence table with fake keys
473+
474+**Type:** Design gap (server.knot)
475+**Files:** `server.knot:139-155`
476+
477+An attacker can send gossip with 100 fake `online` events per request (60/min per IP). Each event adds a presence row. With `maxPresencePerServer = 5000` per peer, a single attacker IP can add 5000 rows. With `maxPresence = 100000` globally, it takes 20 distinct attacker hostnames to fill the pool. Once full, legitimate presence rows are evicted by `capList` (LRU by `expiresAt`).
478+
479+Since gossip rows have `onlineGossipTtl = 90 min` and local rows have `sessionExpiry = 60 min`, local rows are evicted first (they expire sooner). The code handles this by exempting local rows from the cap:
480+
481+```
482+let localRows = filter (\\p -> isLocalServer p.server) merged
483+let remoteRows = filter (\\p -> not isLocalServer p.server) merged
484+*presence = union localRows (capList (capPerServer remoteRows from maxPresencePerServer) maxPresence)
485+```
486+
487+So local rows are always kept, and only remote (gossiped) rows are capped. An attacker can fill the remote pool, but local users' presence is never evicted. The impact is that legitimate *remote* presence (from honest peers) is evicted, causing forwards to those peers to fail.
488+
489+**Exploitable in practice:** Requires federation to be enabled and many distinct attacker hostnames (each passing `isBadServerName`). Bounded by `maxPresencePerServer = 5000` per hostname.
490+
491+**Suggested fix:** Document in PROTOCOL.md §10. Consider a per-request limit on newly-added presence rows (currently capped at `maxGossipEvents = 100` per request, which is already a bound).
492+
493+---
494+
495+## 7. Authentication / Session Management
496+
497+### 🟠 Q34. Session token is not bound to the pubkey — token theft grants full impersonation
498+
499+**Type:** Design gap (server.knot)
500+**Files:** `server.knot:356` (`type Session = {token: Text, pubkey: PubkeyHex, expiresAt: Timestamp, ip: Text}`)
501+
502+The session token is a bearer token — anyone who has it can use it. It's bound to `(pubkey, ip, expiry)`, but the `pubkey` binding is only used for *authorization* (e.g., rejecting self-sends), not for *authenticating the request*. The `authedPubkey` function looks up the token and returns the associated pubkey — it doesn't verify that the requester is the same entity that originally authenticated.
503+
504+If an attacker steals a bearer token (e.g., from a compromised proxy's access log), they can use it from the same IP (or any IP if `trustForwardedFor = false` or IP binding is disabled) to poll the victim's mailbox and send messages as the victim.
505+
506+The IP binding (`matchIp`) is the only thing preventing token replay from a different IP. But:
507+- If `trustForwardedFor = false`, IP binding is disabled (empty IP → wildcard).
508+- If the attacker is behind the same NAT/proxy as the victim, they share the IP.
509+- The token is sent in `Authorization: Bearer ***` over HTTP (TLS in production), so it's only protected by TLS.
510+
511+**Attack scenario:** A compromised proxy logs the `Authorization` header. The attacker uses the token from the same IP (or any IP if XFF is off) to poll the victim's mailbox and read their (encrypted) messages. The attacker can also send messages as the victim.
512+
513+**Exploitable in practice:** Requires token theft (compromised proxy, log exposure, or MITM without TLS). The E2E encryption means the attacker can't read message content, but they can send messages as the victim (the server doesn't verify the sender's identity on `/messages` — it only uses the bearer token's pubkey for self-send rejection).
514+
515+**Suggested fix:** This is inherent to bearer-token auth. The spec should document that a stolen bearer token grants full impersonation (modulo IP binding) until it expires (1 hour). Consider shorter token lifetimes or per-request signing.
516+
517+### 🟡 Q35. `revokeOthers: true` doesn't revoke challenges — stale challenge can be used to create a new session after revocation
518+
519+**Type:** Implementation gap (server.knot)
520+**Files:** `server.knot:1217-1221`
521+
522+When `revokeOthers: true`, the handler drops all other sessions for the pubkey, but it does *not* drop outstanding challenges for the pubkey. A challenge issued before the `revokeOthers` call is still valid for 60 seconds. An attacker who captured a challenge (e.g., by observing the `/auth/challenge` response) can still complete the `/auth/verify` flow and get a new session — after the `revokeOthers` call.
523+
524+But wait — the `/auth/verify` handler also checks the challenge's IP binding (`matchIp clientIp c.ip`). If the attacker is on a different IP, the verify fails. If they're on the same IP (NAT/proxy), it succeeds. And the new session is *not* revoked by the `revokeOthers` call (it was made after the call).
525+
526+**Attack scenario:**
527+1. Alice requests a challenge (IP 1.2.3.4)
528+2. Attacker captures the challenge response (from a compromised proxy)
529+3. Alice calls `/auth/verify` with `revokeOthers: true` (to log out other devices)
530+4. Attacker uses the captured challenge to call `/auth/verify` from IP 1.2.3.4 (same NAT)
531+5. Attacker gets a new session — not revoked by step 3
532+
533+**Exploitable in practice:** Requires the attacker to be on the same IP as the victim and to have captured the challenge. The 60-second challenge expiry limits the window.
534+
535+**Suggested fix:** When `revokeOthers: true`, also drop outstanding challenges for the pubkey.
536+
537+### 🟡 Q36. Challenge IP binding uses `clientIpOf` which reads XFF — same spoofing surface as session IP binding
538+
539+**Type:** Implementation gap (server.knot)
540+**Files:** `server.knot:1180-1188` (`handleChallenge`), `server.knot:781-784` (`clientIpOf`)
541+
542+The challenge's `ip` is set to `clientIpOf xForwardedFor`, which is the last XFF hop when `trustForwardedFor` is true. If the attacker can spoof XFF (finding 8), they can set any IP on the challenge, and then use the same spoofed IP on the verify — bypassing the IP binding entirely.
543+
544+This is the same XFF spoofing issue already documented (finding 8), but it applies to the challenge IP binding as well. The challenge IP binding is supposed to prevent a challenge obtained by a relay from being used by a different client — but with XFF spoofing, this protection is nullified.
545+
546+**Verdict:** Same as finding 8. Not a new issue, but the challenge IP binding is an additional affected surface.
547+
548+### 🟡 Q37. No rate limit on `/auth/verify` failures — unlimited signature verification attempts
549+
550+**Type:** Design gap (server.knot)
551+**Files:** `server.knot:475` (`authLimit = 60/min`)
552+
553+The `/auth/verify` route shares the `authLimit` (60/min per IP) with `/auth/challenge`. An attacker can attempt 60 signature verifications per minute per IP. Each verification is an Ed25519 signature check, which is relatively expensive (~0.1ms). 60/min = ~0.01 CPU-seconds/min — negligible.
554+
555+But the attacker doesn't need to *succeed* at verification to cause work. Each `/auth/verify` call triggers:
556+1. Hex decode of pubkey (32 bytes) and signature (64 bytes)
557+2. Ed25519 signature verification (expensive: ~0.1ms)
558+3. Linear scan of `*challenges` (up to 10,000 entries)
559+
560+The challenge scan only happens if the signature is valid (short-circuit), so a failed signature still costs the Ed25519 verification. With 60/min per IP and many IPs, the attacker can cause non-trivial CPU load.
561+
562+**Exploitable in practice:** Bounded by rate limits. The Ed25519 verification is fast. Not a practical DoS vector.
563+
564+**Suggested fix:** Consider returning 401 immediately if no matching challenge exists (before verifying the signature), to save the expensive verification when the challenge is invalid. Currently the order is: check challenge exists AND signature valid, with `sigValid` pre-computed. If the challenge doesn't exist, the `any` short-circuits and `sigValid` is never used — but `sigValid` was already computed outside the atomic. So the CPU cost is paid regardless. Move the signature verification inside the atomic block, after the challenge existence check, to short-circuit on missing challenges.
565+
566+Wait, actually looking at the code more carefully:
567+
568+```knot
569+let sigValid = case bytesFromHex pubkey of ... -- computed OUTSIDE atomic
570+result <- atomic (do
571+ ...
572+ let valid = any (\\c -> same c && ...) ch && sigValid -- sigValid used here
573+```
574+
575+`sigValid` is pre-computed, so the Ed25519 verification happens on every `/auth/verify` call regardless of whether a matching challenge exists. This is a CPU waste. If the challenge doesn't exist, the `any` returns false and `sigValid` is irrelevant — but the CPU was already spent.
576+
577+**Suggested fix:** Move the signature verification inside the atomic block, after the challenge existence check. Or compute it lazily (only if the challenge exists).
578+
579+### 🟡 Q38. `handleVerifyAuth` logs session eviction count but not the attacker — no audit trail for Sybil floods
580+
581+**Type:** Implementation gap (server.knot)
582+**Files:** `server.knot:1240`
583+
584+The handler logs `logWarn ("session pool full: evicted N session(s)...")` but doesn't log the pubkey or IP of the request that caused the eviction. An operator investigating a Sybil flood would see warnings but not know which pubkeys or IPs are responsible. This makes it hard to block the attacker.
585+
586+**Suggested fix:** Include the requesting pubkey and IP in the log message.
587+
588+---
589+
590+## 8. Spec Gaps
591+
592+### 🟡 Q39. Spec doesn't specify behavior for empty `encryptedBlob`
593+
594+**Type:** Spec gap (PROTOCOL.md §4)
595+**Files:** PROTOCOL.md §4 (Envelope), `server.knot:319-320` (`isValidBlobHex`)
596+
597+The spec says `encryptedBlob` is "Hex-encoded blob from §3" but doesn't say what happens if it's empty or shorter than `minBlobLen`. The server enforces `isValidBlobHex` (min 346 chars = 173 bytes), but the spec doesn't mention this minimum. An independent implementation might accept shorter blobs and store garbage.
598+
599+**Suggested fix:** Add the minimum blob length to PROTOCOL.md §4 or §5.
600+
601+### 🟡 Q40. Spec doesn't specify JSON encoding requirements (UTF-8, key ordering, whitespace)
602+
603+**Type:** Spec gap (PROTOCOL.md §4, §5)
604+**Files:** PROTOCOL.md §4, §5
605+
606+The spec shows JSON examples but doesn't specify:
607+- Whether keys must be in a specific order (the server uses `GossipEvent` with `eventType` and `pubkey` — order matters for some JSON parsers)
608+- Whether whitespace is allowed in the JSON (a client could send pretty-printed JSON)
609+- Whether UTF-8 is required (JSON spec allows UTF-16/UTF-32, but the server likely only handles UTF-8)
610+- Whether duplicate keys are allowed (JSON allows them, but behavior is undefined)
611+
612+These could cause implementation divergence. For example, a client that sends `{"encryptedBlob": "...", "to": "..."}` (keys reversed) would work with most JSON parsers but might confuse a strict implementation.
613+
614+**Suggested fix:** Specify that all JSON is UTF-8, keys are unordered, whitespace is allowed, and duplicate keys are undefined behavior (last one wins).
615+
616+### 🟡 Q41. Spec doesn't specify what `cursor: 0` means vs absent cursor
617+
618+**Type:** Spec gap (PROTOCOL.md §6)
619+**Files:** PROTOCOL.md §6 (`POST /poll`)
620+
621+The spec says `cursor` is "the maximum `receivedAt` value the client has already seen (`0` on first poll)." But what if the client sends `cursor: -1` or omits the field? The server's `handlePoll` clamps to `max 0 (...)`, so negative cursors become 0. But the spec doesn't say this.
622+
623+**Suggested fix:** Specify that cursor must be ≥ 0, and that 0 means "first poll" (no messages acked).
624+
625+### 🟡 Q42. Spec doesn't specify `to` field validation requirements
626+
627+**Type:** Spec gap (PROTOCOL.md §4, §6)
628+**Files:** PROTOCOL.md §4 (Envelope), PROTOCOL.md §6 (`POST /messages`)
629+
630+The spec says `to` is "Recipient's Ed25519 public key (64 lowercase hex)" but doesn't say:
631+- Must it be a valid Ed25519 public key (a valid curve point)?
632+- Is the server required to validate it?
633+- What happens if it's the sender's own key? (The server rejects this as `self_send`, but the spec doesn't say the server should check this — it's an implementation detail)
634+
635+**Suggested fix:** Specify that `to` must be 64 lowercase hex chars (the server validates format only), and that the server rejects self-sends with `400 self_send`.
636+
637+### 🟡 Q43. Spec doesn't specify the format of `challenge` in auth
638+
639+**Type:** Spec gap (PROTOCOL.md §6)
640+**Files:** PROTOCOL.md §6 (Authentication)
641+
642+The spec says the challenge is "a random challenge string" but doesn't specify:
643+- Its length or entropy
644+- Its format (hex? base64? raw text?)
645+- Whether it must be unique per challenge request
646+
647+The server uses `genToken 48` (48 hex chars, 192 bits). The client signs it as part of the auth message. If an independent implementation uses a shorter challenge (e.g., 8 chars), it would be brute-forceable.
648+
649+**Suggested fix:** Specify minimum challenge length (e.g., 32 hex chars / 128 bits) and format (lowercase hex).
650+
651+### ⚪ Q44. Spec doesn't specify `revokeOthers` semantics precisely
652+
653+**Type:** Spec gap (PROTOCOL.md §6)
654+**Files:** PROTOCOL.md §6 (Authentication step 3)
655+
656+The spec says `revokeOthers: true` "invalidates every other live session for this pubkey" but doesn't specify:
657+- Whether it also invalidates challenges (it doesn't — see Q35)
658+- Whether the revocation is immediate or eventual (it's immediate within the atomic block)
659+- What happens to in-flight requests using a revoked session (they get 401 on the next request)
660+
661+**Suggested fix:** Clarify that only sessions (not challenges) are revoked, revocation is immediate, and revoked sessions get 401 on their next request.
662+
663+---
664+
665+## 9. Privacy / Metadata Leaks
666+
667+### 🟠 Q45. Blob size reveals message type to the server
668+
669+**Type:** Privacy leak (protocol + design)
670+**Files:** PROTOCOL.md §3 (Length Padding), `core/src/protocol.rs:60-91`
671+
672+The padding buckets (256, 512, 1024, ...) obscure the exact compressed size, but the bucket boundaries still leak information about the message type:
673+- A `delivery.ack` with a single ID is tiny (~50 bytes JSON) → compresses to ~40 bytes → inner buffer ~233 bytes → blob ~233 + 16 (AEAD) = 249 → rounds to 256 bucket
674+- A `text` message with a short body is ~100 bytes JSON → compresses to ~80 bytes → inner buffer ~273 bytes → blob ~289 → rounds to 512 bucket
675+- A `profile` with a 64 KiB photo → compresses poorly (base64 is high-entropy) → inner buffer ~65 KiB → rounds to 65536 bucket
676+
677+So the server can distinguish:
678+- A `delivery.ack` (256 bucket) from a `text` (512 bucket) from a `profile` with photo (65536 bucket)
679+- A short text (512) from a long text (1024+)
680+
681+This is a metadata leak beyond what's documented. The spec says "message length is only ever obscured *upward*" and "blob size still distinguishes a one-word reply from a pasted document" — but it doesn't mention that blob size reveals the *message type* (text vs ack vs profile).
682+
683+**Attack scenario:** A relay observes that Alice sends Bob a 256-bucket blob → it's a delivery.ack (Alice received Bob's message). Then Alice sends a 65536-bucket blob → it's a profile update. The relay learns the *pattern* of communication (who acks whom, who updates their profile) without decrypting anything.
684+
685+**Exploitable in practice:** The bucket boundaries are coarse but sufficient to distinguish message types. This is a real metadata leak for a "metadata-privacy-focused messenger."
686+
687+**Suggested fix:** Use a single minimum padding bucket (e.g., always pad to at least 1024 bytes) to make all small messages indistinguishable. Or add random padding within the bucket to add noise. Or pad all messages to the same size (expensive but maximally private).
688+
689+### 🟡 Q46. `receivedAt` timing reveals sender-recipient correlation to the relay
690+
691+**Type:** Privacy leak (server.knot)
692+**Files:** `server.knot:919-925` (`appendMessage`)
693+
694+The server assigns `receivedAt` at message arrival time. If Alice sends a message to Bob via relay R, R records the arrival time. If Bob polls R shortly after, R records the poll time. The correlation between arrival and poll times reveals that Bob was online and received the message — even though the server doesn't know who sent it.
695+
696+This is inherent to the relay model (the server must see the recipient and timing), and is documented in §10 ("Recipient metadata exposure"). But the spec understates the leak: it says "servers see the recipient pubkey and arrival time" — it doesn't mention that the server can correlate arrival and poll times to infer delivery.
697+
698+**Suggested fix:** Document in §10 that the relay can infer delivery by correlating message arrival with recipient poll times.
699+
700+### 🟡 Q47. Poll cursor reveals the recipient's last-seen position to the relay
701+
702+**Type:** Privacy leak (server.knot)
703+**Files:** `server.knot:432-433` (cursor), `server.knot:1265-1275` (cursor clamping)
704+
705+The client sends its cursor (last-seen `receivedAt`) on every poll. This tells the relay exactly how far behind the recipient is — how many messages they haven't read yet. A relay can use this to:
706+- Determine if the recipient is actively reading (cursor advances each poll) or just polling (cursor stays the same)
707+- Estimate the recipient's reading speed (how fast the cursor advances)
708+- Detect when the recipient switches devices (cursor jumps backward)
709+
710+This is inherent to the long-poll model but not documented in §10.
711+
712+**Suggested fix:** Document in §10 that the cursor reveals the recipient's reading progress to the relay.
713+
714+---
715+
716+## 10. TOFU and Key Management
717+
718+### 🟠 Q48. No key change detection — TOFU silently accepts key changes without warning
719+
720+**Type:** Design gap (protocol + app.rs)
721+**Files:** PROTOCOL.md §2 (Trust Model), `core/src/app.rs:2043-2045` (sender check)
722+
723+The spec says "the first time a client encounters a public key, it is accepted and cached" and "Warn users when a contact's public key changes (TOFU violation)." But the implementation doesn't have a mechanism for key changes — because the protocol has no key rotation (§11 is future work).
724+
725+However, there's a subtler issue: the sender's identity is *inside* the AEAD ciphertext. If an attacker sends a message to Bob encrypted to Bob's key, signing with Mallory's key, Bob's client will:
726+1. Decrypt the blob (it's encrypted to Bob's key, so it decrypts successfully)
727+2. Recover the sender's pubkey (Mallory's)
728+3. Check if Mallory is in Bob's contacts
729+4. If not, auto-create a contact for Mallory and display the message
730+
731+This is by design (anyone can send you a message). But if the attacker can somehow get Bob to add Mallory's key as a contact (e.g., by social engineering), and then later send messages that appear to come from Mallory, there's no mechanism to detect that "Mallory" has changed keys — because the key IS the identity.
732+
733+The real TOFU weakness is: if Bob adds Alice's key, and Alice's key is later compromised, an attacker with Alice's key can send messages that appear to come from Alice. There's no way for Bob to detect this without out-of-band verification. The spec acknowledges this ("TOFU only" in §10), but the implementation doesn't provide a UI for out-of-band verification (comparing @p syllables).
734+
735+**Exploitable in practice:** This is the fundamental TOFU weakness, already acknowledged in the spec. The implementation gap is the lack of a verification UI, but that's a product feature, not a protocol bug.
736+
737+**Suggested fix:** Add a UI for fingerprint verification (showing @p syllables for comparison) and a warning when a new contact's key hasn't been verified.
738+
739+### 🟡 Q49. `Identity::from_secret_bytes` validates the keypair but doesn't check for weak keys
740+
741+**Type:** Implementation gap (crypto.rs)
742+**Files:** `core/src/crypto.rs:150-166`
743+
744+`from_secret_bytes` re-derives the public key from the seed and checks it matches. But it doesn't check for weak Ed25519 keys (e.g., keys where the X25519 derived key is a low-order point). The `x25519` function checks for all-zero shared secrets (low-order point detection), but this only fires during encryption/decryption, not at key load time.
745+
746+If the user's Ed25519 key happens to produce an X25519 public key that is a low-order point, every encryption to them would fail with `InvalidEphemeralKey` (because `x25519(eph_priv, recip_x)` would return all-zero). The user would be unable to receive messages but wouldn't know why.
747+
748+The probability of this is negligible (low-order points are extremely rare on Curve25519), but the code doesn't check for it at key generation time.
749+
750+**Suggested fix:** Check `x25519_public` is not a low-order point at key generation/load time. Report an error if it is.
751+
752+### 🟡 Q50. No mechanism to revoke or invalidate a compromised identity key
753+
754+**Type:** Design gap (PROTOCOL.md §11)
755+**Files:** PROTOCOL.md §11 (Future Extensions — Key Rotation)
756+
757+The spec acknowledges this in §11 (future work): "a user who loses or wants to retire a key has no in-protocol way to say so." There's no key revocation, no key rotation, and no mechanism to tell contacts that a key is compromised.
758+
759+If a user's key is stolen, the attacker can:
760+1. Decrypt all captured messages (no forward secrecy)
761+2. Send messages as the user (no revocation)
762+3. Poll the user's mailbox (if they know the server)
763+4. Receive messages intended for the user (if they redirect presence)
764+
765+The only defense is out-of-band communication ("my key was compromised, don't talk to the old key anymore"), which requires every contact to manually re-add the new key.
766+
767+**Suggested fix:** This is acknowledged as future work. Document the impact more prominently in §10: a stolen identity key is a full takeover with no in-protocol recovery.
768+
769+---
770+
771+## Summary of New Findings
772+
773+| # | Severity | Type | Title |
774+|---|----------|------|-------|
775+| Q1 | 🟡 medium | Design gap | HKDF salt omits sender identity — KCI vector |
776+| Q2 | 🟡 medium | Design gap | No AAD on AEAD — envelope fields unauthenticated |
777+| Q5 | 🟡 medium | Spec gap | CRIME/BREACH surface wider than documented for profiles |
778+| Q7 | 🟡 medium | Perf/DoS | appendMessage dedup O(n) — CPU amplification |
779+| Q8 | 🟠 high | Design gap | maxMessagesPerRecipient eviction destroys genuine mail |
780+| Q9 | 🟡 medium | Impl bug | capForwards evicts by retry count only, not destination health |
781+| Q10 | 🟡 medium | Impl concern | genToken entropy depends on Knot runtime CSPRNG |
782+| Q11 | 🟡 medium | Impl concern | Challenge lookup O(n) — timing varies with pool size |
783+| Q16 | 🟠 high | Impl concern | Cursor clamping allows relay to poison cursor → message loss |
784+| Q17 | 🟡 medium | Impl concern | Ack staleness check affected by page ordering |
785+| Q18 | 🟡 medium | Impl bug | seen_ids dedup misses trimmed messages → replay after 1000 msgs |
786+| Q23 | 🟡 medium | Impl gap | PubkeyHex accepts non-curve-point keys at route boundary |
787+| Q25 | 🟡 medium | Impl concern | Malformed poll JSON silently produces empty page |
788+| Q27 | 🟠 high | Design gap | Gossip amplification: 100 online events = 100 mailbox scans + forwards |
789+| Q28 | 🟡 medium | Design gap | Offline events don't cancel pending forwards |
790+| Q29 | 🟡 medium | Design gap | fromServer not verified against connecting peer's address |
791+| Q30 | 🟠 high | Design gap | /auth/challenge pool floodable — soft DoS via XFF spoofing |
792+| Q31 | 🟡 medium | Design gap | Rate limit counts requests not messages — effective rate much higher |
793+| Q32 | 🟡 medium | Design gap | maxSessions Sybil-floodable (documented in code, not in spec) |
794+| Q33 | 🟡 medium | Design gap | maxPresence fillable with fake keys (documented in code, not in spec) |
795+| Q34 | 🟠 high | Design gap | Session token not bound to pubkey — theft = full impersonation |
796+| Q35 | 🟡 medium | Impl gap | revokeOthers doesn't revoke challenges |
797+| Q37 | 🟡 medium | Design gap | Signature verification CPU wasted on missing challenges |
798+| Q38 | 🟡 medium | Impl gap | No audit trail (pubkey/IP) for Sybil eviction |
799+| Q39 | 🟡 medium | Spec gap | Spec doesn't specify min blob length |
800+| Q40 | 🟡 medium | Spec gap | Spec doesn't specify JSON encoding requirements |
801+| Q41 | 🟡 medium | Spec gap | Spec doesn't specify cursor validation |
802+| Q42 | 🟡 medium | Spec gap | Spec doesn't specify `to` field validation |
803+| Q43 | 🟡 medium | Spec gap | Spec doesn't specify challenge format/entropy |
804+| Q44 | ⚪ info | Spec gap | Spec doesn't specify revokeOthers semantics precisely |
805+| Q45 | 🟠 high | Privacy leak | Blob size reveals message type (ack vs text vs profile) to relay |
806+| Q46 | 🟡 medium | Privacy leak | receivedAt timing reveals delivery correlation |
807+| Q47 | 🟡 medium | Privacy leak | Poll cursor reveals reading progress to relay |
808+| Q48 | 🟠 high | Design gap | No key change detection — TOFU has no verification UI |
809+| Q49 | 🟡 medium | Impl gap | No weak key check at identity load time |
810+| Q50 | 🟡 medium | Design gap | No identity key revocation mechanism (acknowledged future work) |
811+
812+**High-severity findings (7):** Q8, Q16, Q27, Q30, Q34, Q45, Q48
813+**Medium-severity findings (24):** Q1, Q2, Q5, Q7, Q9, Q10, Q11, Q17, Q18, Q23, Q25, Q28, Q29, Q31, Q32, Q33, Q35, Q37, Q38, Q39, Q40, Q41, Q42, Q43, Q46, Q47, Q49, Q50
814+**Info (1):** Q44
815+
816+Note: Several findings marked as "not a bug" during analysis were downgraded to info or omitted from the final table. The table includes only findings that represent genuine new issues.
Cargo.lock+1
@@ -1797,6 +1797,7 @@ name = "skrepka_core"
1797version = "0.1.0"
1797version = "0.1.0"
1798dependencies = [
1798dependencies = [
1799 "anyhow",
1799 "anyhow",
1800+ "base64",
1800 "boltffi",
1801 "boltffi",
1801 "chacha20poly1305",
1802 "chacha20poly1305",
1802 "clap",
1803 "clap",
Cargo.toml+1
@@ -43,6 +43,7 @@ rand_core = { version = "0.6", features = ["getrandom"] }
43rand_chacha = "0.3"
43rand_chacha = "0.3"
44uuid = { version = "1", features = ["v4"] }
44uuid = { version = "1", features = ["v4"] }
45hex = "0.4"
45hex = "0.4"
46+base64 = "0.22"
46zeroize = { version = "1", features = ["derive"] }
47zeroize = { version = "1", features = ["derive"] }
47url = "2"
48url = "2"
48
49
PROTOCOL.md+66−29
@@ -1,6 +1,6 @@
1# Skrepka Protocol Specification
1# Skrepka Protocol Specification
2
2
3−**Version:** 0.1 (Draft)
3+**Version:** 0.2 (Draft)
4
4
5---
5---
6
6
@@ -110,7 +110,7 @@ The wire-visible "envelope" carries only the recipient and the opaque blob. The
110 key = hkdf_sha256(
110 key = hkdf_sha256(
111 ikm = raw_secret,
111 ikm = raw_secret,
112 salt = ephemeral_public || recipient_x25519_public,
112 salt = ephemeral_public || recipient_x25519_public,
113− info = "skrepka-v1",
113+ info = "skrepka-v2",
114 len = 32
114 len = 32
115 )
115 )
116 ```
116 ```
@@ -118,35 +118,45 @@ The wire-visible "envelope" carries only the recipient and the opaque blob. The
118 ```
118 ```
119 compressed = zstd(plaintext_bytes) // standard zstd frame (embeds content size)
119 compressed = zstd(plaintext_bytes) // standard zstd frame (embeds content size)
120 ```
120 ```
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:
122 ```
122 ```
123 compressed_len = uint32_be(len(compressed))
123 compressed_len = uint32_be(len(compressed))
124 padding = random_bytes(target_blob_size - unpadded_blob_size)
124 padding = random_bytes(target_blob_size - unpadded_blob_size)
125 signature = ed25519_sign(sender_ed25519_private,
125 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)
127 ```
127 ```
1286. Build the inner buffer that will be encrypted:
1286. Build the inner buffer that will be encrypted:
129 ```
129 ```
130 inner = sender_ed25519_public || signature || compressed_len || compressed || padding
130 inner = sender_ed25519_public || signature || compressed_len || compressed || padding
131 ```
131 ```
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:
133 ```
134 ```
134 nonce = random_bytes(24)
135 nonce = random_bytes(24)
136+ aad = version_byte || ephemeral_public || nonce || recipient_ed25519_public
135− ciphertext = xchacha20_poly1305_encrypt(key, nonce, inner) // no associated data
137+ ciphertext = xchacha20_poly1305_encrypt(key, nonce, inner, aad)
136 ```
138 ```
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.
1378. Emit the on-wire blob:
1478. Emit the on-wire blob:
138 ```
148 ```
139 blob = version_byte || ephemeral_public || nonce || ciphertext
149 blob = version_byte || ephemeral_public || nonce || ciphertext
140 ```
150 ```
141− where `version_byte` is `0x01` (see *Cryptographic Versioning* below).
151+ where `version_byte` is `0x02` (see *Cryptographic Versioning* below).
142
152
143**Decryption flow:**
153**Decryption flow:**
144
154
1451. 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`.
1551. 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`.
1462. 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`.
1562. 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.
1484. 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).
1584. 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.
1506. zstd-decompress the `compressed` bytes (only the `compressed_len`-length slice, not the trailing padding) to recover the plaintext payload.
1606. zstd-decompress the `compressed` bytes (only the `compressed_len`-length slice, not the trailing padding) to recover the plaintext payload.
151
161
152### Encrypted Blob Format
162### Encrypted Blob Format
@@ -154,7 +164,7 @@ The wire-visible "envelope" carries only the recipient and the opaque blob. The
154On the wire (cleartext, server-visible):
164On the wire (cleartext, server-visible):
155
165
156```
166```
157−version (1 byte, currently 0x01)
167+version (1 byte, currently 0x02)
158ephemeral_x25519_pubkey (32 bytes)
168ephemeral_x25519_pubkey (32 bytes)
159nonce (24 bytes)
169nonce (24 bytes)
160ciphertext (variable, includes 16-byte AEAD tag)
170ciphertext (variable, includes 16-byte AEAD tag)
@@ -170,7 +180,7 @@ compressed_plaintext (variable, zstd; exactly compressed_len bytes)
170padding (variable, random)
180padding (variable, random)
171```
181```
172
182
173−The sender's pubkey and signature are inside the AEAD ciphertext: the server never sees them, and only the intended recipient can recover the sender's identity. The `compressed_len` field tells the recipient exactly where the zstd frame ends, so trailing padding bytes are never fed to the decompressor. The minimum blob size is `1 + 32 + 24 + 32 + 64 + 4 + 16 = 173` bytes. A relay rejects any blob shorter than that as malformed (`400`), since it cannot possibly carry an envelope.
183+The sender's pubkey and signature are inside the AEAD ciphertext: the server never sees them, and only the intended recipient can recover the sender's identity. The `compressed_len` field tells the recipient exactly where the zstd frame ends, so trailing padding bytes are never fed to the decompressor. The minimum blob size is `1 + 32 + 24 + 32 + 64 + 4 + 16 = 173` bytes (346 hex chars). A relay rejects any blob shorter than this with `400 invalid_message`, since it cannot possibly carry a valid envelope.
174
184
175### Length Padding
185### Length Padding
176
186
@@ -184,15 +194,15 @@ The bucket boundaries are:
184
194
185A 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.
195A 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.
186
196
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.
188>
198>
189> 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.
199> 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.
190>
200>
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.
192
202
193### Cryptographic Versioning
203### Cryptographic Versioning
194
204
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`).
196
206
197---
207---
198
208
@@ -211,10 +221,10 @@ The envelope is what servers see. It carries only the recipient and the opaque e
211
221
212| Field | Type | Description |
222| Field | Type | Description |
213|-----------------|--------|--------------------------------------------------|
223|-----------------|--------|--------------------------------------------------|
214−| `to` | string | Recipient's Ed25519 public key (64 lowercase hex)|
224+| `to` | string | Recipient's Ed25519 public key — exactly 64 lowercase hex chars. The server validates the **format** only (length and charset); it does not verify the decoded bytes form a valid Ed25519 curve point. |
215−| `encryptedBlob` | string | Hex-encoded blob from §3 |
225+| `encryptedBlob` | string | Hex-encoded blob from §3. Must be at least 346 hex chars (173 bytes); the server rejects shorter blobs with `400 invalid_message`. |
216
226
217−Servers route based on `to`. They cannot inspect `encryptedBlob` and they do not know the sender.
227+Servers route based on `to`. They cannot inspect `encryptedBlob` and they do not know the sender. The `to` field is format-validated only — the server does not check that it represents a valid Ed25519 curve point, so blobs addressed to non-existent keys are stored and forwarded normally (the intended recipient simply cannot decrypt them). A message whose `to` matches the authenticated sender's own pubkey is rejected with `400 self_send` (§6, *POST /messages*).
218
228
219### Plaintext Payload
229### Plaintext Payload
220
230
@@ -328,7 +338,12 @@ This file is a static suggestion, not a federation directory. Federation between
328
338
329### Content Type
339### Content Type
330
340
331−All request and response bodies use `application/json`.
341+All request and response bodies use `application/json`. The following encoding rules apply to every JSON body (request and response):
342+
343+- **Encoding:** UTF-8. Implementations MUST NOT use UTF-16 or UTF-32.
344+- **Key ordering:** Object keys are unordered. A sender may emit them in any order; a receiver MUST NOT depend on key ordering for correctness.
345+- **Whitespace:** Inter-token whitespace (spaces, tabs, newlines) is allowed and does not affect semantics. Pretty-printed JSON is valid.
346+- **Duplicate keys:** If an object contains the same key more than once, behavior is undefined. Implementations SHOULD avoid producing duplicates and MAY reject or retain only the first value for a repeated key.
332
347
333### Federation Transport
348### Federation Transport
334
349
@@ -361,8 +376,9 @@ When a client connects, it proves ownership of its keypair:
361 ```json
376 ```json
362 { "challenge": "<random_hex>", "expiresAt": 1679003660000 }
377 { "challenge": "<random_hex>", "expiresAt": 1679003660000 }
363 ```
378 ```
379+ 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).
364
380
365−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):
366 ```
382 ```
367 POST /auth/verify
383 POST /auth/verify
368 {
384 {
@@ -373,7 +389,7 @@ When a client connects, it proves ownership of its keypair:
373 }
389 }
374 ```
390 ```
375 The server verifies against its own configured hostname (`serverHost`, port stripped). Binding the signature to the target host prevents a malicious or relay server from forwarding a challenge it obtained from a *different* server and replaying the client's signature there to impersonate the client (§10, *Unauthenticated session relay*).
391 The server verifies against its own configured hostname (`serverHost`, port stripped). Binding the signature to the target host prevents a malicious or relay server from forwarding a challenge it obtained from a *different* server and replaying the client's signature there to impersonate the client (§10, *Unauthenticated session relay*).
376− Setting `revokeOthers: true` invalidates every other live session for this pubkey (useful for "log out other devices").
392+ Setting `revokeOthers: true` invalidates every other live session for this pubkey (useful for "log out other devices"). Revocation is **immediate** (within the atomic block that processes the verify) and a revoked session receives `401 unauthorized` on its next request. Note that `revokeOthers` revokes **sessions only**, not outstanding challenges — a challenge issued before the `revokeOthers` call remains valid for its 60-second window. (The reference implementation has been fixed to also drop outstanding challenges for the pubkey on `revokeOthers: true`; independent implementations SHOULD do the same.)
377
393
3784. Server verifies the signature. On success, returns a session token valid for **1 hour**:
3944. Server verifies the signature. On success, returns a session token valid for **1 hour**:
379 ```json
395 ```json
@@ -429,7 +445,7 @@ The client makes a blocking POST. The server holds the connection open until new
429{ "cursor": 0 }
445{ "cursor": 0 }
430```
446```
431
447
432−`cursor` is the maximum `receivedAt` value the client has already seen (`0` on first poll).
448+`cursor` is the maximum `receivedAt` value the client has already seen. It MUST be a non-negative integer. `0` means "first poll" — no messages have been acked, so the server returns all pending events. Negative cursors are clamped to `0` by the server.
433
449
434**Response:**
450**Response:**
435```json
451```json
@@ -442,6 +458,7 @@ The client makes a blocking POST. The server holds the connection open until new
442```
458```
443
459
444- `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.
460- `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.
445- 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**.
462- 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**.
446- 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.
463- 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.
447- An empty page (`events: []`) is returned on timeout, with the cursor echoed back.
464- An empty page (`events: []`) is returned on timeout, with the cursor echoed back.
@@ -663,16 +680,34 @@ Skrepka protects message **content and sender identity** from servers and networ
663
680
664| Risk | Details |
681| Risk | Details |
665|-----------------------------------|-----------------------------------------------------|
682|-----------------------------------|-----------------------------------------------------|
666−| **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. |
683+| **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. |
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. |
667−| **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. |
685+| **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. |
686+| **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. |
687+| **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. |
688+| **CRIME/BREACH surface on profiles** | The CRIME/BREACH side channel (§3, *Length Padding*) extends beyond text messages to `profile` broadcasts: `display_name`, `bio`, and `photo` are compressed together in one zstd frame. If an attacker can influence part of the profile text (e.g., the user sets their display name based on something the attacker controls), the compressed size could leak information about the user's other profile fields. The risk is low in practice — the base64-encoded photo is high-entropy and nearly incompressible, and bucket padding coarsens the granularity — but the spec's claim that "an attacker cannot make the client re-encrypt a chosen variant of a secret on demand" is too strong for profile broadcasts. |
668| **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. |
689| **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. |
669| **No durable delivery** | If the sender's server goes down or all forward retries fail, messages may be lost. |
690| **No durable delivery** | If the sender's server goes down or all forward retries fail, messages may be lost. |
691+| **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. |
693+| **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). |
695+| **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. |
696+| **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. |
697+| **Session pool Sybil-floodable** | The global session pool (`maxSessions`, default 10 000) uses LRU eviction. An attacker with enough IPs can generate thousands of keypairs, authenticate each one, and fill the pool — evicting legitimate users' sessions. Users re-authenticate automatically (they get `401` and retry), so this is a soft DoS adding latency and load. Per-IP session caps were considered and rejected because they collapse behind carrier-grade NAT. |
698+| **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. |
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. |
701+| **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. |
670−| **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 — the signature commits to the relay's own hostname. 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); it does not defend against an attacker who fully controls name resolution and the server's certificate. |
702+| **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). |
671−| **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`, `delivery.ack` is idempotent, and `profile` — which carries no `id` — is guarded by the per-contact `ts` staleness check of §4, which the reference client enforces, so a replayed stale `profile` is dropped rather than rolling a contact's cached profile back. A client that omits that check is open to profile rollback. |
703+| **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. |
672−| **Open federation — gossip redirect** | `/federation/gossip` is unauthenticated, and the harm is *active*, not merely passive observation: 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. |
673−| **Open federation — forward injection** | `/federation/forward` is unauthenticated, so any host can inject arbitrary blobs into a currently-online recipient's mailbox. See *Open federation* below. |
705+| **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. |
706+| **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. |
707+| **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). |
708+| **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. |
674−| **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, and a client that does not recognize the version byte rejects the blob outright — but there is no negotiation, so an old client cannot discover that a new one exists. |
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. |
675−| **TOFU only** | A first-time public key is trusted on encounter; users must compare fingerprints out-of-band to detect MITM at first contact. |
710+| **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. |
676
711
677### Open Federation (accepted limitation of v0.1)
712### Open Federation (accepted limitation of v0.1)
678
713
@@ -689,6 +724,8 @@ to every relay it can reach. Each relay holding mail for the victim now forwards
689
724
690**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).
725**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).
691
726
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+
692**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.
729**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.
693
730
694### Recommendations for Implementers
731### Recommendations for Implementers
apple/SkrepkaApp/Core.swift+13−15
@@ -26,16 +26,17 @@ final class Core: ObservableObject {
26 private let store = KvStore()
26 private let store = KvStore()
27
27
28 private init() {
28 private init() {
29− // swiftlint:disable:next force_try
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.
30− self.view = try! .bincodeDeserialize(input: [UInt8](core.view()))
31+ self.view = (try? ViewModel.bincodeDeserialize(input: [UInt8](core.view()))) ?? ViewModel()
31 bootIdentity()
32 bootIdentity()
32 }
33 }
33
34
34 // MARK: - Event/effect loop
35 // MARK: - Event/effect loop
35
36
36 func update(_ event: Event) {
37 func update(_ event: Event) {
37− // swiftlint:disable:next force_try
38+ guard let eventData = try? event.bincodeSerialize() else { return }
38− let effects = [UInt8](core.update(data: Data(try! event.bincodeSerialize())))
39+ let effects = [UInt8](core.update(data: Data(eventData)))
39 dispatch(effects)
40 dispatch(effects)
40 }
41 }
41
42
@@ -45,8 +46,7 @@ final class Core: ObservableObject {
45 }
46 }
46
47
47 private func dispatch(_ effects: [UInt8]) {
48 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 }
50 for request in requests {
50 for request in requests {
51 process(request)
51 process(request)
52 }
52 }
@@ -56,29 +56,27 @@ final class Core: ObservableObject {
56 let id = request.id
56 let id = request.id
57 switch request.effect {
57 switch request.effect {
58 case .render:
58 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
61
60
62 case .http(let req):
61 case .http(let req):
63 Task { [weak self] in
62 Task { [weak self] in
64 let result = await Http.perform(req)
63 let result = await Http.perform(req)
65− // swiftlint:disable:next force_try
64+ guard let output = try? result.bincodeSerialize() else { return }
66− self?.resolve(id, try! result.bincodeSerialize())
65+ self?.resolve(id, output)
67 }
66 }
68
67
69 case .keyValue(let op):
68 case .keyValue(let op):
70 // The file I/O runs on the store's own serial queue; hop the result back to
69 // The file I/O runs on the store's own serial queue; hop the result back to
71 // the main actor, because the core is single-threaded and `.render` publishes.
70 // the main actor, because the core is single-threaded and `.render` publishes.
72 store.handle(op) { [weak self] result in
71 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 }
75 Task { @MainActor in self?.resolve(id, output) }
73 Task { @MainActor in self?.resolve(id, output) }
76 }
74 }
77
75
78 case .time(let req):
76 case .time(let req):
79 Time.handle(req) { [weak self] response in
77 Time.handle(req) { [weak self] response in
80− // swiftlint:disable:next force_try
78+ guard let output = try? response.bincodeSerialize() else { return }
81− self?.resolve(id, try! response.bincodeSerialize())
79+ self?.resolve(id, output)
82 }
80 }
83 }
81 }
84 }
82 }
@@ -97,4 +95,4 @@ final class Core: ObservableObject {
97 identityError = error.message
95 identityError = error.message
98 }
96 }
99 }
97 }
100−}
98+}
apple/SkrepkaApp/Effects.swift+80−14
@@ -5,19 +5,44 @@ import Skrepka
5
5
6// MARK: - HTTP
6// MARK: - HTTP
7
7
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.
9///
16///
10−/// URLSession replays the original request's headers onto a redirect target, including
17+/// **Redirect cancellation.** URLSession replays the original request's headers onto a
11−/// `Authorization: Bearer <session_token>`. A relay is untrusted infrastructure: a hostile
18+/// redirect target, including `Authorization: Bearer <session_token>`. A hostile (or
12−/// (or compromised) one could answer `/poll` or `/messages` with a 302 to a host it chooses
19+/// compromised) relay could answer `/poll` or `/messages` with a 302 to a host it chooses
13/// and harvest the bearer token from the request we would dutifully re-send there. Sessions
20/// and harvest the bearer token from the request we would dutifully re-send there. Sessions
14/// are bound to a single relay by design (§6), so a cross-host redirect is never legitimate
21/// 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.
16///
27///
17−/// Returning `nil` from the completion handler does not error the task: it stops the
28+/// **Response size limiting.** As body chunks arrive we accumulate the byte count per task
18−/// redirect and hands the 3xx response itself back to the caller, which the core sees as a
29+/// and cancel the task the moment it crosses `maxResponseBytes`, so a multi-gigabyte body
19−/// non-2xx status and treats as a failed request.
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.
20−private final class NoRedirectDelegate: NSObject, URLSessionTaskDelegate {
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+
21 func urlSession(
46 func urlSession(
22 _ session: URLSession,
47 _ session: URLSession,
23 task: URLSessionTask,
48 task: URLSessionTask,
@@ -25,20 +50,55 @@ private final class NoRedirectDelegate: NSObject, URLSessionTaskDelegate {
25 newRequest request: URLRequest,
50 newRequest request: URLRequest,
26 completionHandler: @escaping (URLRequest?) -> Void
51 completionHandler: @escaping (URLRequest?) -> Void
27 ) {
52 ) {
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 {
28− completionHandler(nil)
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()
29 }
89 }
30}
90}
31
91
32enum Http {
92enum Http {
33 /// Strongly held by `session` for the app's lifetime — the session is never invalidated,
93 /// Strongly held by `session` for the app's lifetime — the session is never invalidated,
34 /// so the delegate is never released. That is the intended shape here, not a leak.
94 /// 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)
36
96
37 /// A private, ephemeral session. `URLSession.shared` would hand every relay a
97 /// A private, ephemeral session. `URLSession.shared` would hand every relay a
38 /// persistent cookie jar and an on-disk response cache — a relay is untrusted
98 /// persistent cookie jar and an on-disk response cache — a relay is untrusted
39 /// infrastructure and must not be able to pin a stable identifier on the client
99 /// infrastructure and must not be able to pin a stable identifier on the client
40 /// or leave polled ciphertext lying around on disk. It would also follow redirects,
100 /// 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.
42 private static let session: URLSession = {
102 private static let session: URLSession = {
43 let config = URLSessionConfiguration.ephemeral
103 let config = URLSessionConfiguration.ephemeral
44 config.httpCookieAcceptPolicy = .never
104 config.httpCookieAcceptPolicy = .never
@@ -48,11 +108,13 @@ enum Http {
48 config.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
108 config.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
49 config.timeoutIntervalForRequest = 70 // server long-polls for ~25s
109 config.timeoutIntervalForRequest = 70 // server long-polls for ~25s
50 config.tlsMinimumSupportedProtocolVersion = .TLSv12
110 config.tlsMinimumSupportedProtocolVersion = .TLSv12
51− return URLSession(configuration: config, delegate: redirectDelegate, delegateQueue: nil)
111+ return URLSession(configuration: config, delegate: httpDelegate, delegateQueue: nil)
52 }()
112 }()
53
113
54 static func perform(_ req: HttpRequest) async -> HttpResult {
114 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 {
56 return .err(.url(req.url))
118 return .err(.url(req.url))
57 }
119 }
58 var request = URLRequest(url: url)
120 var request = URLRequest(url: url)
@@ -72,6 +134,10 @@ enum Http {
72 headers: headers(of: http),
134 headers: headers(of: http),
73 body: [UInt8](data)
135 body: [UInt8](data)
74 ))
136 ))
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"))
75 } catch let error as URLError where error.code == .timedOut {
141 } catch let error as URLError where error.code == .timedOut {
76 return .err(.timeout)
142 return .err(.timeout)
77 } catch {
143 } catch {
apple/SkrepkaApp/PhotoPicker.swift+23−1
@@ -62,7 +62,29 @@ struct PhotoPicker: UIViewControllerRepresentable {
62 guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
62 guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
63 let thumb = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary)
63 let thumb = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary)
64 else { return nil }
64 else { return nil }
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 {
65− return UIImage(cgImage: thumb).jpegData(compressionQuality: 0.7)?.base64EncodedString()
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
66 }
88 }
67 }
89 }
68}
90}
apple/SkrepkaApp/QRView.swift+36−2
@@ -21,6 +21,9 @@ func qrImage(_ string: String) -> UIImage? {
21/// A QR code carries whatever its author put there. Strip a leading scheme so a share
21/// A QR code carries whatever its author put there. Strip a leading scheme so a share
22/// link (`skrepka:<key>`, `https://…/<key>`) yields the bare key the core will accept,
22/// link (`skrepka:<key>`, `https://…/<key>`) yields the bare key the core will accept,
23/// and so the pubkey field never silently fills with an unrelated URL.
23/// 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.
24func scannedKeyPayload(_ raw: String) -> String {
27func scannedKeyPayload(_ raw: String) -> String {
25 var value = raw.trimmingCharacters(in: .whitespacesAndNewlines)
28 var value = raw.trimmingCharacters(in: .whitespacesAndNewlines)
26 let schemes = ["skrepka://", "skrepka:", "https://", "http://"]
29 let schemes = ["skrepka://", "skrepka:", "https://", "http://"]
@@ -75,6 +78,24 @@ final class ScannerVC: UIViewController {
75 override func viewDidLoad() {
78 override func viewDidLoad() {
76 super.viewDidLoad()
79 super.viewDidLoad()
77 view.backgroundColor = .black
80 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() {
78 guard let device = AVCaptureDevice.default(for: .video),
99 guard let device = AVCaptureDevice.default(for: .video),
79 let input = try? AVCaptureDeviceInput(device: device),
100 let input = try? AVCaptureDeviceInput(device: device),
80 session.canAddInput(input) else { return }
101 session.canAddInput(input) else { return }
@@ -93,6 +114,19 @@ final class ScannerVC: UIViewController {
93 previewLayer = preview
114 previewLayer = preview
94 }
115 }
95
116
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+
96 override func viewDidLayoutSubviews() {
130 override func viewDidLayoutSubviews() {
97 super.viewDidLayoutSubviews()
131 super.viewDidLayoutSubviews()
98 previewLayer?.frame = view.bounds
132 previewLayer?.frame = view.bounds
@@ -100,7 +134,7 @@ final class ScannerVC: UIViewController {
100
134
101 override func viewWillAppear(_ animated: Bool) {
135 override func viewWillAppear(_ animated: Bool) {
102 super.viewWillAppear(animated)
136 super.viewWillAppear(animated)
103− if !session.isRunning {
137+ if !session.isRunning && !session.inputs.isEmpty {
104 DispatchQueue.global(qos: .userInitiated).async { self.session.startRunning() }
138 DispatchQueue.global(qos: .userInitiated).async { self.session.startRunning() }
105 }
139 }
106 }
140 }
@@ -112,4 +146,4 @@ final class ScannerVC: UIViewController {
112 DispatchQueue.global(qos: .userInitiated).async { self.session.stopRunning() }
146 DispatchQueue.global(qos: .userInitiated).async { self.session.stopRunning() }
113 }
147 }
114 }
148 }
115−}
149+}
apple/SkrepkaApp/SkrepkaApp.swift+4−2
@@ -151,13 +151,15 @@ enum BackgroundRefresh {
151 /// Waits for a condition on the view, or for the deadline (or expiry) to overtake it.
151 /// Waits for a condition on the view, or for the deadline (or expiry) to overtake it.
152 ///
152 ///
153 /// The core has nothing to await — it publishes a `ViewModel` and that is the whole of
153 /// 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
155 /// this cares about is cheaper than threading a Combine subscription through an actor hop.
155 /// 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.
156 private static func settle(by deadline: Date, until condition: () -> Bool) async -> Bool {
158 private static func settle(by deadline: Date, until condition: () -> Bool) async -> Bool {
157 while !condition() {
159 while !condition() {
158 guard !Task.isCancelled, Date() < deadline else { return false }
160 guard !Task.isCancelled, Date() < deadline else { return false }
159 // Cancellation makes this throw at once; the guard above is what breaks the loop.
161 // 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))
161 }
163 }
162 return true
164 return true
163 }
165 }
core/Cargo.toml+1
@@ -45,6 +45,7 @@ zstd.workspace = true
45rand_core.workspace = true
45rand_core.workspace = true
46uuid.workspace = true
46uuid.workspace = true
47hex.workspace = true
47hex.workspace = true
48+base64.workspace = true
48zeroize.workspace = true
49zeroize.workspace = true
49url.workspace = true
50url.workspace = true
50
51
core/src/app.rs+512−141
@@ -9,6 +9,9 @@ use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
9use std::sync::Arc;
9use std::sync::Arc;
10use std::time::{Duration, SystemTime, UNIX_EPOCH};
10use std::time::{Duration, SystemTime, UNIX_EPOCH};
11
11
12+use base64::prelude::*;
13+use base64::Engine;
14+
12use crux_core::{
15use crux_core::{
13 macros::effect,
16 macros::effect,
14 render::{render, RenderOperation},
17 render::{render, RenderOperation},
@@ -58,6 +61,24 @@ const MAX_CONTACTS: usize = 500;
58/// the budget is dropped exactly like an undecryptable blob would be.
61/// the budget is dropped exactly like an undecryptable blob would be.
59const MAX_POLL_TOTAL_BYTES: usize = 64 * 1024 * 1024;
62const MAX_POLL_TOTAL_BYTES: usize = 64 * 1024 * 1024;
60
63
64+/// Cap on the number of events we will attempt to decrypt out of a single poll
65+/// page, independent of the byte budget. Minimum-size blobs (173 bytes decoded)
66+/// would otherwise permit ~388K events within the 64 MiB byte budget, each
67+/// costing a full scalar mult + SHA-512 + AEAD pass — enough to freeze the app
68+/// for tens of seconds per poll. An honest relay caps at 50 events
69+/// (`maxPollEvents`); this is a hostile-relay bound. Set above `MAX_ACK_IDS`
70+/// (1024) so a legitimate large page from one sender still fully processes.
71+const MAX_POLL_EVENTS: usize = 2000;
72+
73+/// Upper bound on a poll response body we will buffer and parse, in bytes.
74+///
75+/// Before the per-blob byte/count budgets in `ingest_poll` ever run, the client
76+/// reads the entire response body into a `Vec<u8>` and deserializes it. A
77+/// multi-gigabyte response from a hostile relay would OOM the app before
78+/// `ingest_poll`'s defenses are consulted. This cap (generously above what 50
79+/// max-size blobs + JSON envelope could cost) bounds the allocation.
80+const MAX_POLL_RESPONSE_BYTES: usize = 64 * 1024 * 1024;
81+
61/// How long a poll may stay in flight before we assume the shell lost it.
82/// How long a poll may stay in flight before we assume the shell lost it.
62///
83///
63/// `polling` gates the whole long-poll loop, and it is only cleared by a
84/// `polling` gates the whole long-poll loop, and it is only cleared by a
@@ -87,6 +108,12 @@ const POLL_WATCHDOG_MS: u64 = 90_000;
87/// one costs a redundant request.
108/// one costs a redundant request.
88const FLUSH_WATCHDOG_MS: u64 = 90_000;
109const FLUSH_WATCHDOG_MS: u64 = 90_000;
89
110
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+
90/// Messages per `/messages` request.
117/// Messages per `/messages` request.
91///
118///
92/// The relay rejects a longer batch outright (`413 batch_too_large`,
119/// The relay rejects a longer batch outright (`413 batch_too_large`,
@@ -120,10 +147,18 @@ const K_PROFILE: &str = "profile";
120const K_CONTACTS: &str = "contacts";
147const K_CONTACTS: &str = "contacts";
121const K_CURSOR: &str = "cursor";
148const K_CURSOR: &str = "cursor";
122const K_OUTBOX: &str = "outbox";
149const K_OUTBOX: &str = "outbox";
150+const K_SEEN_IDS: &str = "seen_ids";
123fn k_messages(peer: &str) -> String {
151fn k_messages(peer: &str) -> String {
124 format!("messages:{peer}")
152 format!("messages:{peer}")
125}
153}
126
154
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+
127// ---------------------------------------------------------------------------
162// ---------------------------------------------------------------------------
128// Effect
163// Effect
129// ---------------------------------------------------------------------------
164// ---------------------------------------------------------------------------
@@ -191,6 +226,9 @@ pub enum Event {
191 LoadedOutbox(#[facet(opaque)] KvData),
226 LoadedOutbox(#[facet(opaque)] KvData),
192 #[serde(skip)]
227 #[serde(skip)]
193 #[facet(skip)]
228 #[facet(skip)]
229+ LoadedSeenIds(#[facet(opaque)] KvData),
230+ #[serde(skip)]
231+ #[facet(skip)]
194 LoadedMessages(String, #[facet(opaque)] KvData),
232 LoadedMessages(String, #[facet(opaque)] KvData),
195 #[serde(skip)]
233 #[serde(skip)]
196 #[facet(skip)]
234 #[facet(skip)]
@@ -224,11 +262,17 @@ pub enum Event {
224 #[serde(skip)]
262 #[serde(skip)]
225 #[facet(skip)]
263 #[facet(skip)]
226 StartFlush,
264 StartFlush,
227− /// Fires `FLUSH_WATCHDOG_MS` after a flush is issued; a no-op unless the
265+ /// Fires `FLUSH_WATCHDOG_MS` after generation `n`'s flush is issued; a no-op
228− /// send is somehow still in flight — same pattern as `PollWatchdog`.
266+ /// unless that exact send is somehow still in flight — same pattern as
267+ /// `PollWatchdog`.
268+ #[serde(skip)]
269+ #[facet(skip)]
270+ 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.
229 #[serde(skip)]
273 #[serde(skip)]
230 #[facet(skip)]
274 #[facet(skip)]
231− FlushWatchdog,
275+ AuthWatchdog(u64),
232 #[serde(skip)]
276 #[serde(skip)]
233 #[facet(skip)]
277 #[facet(skip)]
234 ChallengeResult(u64, #[facet(opaque)] HttpResult),
278 ChallengeResult(u64, #[facet(opaque)] HttpResult),
@@ -240,7 +284,7 @@ pub enum Event {
240 PollResult(u64, #[facet(opaque)] HttpResult),
284 PollResult(u64, #[facet(opaque)] HttpResult),
241 #[serde(skip)]
285 #[serde(skip)]
242 #[facet(skip)]
286 #[facet(skip)]
243− SendResult(#[facet(opaque)] HttpResult),
287+ SendResult(u64, #[facet(opaque)] HttpResult),
244}
288}
245
289
246// ---------------------------------------------------------------------------
290// ---------------------------------------------------------------------------
@@ -266,9 +310,9 @@ pub enum Page {
266 EditProfile,
310 EditProfile,
267}
311}
268
312
269−/// The five kv keys loaded at startup (`settings`, `profile`, `contacts`,
313+/// The six kv keys loaded at startup (`settings`, `profile`, `contacts`,
270−/// `cursor`, `outbox`). `Connect` waits for all of them.
314+/// `cursor`, `outbox`, `seen_ids`). `Connect` waits for all of them.
271−const STARTUP_LOADS: u8 = 5;
315+const STARTUP_LOADS: u8 = 6;
272
316
273/// The batch a live `/messages` request is carrying: the first `count` items of
317/// The batch a live `/messages` request is carrying: the first `count` items of
274/// the outbox, all bound for `recipient`.
318/// the outbox, all bound for `recipient`.
@@ -294,6 +338,12 @@ pub struct Model {
294 profile: OwnProfile,
338 profile: OwnProfile,
295 contacts: BTreeMap<String, Contact>,
339 contacts: BTreeMap<String, Contact>,
296 messages: BTreeMap<String, Vec<StoredMessage>>,
340 messages: BTreeMap<String, Vec<StoredMessage>>,
341+ /// Q18: Per-peer dedup ID set, independent of the trimmed conversation.
342+ /// Without this, a replayed blob whose `id` was aged out by `trim_history`
343+ /// (past the 1000-message cap) passes the dedup check and re-enters the
344+ /// conversation. This set is loaded from and persisted to kv alongside
345+ /// messages, so it survives restarts.
346+ seen_ids: BTreeMap<String, HashSet<String>>,
297 outbox: VecDeque<OutboxItem>,
347 outbox: VecDeque<OutboxItem>,
298 cursor: i64,
348 cursor: i64,
299 token: Option<String>,
349 token: Option<String>,
@@ -336,6 +386,12 @@ pub struct Model {
336 /// items it sent off the head of the outbox, so two overlapping sends would
386 /// items it sent off the head of the outbox, so two overlapping sends would
337 /// pop each other's messages.
387 /// pop each other's messages.
338 flushing: bool,
388 flushing: bool,
389+ /// Which flush the in-flight `SendResult` / `FlushWatchdog` is allowed to
390+ /// belong to. Same pattern as `poll_gen`: bumped every time a flush is
391+ /// issued and every time one is abandoned, so a `SendResult` or watchdog
392+ /// from a superseded send cannot clear the guard or pop items belonging to
393+ /// the replacement.
394+ flush_gen: u64,
339 /// What the in-flight send will pop off the head of the outbox when it
395 /// What the in-flight send will pop off the head of the outbox when it
340 /// succeeds — see `InFlight`. `None` while nothing is in flight, and also
396 /// succeeds — see `InFlight`. `None` while nothing is in flight, and also
341 /// while a send is in flight whose items have since been removed from under
397 /// while a send is in flight whose items have since been removed from under
@@ -400,6 +456,7 @@ impl Default for Model {
400 profile: OwnProfile::default(),
456 profile: OwnProfile::default(),
401 contacts: BTreeMap::new(),
457 contacts: BTreeMap::new(),
402 messages: BTreeMap::new(),
458 messages: BTreeMap::new(),
459+ seen_ids: BTreeMap::new(),
403 outbox: VecDeque::new(),
460 outbox: VecDeque::new(),
404 cursor: 0,
461 cursor: 0,
405 token: None,
462 token: None,
@@ -411,6 +468,7 @@ impl Default for Model {
411 authenticating: false,
468 authenticating: false,
412 auth_gen: 0,
469 auth_gen: 0,
413 flushing: false,
470 flushing: false,
471+ flush_gen: 0,
414 in_flight: None,
472 in_flight: None,
415 send_batch_budget: None,
473 send_batch_budget: None,
416 loads_pending: 0,
474 loads_pending: 0,
@@ -462,6 +520,24 @@ impl Model {
462 self.auth_gen
520 self.auth_gen
463 }
521 }
464
522
523+ /// Open a new flush generation and return it. Same pattern as
524+ /// `next_poll_gen`: a `SendResult` or `FlushWatchdog` whose generation no
525+ /// longer matches is dropped, so a late result from a superseded send
526+ /// cannot clear the guard or pop items belonging to the replacement.
527+ fn next_flush_gen(&mut self) -> u64 {
528+ self.flush_gen = self.flush_gen.wrapping_add(1);
529+ self.flush_gen
530+ }
531+
532+ /// Abandon the in-flight flush, if any: release the guard and retire the
533+ /// generation so a late `SendResult` cannot clear the *replacement* flush's
534+ /// guard or pop its items.
535+ fn abandon_flush(&mut self) {
536+ self.flushing = false;
537+ self.in_flight = None;
538+ self.flush_gen = self.flush_gen.wrapping_add(1);
539+ }
540+
465 /// Abandon the in-flight auth round-trip (either leg).
541 /// Abandon the in-flight auth round-trip (either leg).
466 fn abandon_auth(&mut self) {
542 fn abandon_auth(&mut self) {
467 self.authenticating = false;
543 self.authenticating = false;
@@ -508,7 +584,7 @@ fn now_ms() -> i64 {
508 SystemTime::now()
584 SystemTime::now()
509 .duration_since(UNIX_EPOCH)
585 .duration_since(UNIX_EPOCH)
510 .map(|d| d.as_millis() as i64)
586 .map(|d| d.as_millis() as i64)
511− .unwrap_or(0)
587+ .unwrap_or(1)
512}
588}
513
589
514/// Bare lowercased hostname of a server URL (no scheme/port/trailing dot).
590/// Bare lowercased hostname of a server URL (no scheme/port/trailing dot).
@@ -596,6 +672,16 @@ struct PollEvent {
596 #[serde(rename = "encryptedBlob")]
672 #[serde(rename = "encryptedBlob")]
597 encrypted_blob: String,
673 encrypted_blob: String,
598}
674}
675+
676+/// Q17: Deferred delivery-ack accumulator. Collects ack_ids and the max `ts`
677+/// across all `delivery.ack` payloads from one sender in a single poll page,
678+/// so the staleness check and watermark update run once per sender after the
679+/// event loop — immune to relay page ordering.
680+#[derive(Default)]
681+struct PendingAck {
682+ ts: i64,
683+ ids: HashSet<String>,
684+}
599#[derive(Serialize)]
685#[derive(Serialize)]
600struct SendBatch {
686struct SendBatch {
601 messages: Vec<Envelope>,
687 messages: Vec<Envelope>,
@@ -639,6 +725,7 @@ impl App for Skrepka {
639 KeyValue::get(K_CONTACTS).then_send(Event::LoadedContacts),
725 KeyValue::get(K_CONTACTS).then_send(Event::LoadedContacts),
640 KeyValue::get(K_CURSOR).then_send(Event::LoadedCursor),
726 KeyValue::get(K_CURSOR).then_send(Event::LoadedCursor),
641 KeyValue::get(K_OUTBOX).then_send(Event::LoadedOutbox),
727 KeyValue::get(K_OUTBOX).then_send(Event::LoadedOutbox),
728+ KeyValue::get(K_SEEN_IDS).then_send(Event::LoadedSeenIds),
642 ])
729 ])
643 .and(render())
730 .and(render())
644 }
731 }
@@ -716,10 +803,51 @@ impl App for Skrepka {
716 }
803 }
717 self.startup_done(model).and(render())
804 self.startup_done(model).and(render())
718 }
805 }
806+ Event::LoadedSeenIds(res) => {
807+ match parse_kv::<BTreeMap<String, HashSet<String>>>(res) {
808+ Ok(Some(map)) => model.seen_ids = map,
809+ Ok(None) => {}
810+ Err(()) => {
811+ // Non-fatal: dedup falls back to the conversation-derived
812+ // set, which is weaker but functional.
813+ model.seen_ids = BTreeMap::new();
814+ }
815+ }
816+ self.startup_done(model).and(render())
817+ }
719 Event::LoadedMessages(peer, res) => {
818 Event::LoadedMessages(peer, res) => {
720 match parse_kv::<Vec<StoredMessage>>(res) {
819 match parse_kv::<Vec<StoredMessage>>(res) {
721 Ok(Some(list)) => {
820 Ok(Some(list)) => {
821+ // D5: Add on-disk message IDs to seen_ids so future
822+ // replays don't pass dedup. Without this, a seen_ids
823+ // kv load failure leaves the set empty, and on-disk
824+ // messages aren't protected against replay.
825+ if let Some(id_set) = model.seen_ids.get_mut(&peer) {
826+ id_set.extend(list.iter().map(|m| m.id.clone()));
827+ }
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 => {
722− model.messages.insert(peer, list);
848+ model.messages.insert(peer, list);
849+ }
850+ }
723 }
851 }
724 Ok(None) => {}
852 Ok(None) => {}
725 Err(()) => {
853 Err(()) => {
@@ -829,11 +957,20 @@ impl App for Skrepka {
829 // would then ack a sequence number B never issued, and B would
957 // would then ack a sequence number B never issued, and B would
830 // drop every message waiting for us.
958 // drop every message waiting for us.
831 model.abandon_poll();
959 model.abandon_poll();
960+ // ...and retire the send that is still in flight *to relay A*,
961+ // for the same reason: its `SendResult` carries A's acceptance,
962+ // and popping items here would eat whatever the new relay's
963+ // send is about to carry. The generation check in `SendResult`
964+ // is what makes the late result a no-op.
965+ model.abandon_flush();
832 // The old relay's rate limit was the old relay's opinion. A new one is
966 // The old relay's rate limit was the old relay's opinion. A new one is
833 // entitled to take our sends immediately, and carrying the pause over
967 // entitled to take our sends immediately, and carrying the pause over
834 // would park the outbox against a limit that no longer applies.
968 // would park the outbox against a limit that no longer applies.
835 model.flush_paused_until = 0;
969 model.flush_paused_until = 0;
836 model.send_retries = 0;
970 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;
837 let writes = if self.refuse_write(model) {
974 let writes = if self.refuse_write(model) {
838 Command::done()
975 Command::done()
839 } else {
976 } else {
@@ -940,6 +1077,7 @@ impl App for Skrepka {
940 Event::DeleteContact { peer } => {
1077 Event::DeleteContact { peer } => {
941 model.contacts.remove(&peer);
1078 model.contacts.remove(&peer);
942 model.messages.remove(&peer);
1079 model.messages.remove(&peer);
1080+ model.seen_ids.remove(&peer);
943 model.outbox.retain(|item| item.recipient != peer);
1081 model.outbox.retain(|item| item.recipient != peer);
944 // The send in flight may be carrying the very items we just
1082 // The send in flight may be carrying the very items we just
945 // dropped; if so its result must pop nothing.
1083 // dropped; if so its result must pop nothing.
@@ -956,6 +1094,8 @@ impl App for Skrepka {
956 // message the user just asked us to forget. Delete the key.
1094 // message the user just asked us to forget. Delete the key.
957 KeyValue::delete(k_messages(&peer)).then_send(Event::Saved),
1095 KeyValue::delete(k_messages(&peer)).then_send(Event::Saved),
958 self.persist_outbox(model),
1096 self.persist_outbox(model),
1097+ // Q18: persist the seen_ids map without the removed peer.
1098+ self.persist_seen_ids(model),
959 ])
1099 ])
960 .and(render())
1100 .and(render())
961 }
1101 }
@@ -983,6 +1123,13 @@ impl App for Skrepka {
983 model.error = Some("photo too large".into());
1123 model.error = Some("photo too large".into());
984 return render();
1124 return render();
985 }
1125 }
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+ }
986 model.profile = OwnProfile {
1133 model.profile = OwnProfile {
987 display_name,
1134 display_name,
988 bio,
1135 bio,
@@ -991,8 +1138,8 @@ impl App for Skrepka {
991 model.page = Page::Conversations;
1138 model.page = Page::Conversations;
992 let ts = now_ms();
1139 let ts = now_ms();
993 let payload = Payload::Profile {
1140 let payload = Payload::Profile {
994− display_name: model.profile.display_name.clone(),
1141+ display_name: Some(model.profile.display_name.clone()),
995− bio: model.profile.bio.clone(),
1142+ bio: Some(model.profile.bio.clone()),
996 photo: model.profile.photo.clone(),
1143 photo: model.profile.photo.clone(),
997 };
1144 };
998 // Broadcast the new profile to every contact we haven't blocked.
1145 // Broadcast the new profile to every contact we haven't blocked.
@@ -1071,6 +1218,11 @@ impl App for Skrepka {
1071 let gen = model.next_auth_gen();
1218 let gen = model.next_auth_gen();
1072 req.build()
1219 req.build()
1073 .then_send(move |r| Event::ChallengeResult(gen, r))
1220 .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+ )
1074 .and(render())
1226 .and(render())
1075 }
1227 }
1076 Err(_) => {
1228 Err(_) => {
@@ -1249,35 +1401,90 @@ impl App for Skrepka {
1249 return render();
1401 return render();
1250 }
1402 }
1251 let bytes = resp.take_body().unwrap_or_default();
1403 let bytes = resp.take_body().unwrap_or_default();
1404+ // Bound the response before parsing: a hostile relay can return
1405+ // a multi-gigabyte body that OOMs the app before `ingest_poll`'s
1406+ // per-blob budgets are even consulted.
1407+ if bytes.len() > MAX_POLL_RESPONSE_BYTES {
1408+ model.error = Some("relay sent an oversized poll response".into());
1409+ return self.backoff_poll(model);
1410+ }
1411+ // Q25: A malformed or unparseable poll response must not be
1412+ // treated as an empty page — `unwrap_or_default()` would adopt
1413+ // cursor 0, forcing a re-fetch from the beginning and wasting
1414+ // bandwidth on already-seen (deduped) messages. Back off instead.
1252− let parsed: PollResp = serde_json::from_slice(&bytes).unwrap_or_default();
1415+ let parsed: PollResp = match serde_json::from_slice(&bytes) {
1416+ Ok(p) => p,
1417+ Err(_) => {
1418+ model.error = Some("relay sent a malformed poll response".into());
1419+ return self.backoff_poll(model);
1420+ }
1421+ };
1422+ let cursor_before = model.cursor;
1253 let cmd = self.ingest_poll(model, parsed);
1423 let cmd = self.ingest_poll(model, parsed);
1254 // The re-poll hangs off the *cursor write*, not off this event.
1424 // The re-poll hangs off the *cursor write*, not off this event.
1255 // Polling again is what acks the batch we just took — the server
1425 // Polling again is what acks the batch we just took — the server
1256 // deletes everything up to `cursor` — so it must not happen until
1426 // deletes everything up to `cursor` — so it must not happen until
1257 // the batch is durable on our side. `SavedCursor` re-polls.
1427 // the batch is durable on our side. `SavedCursor` re-polls.
1428+ //
1429+ // Arm a watchdog alongside the cursor write: if the shell ever
1430+ // drops the KV effect (suspension, bug), `SavedCursor` never fires,
1431+ // `polling` stays `false`, and the loop is dead until the next
1432+ // foreground `Connect`. The watchdog fires `Poll` if the loop is
1433+ // still idle — same pattern as the poll/send HTTP watchdogs.
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;
1258− cmd.and(
1439+ cmd.and(if cursor_advanced {
1259 KeyValue::set(K_CURSOR, json_bytes(&model.cursor))
1440 KeyValue::set(K_CURSOR, json_bytes(&model.cursor))
1260− .then_send(Event::SavedCursor),
1441+ .then_send(Event::SavedCursor)
1442+ } else {
1443+ Command::event(Event::Poll)
1444+ })
1445+ .and(
1446+ Time::notify_after(Duration::from_millis(POLL_WATCHDOG_MS))
1447+ .0
1448+ .then_send(|_| Event::Poll),
1261 )
1449 )
1262 .and(render())
1450 .and(render())
1263 }
1451 }
1264
1452
1265 // ---------------- outbox ----------------
1453 // ---------------- outbox ----------------
1266 Event::StartFlush => self.flush_next(model),
1454 Event::StartFlush => self.flush_next(model),
1267− Event::FlushWatchdog => {
1455+ Event::FlushWatchdog(gen) => {
1268− // The send request never came back — the shell dropped the HTTP
1456+ // Only the flush this watchdog was armed for, and only while it
1269− // effect (backgrounded app, bug), and `flushing` has been stuck
1457+ // is still the current one and still in flight. A watchdog whose
1270− // ever since. Release the guard and retry the flush. The late
1458+ // send already completed (or was superseded) is a stale timer and
1271− // `SendResult`, if it ever arrives, finds `in_flight` already
1459+ // must not touch a healthy successor — same pattern as
1272− // cleared and pops nothing.
1460+ // `PollWatchdog`.
1273− if !model.flushing {
1461+ if gen != model.flush_gen || !model.flushing {
1274 return Command::done();
1462 return Command::done();
1275 }
1463 }
1276− model.flushing = false;
1464+ // Retire it as we give up on it. The request itself is not
1277− model.in_flight = None;
1465+ // cancelled, so if it eventually resolves the generation check
1466+ // in `SendResult` is what stops it from popping the replacement's
1467+ // items.
1468+ model.abandon_flush();
1278 self.flush_next(model)
1469 self.flush_next(model)
1279 }
1470 }
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+ }
1280− Event::SendResult(Ok(resp)) => {
1480+ Event::SendResult(gen, Ok(resp)) => {
1481+ // A result for a send we already abandoned (a watchdog gave up
1482+ // on it, or a server change invalidated it). Dropping it is safe
1483+ // and required: its items were re-queued, so popping by count
1484+ // here would eat whichever messages slid into their place.
1485+ if gen != model.flush_gen {
1486+ return Command::done();
1487+ }
1281 // The in-flight send is over; `flush_next` refuses to run while
1488 // The in-flight send is over; `flush_next` refuses to run while
1282 // this is set, so it must be cleared before *any* branch below.
1489 // this is set, so it must be cleared before *any* branch below.
1283 model.flushing = false;
1490 model.flushing = false;
@@ -1367,16 +1574,24 @@ impl App for Skrepka {
1367 // later, under load that has nothing to do with this one — starts
1574 // later, under load that has nothing to do with this one — starts
1368 // at whatever height the last one climbed to.
1575 // at whatever height the last one climbed to.
1369 model.send_retries = 0;
1576 model.send_retries = 0;
1577+ // D3-app: Don't pop if kv is broken — the persist would be
1578+ // refused, and the items would be lost from memory but still
1579+ // on disk, causing duplicate sends on relaunch.
1580+ if !model.kv_load_failed {
1370− for _ in 0..batch.map_or(0, |b| b.count) {
1581+ for _ in 0..batch.map_or(0, |b| b.count) {
1371− model.outbox.pop_front();
1582+ model.outbox.pop_front();
1583+ }
1372 }
1584 }
1373 return self.persist_outbox(model).and(self.flush_next(model));
1585 return self.persist_outbox(model).and(self.flush_next(model));
1374 }
1586 }
1375 // A transient failure: a 5xx, a rate limit, a relay restarting.
1587 // A transient failure: a 5xx, a rate limit, a relay restarting.
1376 self.retry_batch(model, batch)
1588 self.retry_batch(model, batch)
1377 }
1589 }
1378− Event::SendResult(Err(_)) => {
1590+ Event::SendResult(gen, Err(_)) => {
1379 // A transport error is the same transient failure as a 5xx.
1591 // A transport error is the same transient failure as a 5xx.
1592+ if gen != model.flush_gen {
1593+ return Command::done();
1594+ }
1380 model.flushing = false;
1595 model.flushing = false;
1381 let batch = model.in_flight.take();
1596 let batch = model.in_flight.take();
1382 self.retry_batch(model, batch)
1597 self.retry_batch(model, batch)
@@ -1551,6 +1766,16 @@ impl Skrepka {
1551 KeyValue::set(k_messages(peer), json_bytes(&list)).then_send(Event::Saved)
1766 KeyValue::set(k_messages(peer), json_bytes(&list)).then_send(Event::Saved)
1552 }
1767 }
1553
1768
1769+ /// Q18: Persist the per-peer dedup ID set. Written alongside messages so
1770+ /// the set survives restarts and covers IDs that `trim_history` has aged
1771+ /// out of the conversation.
1772+ fn persist_seen_ids(&self, model: &mut Model) -> Command<Effect, Event> {
1773+ if self.refuse_write(model) {
1774+ return Command::done();
1775+ }
1776+ KeyValue::set(K_SEEN_IDS, json_bytes(&model.seen_ids)).then_send(Event::Saved)
1777+ }
1778+
1554 fn schedule_reconnect(&self, model: &mut Model) -> Command<Effect, Event> {
1779 fn schedule_reconnect(&self, model: &mut Model) -> Command<Effect, Event> {
1555 let delay = backoff_ms(model.poll_retries);
1780 let delay = backoff_ms(model.poll_retries);
1556 model.poll_retries = model.poll_retries.saturating_add(1);
1781 model.poll_retries = model.poll_retries.saturating_add(1);
@@ -1649,14 +1874,21 @@ impl Skrepka {
1649 .insert(peer.clone(), Contact::new(peer.clone(), String::new(), ts));
1874 .insert(peer.clone(), Contact::new(peer.clone(), String::new(), ts));
1650 }
1875 }
1651
1876
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+
1652− // On first contact, share our profile *before* the text so the
1883+ // On first contact, share our profile *after* the text. The text
1653− // recipient's first impression is a name and avatar, not a truncated
1884+ // auto-creates the contact on the recipient's side; the profile that
1654− // @p that gets replaced a moment later. Both are queued behind any
1885+ // follows in the same batch then applies to the now-existing contact.
1655− // in-flight items, so the ordering is preserved through the outbox.
1886+ // (Previously profile was queued before the text, but the recipient's
1887+ // ingest drops profiles from non-contacts, so the profile was lost.)
1656 if was_new {
1888 if was_new {
1657 let profile = Payload::Profile {
1889 let profile = Payload::Profile {
1658− display_name: model.profile.display_name.clone(),
1890+ display_name: Some(model.profile.display_name.clone()),
1659− bio: model.profile.bio.clone(),
1891+ bio: Some(model.profile.bio.clone()),
1660 photo: model.profile.photo.clone(),
1892 photo: model.profile.photo.clone(),
1661 };
1893 };
1662 model.outbox.push_back(OutboxItem::profile(
1894 model.outbox.push_back(OutboxItem::profile(
@@ -1665,12 +1897,6 @@ impl Skrepka {
1665 ));
1897 ));
1666 }
1898 }
1667
1899
1668− let text = Payload::Text { id, body };
1669− model.outbox.push_back(OutboxItem::new(
1670− peer.clone(),
1671− Arc::new(protocol::serialize_payload(&text, ts)),
1672− ));
1673−
1674 Command::all([
1900 Command::all([
1675 self.persist_messages(model, &peer),
1901 self.persist_messages(model, &peer),
1676 self.persist_contacts(model),
1902 self.persist_contacts(model),
@@ -1798,6 +2024,7 @@ impl Skrepka {
1798 };
2024 };
1799
2025
1800 model.flushing = true;
2026 model.flushing = true;
2027+ let gen = model.next_flush_gen();
1801 model.in_flight = Some(InFlight {
2028 model.in_flight = Some(InFlight {
1802 recipient,
2029 recipient,
1803 count: messages.len(),
2030 count: messages.len(),
@@ -1808,11 +2035,11 @@ impl Skrepka {
1808 .header("authorization", format!("Bearer {token}"))
2035 .header("authorization", format!("Bearer {token}"))
1809 .body_json(&SendBatch { messages })
2036 .body_json(&SendBatch { messages })
1810 {
2037 {
1811− Ok(req) => persist.and(req.build().then_send(Event::SendResult))
2038+ Ok(req) => persist.and(req.build().then_send(move |r| Event::SendResult(gen, r)))
1812 .and(
2039 .and(
1813 Time::notify_after(Duration::from_millis(FLUSH_WATCHDOG_MS))
2040 Time::notify_after(Duration::from_millis(FLUSH_WATCHDOG_MS))
1814 .0
2041 .0
1815− .then_send(|_| Event::FlushWatchdog),
2042+ .then_send(move |_| Event::FlushWatchdog(gen)),
1816 ),
2043 ),
1817 Err(_) => {
2044 Err(_) => {
1818 // Not a dead loop: the next StartFlush (from a poll page or a
2045 // Not a dead loop: the next StartFlush (from a poll page or a
@@ -1883,8 +2110,20 @@ impl Skrepka {
1883 return Command::done();
2110 return Command::done();
1884 }
2111 }
1885 let prev_cursor = model.cursor;
2112 let prev_cursor = model.cursor;
2113+ // Clamp the relay-supplied cursor: honest cursors are wall-clock-seeded
2114+ // sequence numbers, so anything far in the future is a hostile or buggy
2115+ // relay. Adopting `i64::MAX` would ack (delete) all queued mail on the
2116+ // next poll and persist that poison across relaunches.
2117+ let clamped_cursor = page.cursor.min(now_ms().saturating_add(60_000));
2118+ // Q16: Only advance the cursor when the page actually carried events.
2119+ // An empty page with a cursor higher than ours is suspicious — a
2120+ // compromised relay could poison the cursor to ack (delete) messages
2121+ // the client hasn't seen. When there are no events, there is nothing
2122+ // to acknowledge, so the cursor should not move. (An honest relay's
2123+ // empty-page cursor equals what the client sent, so this is a no-op
2124+ // in the normal case.)
1886− if page.cursor > model.cursor {
2125+ if !page.events.is_empty() && clamped_cursor > model.cursor {
1887− model.cursor = page.cursor;
2126+ model.cursor = clamped_cursor;
1888 }
2127 }
1889 let Some(id) = model.identity() else {
2128 let Some(id) = model.identity() else {
1890 return Command::done();
2129 return Command::done();
@@ -1893,12 +2132,24 @@ impl Skrepka {
1893 let now = now_ms();
2132 let now = now_ms();
1894 let mut touched_convos: HashSet<String> = HashSet::new();
2133 let mut touched_convos: HashSet<String> = HashSet::new();
1895 let mut contacts_dirty = false;
2134 let mut contacts_dirty = false;
2135+ let mut outbox_dirty = false;
1896 // sender hex -> message ids needing acks
2136 // sender hex -> message ids needing acks
1897 let mut ack_targets: BTreeMap<String, Vec<String>> = BTreeMap::new();
2137 let mut ack_targets: BTreeMap<String, Vec<String>> = BTreeMap::new();
1898− // Lazily-built per-sender sets of known message IDs for O(1) text dedup.
2138+ // Q17: Per-sender deferred ack accumulation. Collected during the event
2139+ // loop, applied after it — so relay page ordering cannot cause an older
2140+ // ack to be dropped as stale by a newer one processed first.
1899− let mut seen_ids: HashMap<String, HashSet<String>> = HashMap::new();
2141+ let mut pending_acks: HashMap<String, PendingAck> = HashMap::new();
1900
2142
1901 let mut budget = MAX_POLL_TOTAL_BYTES;
2143 let mut budget = MAX_POLL_TOTAL_BYTES;
2144+ 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;
1902 for ev in page.events {
2153 for ev in page.events {
1903 // Reject an oversized blob on its hex length, before it costs us a
2154 // Reject an oversized blob on its hex length, before it costs us a
1904 // decode. No conforming sender produces one (the relay caps blobs at
2155 // decode. No conforming sender produces one (the relay caps blobs at
@@ -1906,19 +2157,37 @@ impl Skrepka {
1906 if ev.encrypted_blob.len() > MAX_BLOB_LEN * 2 {
2157 if ev.encrypted_blob.len() > MAX_BLOB_LEN * 2 {
1907 continue;
2158 continue;
1908 }
2159 }
2160+ // Event count cap: the byte budget alone permits ~388K minimum-size
2161+ // events, each costing a full decrypt attempt. A hostile relay can
2162+ // freeze the app for tens of seconds per poll with a page of small
2163+ // junk blobs. Stop processing past this count.
2164+ if events_processed >= MAX_POLL_EVENTS {
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+ }
2173+ model.error = Some("relay sent too many poll events".into());
2174+ break;
2175+ }
2176+ events_processed += 1;
1909 match budget.checked_sub(ev.encrypted_blob.len() / 2) {
2177 match budget.checked_sub(ev.encrypted_blob.len() / 2) {
1910 Some(left) => budget = left,
2178 Some(left) => budget = left,
1911 None => {
2179 None => {
1912− // A page this large is a misbehaving relay, not real mail.
2180+ // N9: Budget exhausted. If we stored at least one event,
1913− // Stop here; the rest of the page is discarded exactly as an
2181+ // keep the advanced cursor to ack the page — re-fetching
1914− // undecryptable blob would be.
2182+ // an oversized page forever is a livelock. The unprocessed
1915− //
2183+ // events are beyond our capacity and would be re-served
1916− // Restore the cursor: it was advanced to `page.cursor` above,
2184+ // on every poll, wedging the client. Acking them breaks
1917− // but we have not processed the remaining events. If we keep
2185+ // the loop. If zero events were stored (completely hostile
1918− // the advanced cursor, the next poll acks the whole page to
2186+ // page), restore the cursor to avoid acking mail we
1919− // the relay — including every event we skipped — and the
2187+ // never saw.
1920− // relay deletes them. Those messages are permanently lost.
2188+ if events_stored == 0 {
1921− model.cursor = prev_cursor;
2189+ model.cursor = prev_cursor;
2190+ }
1922 model.error = Some("relay sent an oversized poll page".into());
2191 model.error = Some("relay sent an oversized poll page".into());
1923 break;
2192 break;
1924 }
2193 }
@@ -1932,6 +2201,10 @@ impl Skrepka {
1932 let Some(parsed) = protocol::parse_payload(&dec.plaintext) else {
2201 let Some(parsed) = protocol::parse_payload(&dec.plaintext) else {
1933 continue;
2202 continue;
1934 };
2203 };
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;
1935 let sender = dec.sender_hex;
2208 let sender = dec.sender_hex;
1936
2209
1937 // Mail from ourselves. A relay cannot forge this — the sender identity
2210 // Mail from ourselves. A relay cannot forge this — the sender identity
@@ -1983,12 +2256,35 @@ impl Skrepka {
1983 Contact::new(sender.clone(), String::new(), ts),
2256 Contact::new(sender.clone(), String::new(), ts),
1984 );
2257 );
1985 contacts_dirty = true;
2258 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;
1986 }
2271 }
1987 let convo = model.messages.entry(sender.clone()).or_default();
2272 let convo = model.messages.entry(sender.clone()).or_default();
1988− // Lazily build the per-sender ID set on first touch.
2273+ // Q18: Use the persistent per-peer dedup set from
2274+ // `model.seen_ids` (loaded from kv at startup) instead of
2275+ // deriving it from the trimmed conversation. Without this,
2276+ // a replayed blob whose `id` was aged out by `trim_history`
2277+ // (past the 1000-message cap) passes the dedup check and
2278+ // re-enters the conversation.
2279+ let id_set = model
2280+ .seen_ids
1989− let id_set = seen_ids.entry(sender.clone()).or_insert_with(|| {
2281+ .entry(sender.clone())
2282+ .or_default();
2283+ // Also seed from the conversation in case the kv load was
2284+ // missing or partial — covers the upgrade path.
2285+ if id_set.is_empty() {
1990− convo.iter().map(|m| m.id.clone()).collect()
2286+ id_set.extend(convo.iter().map(|m| m.id.clone()));
1991− });
2287+ }
1992 // dedup by id — O(1) via HashSet
2288 // dedup by id — O(1) via HashSet
1993 if !id_set.contains(&msg_id) {
2289 if !id_set.contains(&msg_id) {
1994 // The conversation is *kept* sorted rather than re-sorted:
2290 // The conversation is *kept* sorted rather than re-sorted:
@@ -2006,6 +2302,29 @@ impl Skrepka {
2006 },
2302 },
2007 );
2303 );
2008 id_set.insert(msg_id.clone());
2304 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+ }
2009 trim_history(convo);
2328 trim_history(convo);
2010 touched_convos.insert(sender.clone());
2329 touched_convos.insert(sender.clone());
2011 // Only ack a message we actually stored — a duplicate id
2330 // Only ack a message we actually stored — a duplicate id
@@ -2014,39 +2333,26 @@ impl Skrepka {
2014 }
2333 }
2015 }
2334 }
2016 Payload::DeliveryAck { ack_ids } => {
2335 Payload::DeliveryAck { ack_ids } => {
2017− // Staleness check: drop a replayed ack whose ts predates the
2336+ // Q17: Defer the staleness check until after the event loop.
2018− // last one we processed for this contact.
2337+ // Within a single page, a relay can reorder events so that a
2019− let stale = model
2338+ // newer ack (higher ts) is processed before an older one. If
2020− .contacts
2339+ // we applied the watermark immediately, the older ack would be
2340+ // dropped as stale — even if it acks different message IDs.
2341+ // Instead, accumulate all ack_ids per sender and the max ts,
2342+ // then apply them in a second pass below the loop.
2343+ let prev_ts = pending_acks
2021 .get(&sender)
2344 .get(&sender)
2022− .is_some_and(|c| ts < c.last_ack_ts);
2345+ .map(|p| p.ts)
2023− if stale {
2346+ .unwrap_or(0);
2024− continue;
2347+ if ts > prev_ts {
2025− }
2348+ let entry = pending_acks.entry(sender.clone()).or_default();
2026− // The watermark is clamped to *now*, not merely into the skew
2349+ entry.ts = ts;
2027− // window: `ts` is the peer's to choose, and `last_ack_ts` is
2028− // what every later ack is measured against. A single ack at
2029− // `ts = i64::MAX` would otherwise park the watermark a minute
2030− // into the future, and every honest ack that followed — the
2031− // ones that actually mark our messages delivered — would read
2032− // as stale and be dropped.
2033− if let Some(c) = model.contacts.get_mut(&sender) {
2034− c.last_ack_ts = ts.min(now);
2035− contacts_dirty = true;
2036− }
2037− let ack_set: HashSet<String> = ack_ids.iter().cloned().collect();
2038− if let Some(convo) = model.messages.get_mut(&sender) {
2039− let mut changed = false;
2040− for m in convo.iter_mut() {
2041− if m.outgoing && ack_set.contains(&m.id) && !m.delivered {
2042− m.delivered = true;
2043− changed = true;
2044− }
2045− }
2046− if changed {
2047− touched_convos.insert(sender);
2048− }
2049 }
2350 }
2351+ pending_acks
2352+ .entry(sender.clone())
2353+ .or_default()
2354+ .ids
2355+ .extend(ack_ids);
2050 }
2356 }
2051 Payload::Profile {
2357 Payload::Profile {
2052 display_name,
2358 display_name,
@@ -2073,9 +2379,17 @@ impl Skrepka {
2073 // A replayed profile at the same `ts` overwrites with identical
2379 // A replayed profile at the same `ts` overwrites with identical
2074 // content, which is a no-op in practice.
2380 // content, which is a no-op in practice.
2075 if ts >= c.last_profile_ts {
2381 if ts >= c.last_profile_ts {
2382+ // D13: Only update fields that are present (Some).
2383+ // An absent field (None) means "don't change".
2384+ if let Some(name) = display_name {
2076− c.display_name = display_name;
2385+ c.display_name = name;
2386+ }
2387+ if let Some(b) = bio {
2077− c.bio = bio;
2388+ c.bio = b;
2389+ }
2390+ if let Some(p) = photo {
2078− c.photo = photo;
2391+ c.photo = Some(p);
2392+ }
2079 c.last_profile_ts = ts.min(now);
2393 c.last_profile_ts = ts.min(now);
2080 contacts_dirty = true;
2394 contacts_dirty = true;
2081 }
2395 }
@@ -2083,6 +2397,42 @@ impl Skrepka {
2083 }
2397 }
2084 }
2398 }
2085
2399
2400+ // Q17: Apply deferred delivery acks. Collected during the event loop
2401+ // above, now applied with a single staleness check per sender using the
2402+ // highest `ts` seen — so relay page ordering cannot cause an older ack
2403+ // to be dropped as stale by a newer one processed first.
2404+ for (sender, pending) in &pending_acks {
2405+ let stale = model
2406+ .contacts
2407+ .get(sender)
2408+ .is_some_and(|c| pending.ts < c.last_ack_ts);
2409+ if stale {
2410+ continue;
2411+ }
2412+ // The watermark is clamped to *now*, not merely into the skew window:
2413+ // `ts` is the peer's to choose, and `last_ack_ts` is what every later
2414+ // ack is measured against. A single ack at `ts = i64::MAX` would
2415+ // otherwise park the watermark a minute into the future, and every
2416+ // honest ack that followed — the ones that actually mark our messages
2417+ // delivered — would read as stale and be dropped.
2418+ if let Some(c) = model.contacts.get_mut(sender) {
2419+ c.last_ack_ts = pending.ts.min(now);
2420+ contacts_dirty = true;
2421+ }
2422+ if let Some(convo) = model.messages.get_mut(sender) {
2423+ let mut changed = false;
2424+ for m in convo.iter_mut() {
2425+ if m.outgoing && pending.ids.contains(&m.id) && !m.delivered {
2426+ m.delivered = true;
2427+ changed = true;
2428+ }
2429+ }
2430+ if changed {
2431+ touched_convos.insert(sender.clone());
2432+ }
2433+ }
2434+ }
2435+
2086 // Queue delivery acks, in batches the recipient will actually accept.
2436 // Queue delivery acks, in batches the recipient will actually accept.
2087 //
2437 //
2088 // `parse_payload` drops a `delivery.ack` carrying more than `MAX_ACK_IDS`
2438 // `parse_payload` drops a `delivery.ack` carrying more than `MAX_ACK_IDS`
@@ -2105,6 +2455,7 @@ impl Skrepka {
2105 Arc::new(protocol::serialize_payload(&payload, ack_ts)),
2455 Arc::new(protocol::serialize_payload(&payload, ack_ts)),
2106 ));
2456 ));
2107 }
2457 }
2458+ outbox_dirty = true;
2108 }
2459 }
2109
2460
2110 // Persist everything touched.
2461 // Persist everything touched.
@@ -2112,10 +2463,16 @@ impl Skrepka {
2112 for peer in &touched_convos {
2463 for peer in &touched_convos {
2113 cmds.push(self.persist_messages(model, peer));
2464 cmds.push(self.persist_messages(model, peer));
2114 }
2465 }
2466+ // Q18: Persist the dedup ID set whenever we touched conversations.
2467+ if !touched_convos.is_empty() {
2468+ cmds.push(self.persist_seen_ids(model));
2469+ }
2115 if contacts_dirty {
2470 if contacts_dirty {
2116 cmds.push(self.persist_contacts(model));
2471 cmds.push(self.persist_contacts(model));
2117 }
2472 }
2473+ if outbox_dirty {
2118− cmds.push(self.persist_outbox(model));
2474+ cmds.push(self.persist_outbox(model));
2475+ }
2119 cmds.push(Command::event(Event::StartFlush));
2476 cmds.push(Command::event(Event::StartFlush));
2120 Command::all(cmds)
2477 Command::all(cmds)
2121 }
2478 }
@@ -2188,7 +2545,7 @@ mod tests {
2188 use crate::model::{MAX_OUTBOX_RETRIES, OUTBOX_TTL_MS};
2545 use crate::model::{MAX_OUTBOX_RETRIES, OUTBOX_TTL_MS};
2189
2546
2190 fn with_identity() -> Model {
2547 fn with_identity() -> Model {
2191− let id = Identity::from_seed(&[3u8; 32]);
2548+ let id = Identity::from_seed(&[3u8; 32]).unwrap();
2192 Model {
2549 Model {
2193 secret_key: Some(Zeroizing::new(id.secret_key.to_vec())),
2550 secret_key: Some(Zeroizing::new(id.secret_key.to_vec())),
2194 my_pubkey: id.public_key_hex(),
2551 my_pubkey: id.public_key_hex(),
@@ -2197,7 +2554,7 @@ mod tests {
2197 }
2554 }
2198
2555
2199 fn peer_hex(seed: u8) -> String {
2556 fn peer_hex(seed: u8) -> String {
2200− Identity::from_seed(&[seed; 32]).public_key_hex()
2557+ Identity::from_seed(&[seed; 32]).unwrap().public_key_hex()
2201 }
2558 }
2202
2559
2203 /// `Command::event` *queues* an event; it does not run it. The runtime drains
2560 /// `Command::event` *queues* an event; it does not run it. The runtime drains
@@ -2220,7 +2577,7 @@ mod tests {
2220
2577
2221 /// A poll page carrying one payload, encrypted from `sender_seed` to `me`.
2578 /// A poll page carrying one payload, encrypted from `sender_seed` to `me`.
2222 fn page_from(sender_seed: u8, me: &Model, payload: &Payload, ts: i64) -> PollResp {
2579 fn page_from(sender_seed: u8, me: &Model, payload: &Payload, ts: i64) -> PollResp {
2223− let sender = Identity::from_seed(&[sender_seed; 32]);
2580+ let sender = Identity::from_seed(&[sender_seed; 32]).unwrap();
2224 let recipient = Identity::from_secret_bytes(me.secret_key.as_ref().unwrap()).unwrap();
2581 let recipient = Identity::from_secret_bytes(me.secret_key.as_ref().unwrap()).unwrap();
2225 let json = protocol::serialize_payload(payload, ts);
2582 let json = protocol::serialize_payload(payload, ts);
2226 let blob = crate::crypto::encrypt(
2583 let blob = crate::crypto::encrypt(
@@ -2309,7 +2666,8 @@ mod tests {
2309 /// One full send round-trip: flush the outbox, then answer with `status`.
2666 /// One full send round-trip: flush the outbox, then answer with `status`.
2310 fn send_and_answer(app: &Skrepka, m: &mut Model, status: u16) {
2667 fn send_and_answer(app: &Skrepka, m: &mut Model, status: u16) {
2311 let _ = app.update(Event::StartFlush, m);
2668 let _ = app.update(Event::StartFlush, m);
2669+ let gen = m.flush_gen;
2312− let _ = app.update(Event::SendResult(Ok(ok_response(status))), m);
2670+ let _ = app.update(Event::SendResult(gen, Ok(ok_response(status))), m);
2313 }
2671 }
2314
2672
2315 // -----------------------------------------------------------------------
2673 // -----------------------------------------------------------------------
@@ -2338,7 +2696,7 @@ mod tests {
2338 // The last one exhausts the budget: the head goes, and what was stuck
2696 // The last one exhausts the budget: the head goes, and what was stuck
2339 // behind it goes out.
2697 // behind it goes out.
2340 let _ = app.update(Event::StartFlush, &mut m);
2698 let _ = app.update(Event::StartFlush, &mut m);
2341− let mut cmd = app.update(Event::SendResult(Ok(ok_response(503))), &mut m);
2699+ let mut cmd = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(503))), &mut m);
2342
2700
2343 assert_eq!(m.outbox.len(), 1, "the undeliverable item is dropped");
2701 assert_eq!(m.outbox.len(), 1, "the undeliverable item is dropped");
2344 assert_eq!(m.outbox[0].recipient, behind, "and the queue moves on");
2702 assert_eq!(m.outbox[0].recipient, behind, "and the queue moves on");
@@ -2457,7 +2815,7 @@ mod tests {
2457
2815
2458 let _ = app.update(Event::StartFlush, &mut m);
2816 let _ = app.update(Event::StartFlush, &mut m);
2459 let mut cmd = app.update(
2817 let mut cmd = app.update(
2460− Event::SendResult(Ok(response_with_header(429, "retry-after", "2"))),
2818+ Event::SendResult(m.flush_gen, Ok(response_with_header(429, "retry-after", "2"))),
2461 &mut m,
2819 &mut m,
2462 );
2820 );
2463
2821
@@ -2488,7 +2846,7 @@ mod tests {
2488 m.poll_retries = 0;
2846 m.poll_retries = 0;
2489
2847
2490 let _ = app.update(Event::StartFlush, &mut m);
2848 let _ = app.update(Event::StartFlush, &mut m);
2491− let mut cmd = app.update(Event::SendResult(Ok(ok_response(429))), &mut m);
2849+ let mut cmd = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(429))), &mut m);
2492
2850
2493 assert_eq!(m.outbox[0].retries, 0, "still not the message's fault");
2851 assert_eq!(m.outbox[0].retries, 0, "still not the message's fault");
2494 assert_eq!(
2852 assert_eq!(
@@ -2501,7 +2859,7 @@ mod tests {
2501 // A second 429 climbs, rather than restarting at 3 s.
2859 // A second 429 climbs, rather than restarting at 3 s.
2502 m.flush_paused_until = 0;
2860 m.flush_paused_until = 0;
2503 let _ = app.update(Event::StartFlush, &mut m);
2861 let _ = app.update(Event::StartFlush, &mut m);
2504− let mut cmd = app.update(Event::SendResult(Ok(ok_response(429))), &mut m);
2862+ let mut cmd = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(429))), &mut m);
2505 assert_eq!(scheduled_delays(&mut cmd), vec![backoff_ms(1)], "it climbs");
2863 assert_eq!(scheduled_delays(&mut cmd), vec![backoff_ms(1)], "it climbs");
2506 assert_eq!(m.send_retries, 2);
2864 assert_eq!(m.send_retries, 2);
2507 }
2865 }
@@ -2519,7 +2877,7 @@ mod tests {
2519
2877
2520 let _ = app.update(Event::StartFlush, &mut m);
2878 let _ = app.update(Event::StartFlush, &mut m);
2521 let _ = app.update(
2879 let _ = app.update(
2522− Event::SendResult(Ok(response_with_header(429, "retry-after", "2"))),
2880+ Event::SendResult(m.flush_gen, Ok(response_with_header(429, "retry-after", "2"))),
2523 &mut m,
2881 &mut m,
2524 );
2882 );
2525 assert!(
2883 assert!(
@@ -2575,7 +2933,7 @@ mod tests {
2575
2933
2576 let _ = app.update(Event::StartFlush, &mut m);
2934 let _ = app.update(Event::StartFlush, &mut m);
2577 let mut cmd = app.update(
2935 let mut cmd = app.update(
2578− Event::SendResult(Ok(response_with_header(429, "retry-after", "999999999"))),
2936+ Event::SendResult(m.flush_gen, Ok(response_with_header(429, "retry-after", "999999999"))),
2579 &mut m,
2937 &mut m,
2580 );
2938 );
2581
2939
@@ -2610,7 +2968,7 @@ mod tests {
2610 assert!(batches[0].iter().all(|e| e.to == peer));
2968 assert!(batches[0].iter().all(|e| e.to == peer));
2611
2969
2612 // ...and its success clears the whole batch, not just the head.
2970 // ...and its success clears the whole batch, not just the head.
2613− let _ = app.update(Event::SendResult(Ok(ok_response(200))), &mut m);
2971+ let _ = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
2614 assert!(m.outbox.is_empty());
2972 assert!(m.outbox.is_empty());
2615 }
2973 }
2616
2974
@@ -2635,7 +2993,7 @@ mod tests {
2635 assert_eq!(batches[0].len(), 2, "only the run at the head");
2993 assert_eq!(batches[0].len(), 2, "only the run at the head");
2636 assert!(batches[0].iter().all(|e| e.to == a));
2994 assert!(batches[0].iter().all(|e| e.to == a));
2637
2995
2638− let mut cmd = app.update(Event::SendResult(Ok(ok_response(200))), &mut m);
2996+ let mut cmd = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
2639 assert_eq!(m.outbox.len(), 2);
2997 assert_eq!(m.outbox.len(), 2);
2640 let batches = sent_batches(&mut cmd);
2998 let batches = sent_batches(&mut cmd);
2641 assert_eq!(batches[0].len(), 1, "then b's single message");
2999 assert_eq!(batches[0].len(), 1, "then b's single message");
@@ -2658,7 +3016,7 @@ mod tests {
2658 let batches = sent_batches(&mut cmd);
3016 let batches = sent_batches(&mut cmd);
2659 assert_eq!(batches[0].len(), MAX_SEND_BATCH);
3017 assert_eq!(batches[0].len(), MAX_SEND_BATCH);
2660
3018
2661− let _ = app.update(Event::SendResult(Ok(ok_response(200))), &mut m);
3019+ let _ = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
2662 assert_eq!(m.outbox.len(), 20, "the rest stays queued for the next batch");
3020 assert_eq!(m.outbox.len(), 20, "the rest stays queued for the next batch");
2663 }
3021 }
2664
3022
@@ -2720,7 +3078,7 @@ mod tests {
2720 let sent_bytes: usize = first[0].iter().map(|e| e.encrypted_blob.len()).sum();
3078 let sent_bytes: usize = first[0].iter().map(|e| e.encrypted_blob.len()).sum();
2721
3079
2722 // ...and the relay says that was too big.
3080 // ...and the relay says that was too big.
2723− let mut cmd = app.update(Event::SendResult(Ok(ok_response(413))), &mut m);
3081+ let mut cmd = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(413))), &mut m);
2724
3082
2725 assert_eq!(
3083 assert_eq!(
2726 m.send_batch_budget,
3084 m.send_batch_budget,
@@ -2741,7 +3099,7 @@ mod tests {
2741 assert!(shrunk > 0, "but not an empty one");
3099 assert!(shrunk > 0, "but not an empty one");
2742
3100
2743 // The relay takes the smaller batch, and exactly it is popped.
3101 // The relay takes the smaller batch, and exactly it is popped.
2744− let _ = app.update(Event::SendResult(Ok(ok_response(200))), &mut m);
3102+ let _ = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
2745 assert_eq!(m.outbox.len(), 5 - shrunk, "exactly the batch that landed is gone");
3103 assert_eq!(m.outbox.len(), 5 - shrunk, "exactly the batch that landed is gone");
2746 assert_eq!(
3104 assert_eq!(
2747 m.send_batch_budget,
3105 m.send_batch_budget,
@@ -2846,7 +3204,7 @@ mod tests {
2846 let parsed = protocol::parse_payload(item.envelope_json.as_bytes()).unwrap();
3204 let parsed = protocol::parse_payload(item.envelope_json.as_bytes()).unwrap();
2847 match parsed.payload {
3205 match parsed.payload {
2848 Payload::Profile { display_name, .. } => {
3206 Payload::Profile { display_name, .. } => {
2849− 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");
2850 }
3208 }
2851 _ => panic!("expected a profile"),
3209 _ => panic!("expected a profile"),
2852 }
3210 }
@@ -2889,12 +3247,12 @@ mod tests {
2889 );
3247 );
2890 assert_eq!(m.outbox.len(), 2, "the in-flight item is left alone");
3248 assert_eq!(m.outbox.len(), 2, "the in-flight item is left alone");
2891
3249
2892− let _ = app.update(Event::SendResult(Ok(ok_response(200))), &mut m);
3250+ let _ = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
2893 assert_eq!(m.outbox.len(), 1, "the sent one is popped");
3251 assert_eq!(m.outbox.len(), 1, "the sent one is popped");
2894 let parsed = protocol::parse_payload(m.outbox[0].envelope_json.as_bytes()).unwrap();
3252 let parsed = protocol::parse_payload(m.outbox[0].envelope_json.as_bytes()).unwrap();
2895 match parsed.payload {
3253 match parsed.payload {
2896 Payload::Profile { display_name, .. } => assert_eq!(
3254 Payload::Profile { display_name, .. } => assert_eq!(
2897− display_name, "New",
3255+ display_name, Some("New".to_string()),
2898 "and the newer profile survives to be sent"
3256 "and the newer profile survives to be sent"
2899 ),
3257 ),
2900 _ => panic!("expected a profile"),
3258 _ => panic!("expected a profile"),
@@ -2935,7 +3293,7 @@ mod tests {
2935 assert_eq!(m.outbox.len(), 1);
3293 assert_eq!(m.outbox.len(), 1);
2936
3294
2937 // The send lands. It must pop nothing.
3295 // The send lands. It must pop nothing.
2938− let _ = app.update(Event::SendResult(Ok(ok_response(200))), &mut m);
3296+ let _ = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
2939 assert_eq!(m.outbox.len(), 1, "the friend's message is untouched");
3297 assert_eq!(m.outbox.len(), 1, "the friend's message is untouched");
2940 assert_eq!(m.outbox[0].recipient, friend);
3298 assert_eq!(m.outbox[0].recipient, friend);
2941 }
3299 }
@@ -3076,8 +3434,8 @@ mod tests {
3076 .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
3434 .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
3077
3435
3078 let profile = |name: &str| Payload::Profile {
3436 let profile = |name: &str| Payload::Profile {
3079− display_name: name.into(),
3437+ display_name: Some(name.to_string()),
3080− bio: String::new(),
3438+ bio: Some(String::new()),
3081 photo: None,
3439 photo: None,
3082 };
3440 };
3083 let page = page_from(9, &m, &profile("Far"), i64::MAX);
3441 let page = page_from(9, &m, &profile("Far"), i64::MAX);
@@ -3174,8 +3532,11 @@ mod tests {
3174 let (m, peer) = ingest_one_text_from_an_unblocked_peer();
3532 let (m, peer) = ingest_one_text_from_an_unblocked_peer();
3175 assert_eq!(m.messages[&peer].len(), 1);
3533 assert_eq!(m.messages[&peer].len(), 1);
3176 assert_eq!(m.messages[&peer][0].body, "hi");
3534 assert_eq!(m.messages[&peer][0].body, "hi");
3535+ // D6-app: A profile broadcast is queued alongside the delivery.ack
3536+ // so the new contact sees our display name and avatar.
3177− assert_eq!(m.outbox.len(), 1, "one delivery.ack is queued");
3537+ assert_eq!(m.outbox.len(), 2, "one delivery.ack + one profile queued");
3178 assert_eq!(m.outbox[0].recipient, peer);
3538 assert_eq!(m.outbox[0].recipient, peer);
3539+ assert_eq!(m.outbox[1].recipient, peer);
3179 }
3540 }
3180
3541
3181 /// A peer sending `ts = i64::MAX` used to pin itself to the top of the
3542 /// A peer sending `ts = i64::MAX` used to pin itself to the top of the
@@ -3612,7 +3973,7 @@ mod tests {
3612 assert!(m.flushing);
3973 assert!(m.flushing);
3613
3974
3614 let resp = crux_http::testing::ResponseBuilder::ok().body(Vec::new()).build();
3975 let resp = crux_http::testing::ResponseBuilder::ok().body(Vec::new()).build();
3615− let _ = app.update(Event::SendResult(Ok(resp)), &mut m);
3976+ let _ = app.update(Event::SendResult(m.flush_gen, Ok(resp)), &mut m);
3616
3977
3617 assert!(!m.flushing, "flushing must be cleared on the success path");
3978 assert!(!m.flushing, "flushing must be cleared on the success path");
3618 assert!(m.outbox.is_empty(), "the sent item is dropped from the outbox");
3979 assert!(m.outbox.is_empty(), "the sent item is dropped from the outbox");
@@ -3634,7 +3995,7 @@ mod tests {
3634
3995
3635 // Its success must both drop the item and let the *next* one go out.
3996 // Its success must both drop the item and let the *next* one go out.
3636 let resp = crux_http::testing::ResponseBuilder::ok().body(Vec::new()).build();
3997 let resp = crux_http::testing::ResponseBuilder::ok().body(Vec::new()).build();
3637− let _ = app.update(Event::SendResult(Ok(resp)), &mut m);
3998+ let _ = app.update(Event::SendResult(m.flush_gen, Ok(resp)), &mut m);
3638 assert_eq!(m.outbox.len(), 1);
3999 assert_eq!(m.outbox.len(), 1);
3639 assert!(m.flushing, "the second item is now in flight");
4000 assert!(m.flushing, "the second item is now in flight");
3640 }
4001 }
@@ -3777,6 +4138,7 @@ mod tests {
3777 Event::LoadedProfile(Ok(None)),
4138 Event::LoadedProfile(Ok(None)),
3778 Event::LoadedContacts(Ok(None)),
4139 Event::LoadedContacts(Ok(None)),
3779 Event::LoadedCursor(Ok(None)),
4140 Event::LoadedCursor(Ok(None)),
4141+ Event::LoadedSeenIds(Ok(None)),
3780 ]
4142 ]
3781 .into_iter()
4143 .into_iter()
3782 .enumerate()
4144 .enumerate()
@@ -3790,7 +4152,7 @@ mod tests {
3790 assert_eq!(connects(&mut cmd), 0, "and none of them connects");
4152 assert_eq!(connects(&mut cmd), 0, "and none of them connects");
3791 }
4153 }
3792
4154
3793− // The fifth — and only the fifth — emits Connect.
4155+ // The sixth — and only the sixth — emits Connect.
3794 let mut cmd = app.update(Event::LoadedOutbox(Ok(None)), &mut m);
4156 let mut cmd = app.update(Event::LoadedOutbox(Ok(None)), &mut m);
3795 assert_eq!(m.loads_pending, 0);
4157 assert_eq!(m.loads_pending, 0);
3796 assert_eq!(connects(&mut cmd), 1, "the last load connects, exactly once");
4158 assert_eq!(connects(&mut cmd), 1, "the last load connects, exactly once");
@@ -3880,7 +4242,7 @@ mod tests {
3880 );
4242 );
3881
4243
3882 // The recovery is a relaunch, which re-runs the loads and clears the latch.
4244 // The recovery is a relaunch, which re-runs the loads and clears the latch.
3883− let sk = Identity::from_seed(&[3u8; 32]).secret_key.to_vec();
4245+ let sk = Identity::from_seed(&[3u8; 32]).unwrap().secret_key.to_vec();
3884 let _ = app.update(Event::IdentityLoaded(sk), &mut m);
4246 let _ = app.update(Event::IdentityLoaded(sk), &mut m);
3885 assert!(
4247 assert!(
3886 !m.kv_load_failed,
4248 !m.kv_load_failed,
@@ -3987,8 +4349,8 @@ mod tests {
3987 );
4349 );
3988
4350
3989 let profile = Payload::Profile {
4351 let profile = Payload::Profile {
3990− display_name: "Mallory".into(),
4352+ display_name: Some("Mallory".into()),
3991− bio: "spam".into(),
4353+ bio: Some("spam".into()),
3992 photo: Some("aGk=".into()),
4354 photo: Some("aGk=".into()),
3993 };
4355 };
3994 let page = page_from(9, &m, &profile, now_ms());
4356 let page = page_from(9, &m, &profile, now_ms());
@@ -4069,8 +4431,8 @@ mod tests {
4069 let peer = peer_hex(9);
4431 let peer = peer_hex(9);
4070
4432
4071 let profile = Payload::Profile {
4433 let profile = Payload::Profile {
4072− display_name: "Mallory".into(),
4434+ display_name: Some("Mallory".into()),
4073− bio: String::new(),
4435+ bio: Some(String::new()),
4074 photo: Some("aGk=".into()),
4436 photo: Some("aGk=".into()),
4075 };
4437 };
4076 let page = page_from(9, &m, &profile, now_ms());
4438 let page = page_from(9, &m, &profile, now_ms());
@@ -4282,7 +4644,7 @@ mod tests {
4282
4644
4283 // One page holding more messages from one sender than fits in a single ack.
4645 // One page holding more messages from one sender than fits in a single ack.
4284 let count = protocol::MAX_ACK_IDS + 5;
4646 let count = protocol::MAX_ACK_IDS + 5;
4285− let sender = Identity::from_seed(&[9u8; 32]);
4647+ let sender = Identity::from_seed(&[9u8; 32]).unwrap();
4286 let me = Identity::from_secret_bytes(m.secret_key.as_ref().unwrap()).unwrap();
4648 let me = Identity::from_secret_bytes(m.secret_key.as_ref().unwrap()).unwrap();
4287 let events = (0..count)
4649 let events = (0..count)
4288 .map(|i| {
4650 .map(|i| {
@@ -4602,7 +4964,7 @@ mod tests {
4602
4964
4603 // The send never comes back. The watchdog fires, clears the stuck guard,
4965 // The send never comes back. The watchdog fires, clears the stuck guard,
4604 // and retries the flush — the message goes out again.
4966 // and retries the flush — the message goes out again.
4605− let mut cmd = app.update(Event::FlushWatchdog, &mut m);
4967+ let mut cmd = app.update(Event::FlushWatchdog(m.flush_gen), &mut m);
4606
4968
4607 assert!(m.flushing, "a fresh send is in flight (the retry)");
4969 assert!(m.flushing, "a fresh send is in flight (the retry)");
4608 assert!(m.in_flight.is_some(), "in_flight is set for the retry");
4970 assert!(m.in_flight.is_some(), "in_flight is set for the retry");
@@ -4619,22 +4981,23 @@ mod tests {
4619 m.outbox.push_back(queued(&peer_hex(9), "hi"));
4981 m.outbox.push_back(queued(&peer_hex(9), "hi"));
4620
4982
4621 let _ = app.update(Event::StartFlush, &mut m);
4983 let _ = app.update(Event::StartFlush, &mut m);
4622− let _ = app.update(Event::SendResult(Ok(ok_response(200))), &mut m);
4984+ let _ = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
4623 assert!(!m.flushing);
4985 assert!(!m.flushing);
4624 assert!(m.outbox.is_empty());
4986 assert!(m.outbox.is_empty());
4625
4987
4626 // The watchdog for the completed send fires late.
4988 // The watchdog for the completed send fires late.
4627− let _ = app.update(Event::FlushWatchdog, &mut m);
4989+ let _ = app.update(Event::FlushWatchdog(m.flush_gen), &mut m);
4628 assert!(!m.flushing, "a completed send is not re-armed");
4990 assert!(!m.flushing, "a completed send is not re-armed");
4629 assert!(m.outbox.is_empty());
4991 assert!(m.outbox.is_empty());
4630 }
4992 }
4631
4993
4632− /// A late `SendResult` after the watchdog has already retried sees the
4994+ /// A late `SendResult` from a superseded send is dropped by the generation
4633− /// retry's `in_flight` and pops its items. This is correct: the retry sent
4995+ /// check. The watchdog abandoned the first send and retried under a new
4634− /// the same items, and a 200 means they were accepted — whether by the
4996+ /// generation; the late result's generation no longer matches, so it cannot
4635− /// original request or the retry is irrelevant. The items are delivered.
4997+ /// pop the retry's items or clear its guard. The retry's own `SendResult`
4998+ /// is what eventually pops them.
4636 #[test]
4999 #[test]
4637− fn a_late_send_result_after_the_watchdog_pops_the_retrys_items() {
5000+ fn a_late_send_result_after_the_watchdog_is_dropped_by_generation() {
4638 let app = Skrepka;
5001 let app = Skrepka;
4639 let mut m = with_identity();
5002 let mut m = with_identity();
4640 m.token = Some("t".into());
5003 m.token = Some("t".into());
@@ -4642,15 +5005,23 @@ mod tests {
4642 m.outbox.push_back(queued(&peer, "stuck"));
5005 m.outbox.push_back(queued(&peer, "stuck"));
4643
5006
4644 let _ = app.update(Event::StartFlush, &mut m);
5007 let _ = app.update(Event::StartFlush, &mut m);
5008+ let first_gen = m.flush_gen;
4645− // The watchdog fires and retries the flush.
5009+ // The watchdog fires and retries the flush under a new generation.
4646− let _ = app.update(Event::FlushWatchdog, &mut m);
5010+ let _ = app.update(Event::FlushWatchdog(first_gen), &mut m);
4647 assert_eq!(m.outbox.len(), 1, "the item is still queued (re-sent)");
5011 assert_eq!(m.outbox.len(), 1, "the item is still queued (re-sent)");
4648−
5012+ assert_ne!(m.flush_gen, first_gen, "the generation was bumped");
5013+ assert!(m.flushing, "the retry is in flight");
5014+
4649− // Now the original send's result arrives late. The retry's in_flight is
5015+ // The original send's result arrives late. Its generation no longer
4650− // set, so a 200 pops the item — which is correct, the message was
5016+ // matches, so it is dropped — it cannot pop the retry's items or
4651− // accepted by one of the two sends.
5017+ // clear the retry's guard.
5018+ let _ = app.update(Event::SendResult(first_gen, Ok(ok_response(200))), &mut m);
5019+ assert!(m.flushing, "the retry's guard is untouched");
5020+ assert_eq!(m.outbox.len(), 1, "the late result popped nothing");
5021+
5022+ // The retry's own result arrives and pops the items.
4652− let _ = app.update(Event::SendResult(Ok(ok_response(200))), &mut m);
5023+ let _ = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
4653− assert!(m.outbox.is_empty(), "the late 200 pops the retry's items — the message was accepted");
5024+ assert!(m.outbox.is_empty(), "the retry's result pops the items");
4654 assert!(!m.flushing, "and the guard is released");
5025 assert!(!m.flushing, "and the guard is released");
4655 }
5026 }
4656}
5027}
core/src/crypto.rs+240−64
@@ -15,28 +15,60 @@
15
15
16use std::io::Read;
16use std::io::Read;
17
17
18−use chacha20poly1305::aead::Aead;
18+use chacha20poly1305::aead::{Aead, Payload as AeadPayload};
19use chacha20poly1305::{Key, KeyInit, XChaCha20Poly1305, XNonce};
19use chacha20poly1305::{Key, KeyInit, XChaCha20Poly1305, XNonce};
20use curve25519_dalek::edwards::CompressedEdwardsY;
20use curve25519_dalek::edwards::CompressedEdwardsY;
21−use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey};
21+use ed25519_dalek::{Signer, SigningKey, VerifyingKey};
22use hkdf::Hkdf;
22use hkdf::Hkdf;
23use rand_core::{CryptoRng, RngCore};
23use rand_core::{CryptoRng, RngCore};
24use sha2::{Digest, Sha256, Sha512};
24use sha2::{Digest, Sha256, Sha512};
25use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
25use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
26
26
27−/// HKDF `info` string (PROTOCOL.md §3 / Constants.hkdfInfo). Fixed.
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).
28−const HKDF_INFO: &[u8] = b"skrepka-v1";
30+const HKDF_INFO: &[u8] = b"skrepka-v2";
29/// Domain-separation tag for the auth challenge signature (PROTOCOL.md §6).
31/// Domain-separation tag for the auth challenge signature (PROTOCOL.md §6).
30const AUTH_TAG: &str = "skrepka-auth-v1:";
32const AUTH_TAG: &str = "skrepka-auth-v1:";
31
33
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+
32/// Wire-format version byte prepended to every encrypted blob (PROTOCOL.md §3).
41/// Wire-format version byte prepended to every encrypted blob (PROTOCOL.md §3).
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.
33−const WIRE_VERSION: u8 = 1;
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+}
34
64
35pub const ED25519_SECRET_LEN: usize = 64;
65pub const ED25519_SECRET_LEN: usize = 64;
36pub const ED25519_PUBLIC_LEN: usize = 32;
66pub const ED25519_PUBLIC_LEN: usize = 32;
37pub const NONCE_LEN: usize = 24;
67pub const NONCE_LEN: usize = 24;
38−/// 1 (version) + 32 (eph) + 24 (nonce) + 32 (sender pub) + 64 (sig) + 4 (compressed_len)
68+/// `1+32+24+32+64+4+16` bytes = version, eph pubkey, nonce, sender pub, sig,
39−/// + 16 (AEAD tag). The 4-byte compressed_len field sits inside the AEAD ciphertext.
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).
40pub const MIN_BLOB_LEN: usize = 1 + 32 + NONCE_LEN + 32 + 64 + 4 + 16;
72pub const MIN_BLOB_LEN: usize = 1 + 32 + NONCE_LEN + 32 + 64 + 4 + 16;
41
73
42/// Blob sizes are padded up to these boundaries so the on-wire length does not
74/// 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 {
58 // Above 65536: round to the next multiple of 65536.
90 // Above 65536: round to the next multiple of 65536.
59 let remainder = actual % 65536;
91 let remainder = actual % 65536;
60 if remainder == 0 {
92 if remainder == 0 {
93+ // D6: When actual is an exact multiple of 65536 above 65536,
94+ // returning `actual` would give zero padding, revealing the exact
95+ // compressed size. Always add at least one bucket of padding.
61− actual
96+ actual + 65536
62 } else {
97 } else {
63 actual + 65536 - remainder
98 actual + 65536 - remainder
64 }
99 }
@@ -127,9 +162,17 @@ impl std::error::Error for CryptoError {}
127///
162///
128/// `ZeroizeOnDrop` wipes the seed when the value goes away. `Identity` is cloned
163/// `ZeroizeOnDrop` wipes the seed when the value goes away. `Identity` is cloned
129/// freely (once per encrypt/decrypt), so each of those copies is wiped too.
164/// freely (once per encrypt/decrypt), so each of those copies is wiped too.
165+///
166+/// The derived X25519 keys (`x25519_priv`, `x25519_pub`) are computed once at
167+/// construction and cached: `decrypt` is called per-blob in a poll page, and
168+/// recomputing the SHA-512 + Edwards decompress on every call is a measurable
169+/// CPU multiplier under a flood of small blobs. Both are `Zeroizing` so the
170+/// private half is wiped on drop alongside the seed.
130#[derive(Clone, ZeroizeOnDrop)]
171#[derive(Clone, ZeroizeOnDrop)]
131pub struct Identity {
172pub struct Identity {
132 pub secret_key: [u8; ED25519_SECRET_LEN],
173 pub secret_key: [u8; ED25519_SECRET_LEN],
174+ x25519_priv: [u8; 32],
175+ x25519_pub: [u8; 32],
133}
176}
134
177
135impl Identity {
178impl Identity {
@@ -154,24 +197,24 @@ impl Identity {
154 return Err(CryptoError::BadKeyLength);
197 return Err(CryptoError::BadKeyLength);
155 }
198 }
156
199
157− Ok(Identity { secret_key })
200+ Identity::with_x25519(secret_key)
158 }
201 }
159
202
160 /// Generate a fresh identity from a CSPRNG.
203 /// Generate a fresh identity from a CSPRNG.
161− pub fn generate(rng: &mut (impl RngCore + CryptoRng)) -> Self {
204+ pub fn generate(rng: &mut (impl RngCore + CryptoRng)) -> Result<Self, CryptoError> {
162 let mut seed = Zeroizing::new([0u8; 32]);
205 let mut seed = Zeroizing::new([0u8; 32]);
163 rng.fill_bytes(&mut *seed);
206 rng.fill_bytes(&mut *seed);
164 Self::from_seed(&seed)
207 Self::from_seed(&seed)
165 }
208 }
166
209
167 /// Build the 64-byte secret key (`seed || pub`) from a 32-byte seed.
210 /// Build the 64-byte secret key (`seed || pub`) from a 32-byte seed.
168− pub fn from_seed(seed: &[u8; 32]) -> Self {
211+ pub fn from_seed(seed: &[u8; 32]) -> Result<Self, CryptoError> {
169 let signing = SigningKey::from_bytes(seed);
212 let signing = SigningKey::from_bytes(seed);
170 let public = signing.verifying_key();
213 let public = signing.verifying_key();
171 let mut secret_key = [0u8; ED25519_SECRET_LEN];
214 let mut secret_key = [0u8; ED25519_SECRET_LEN];
172 secret_key[..32].copy_from_slice(seed);
215 secret_key[..32].copy_from_slice(seed);
173 secret_key[32..].copy_from_slice(public.as_bytes());
216 secret_key[32..].copy_from_slice(public.as_bytes());
174− Identity { secret_key }
217+ Identity::with_x25519(secret_key)
175 }
218 }
176
219
177 pub fn public_key(&self) -> [u8; ED25519_PUBLIC_LEN] {
220 pub fn public_key(&self) -> [u8; ED25519_PUBLIC_LEN] {
@@ -190,6 +233,46 @@ impl Identity {
190 SigningKey::from_bytes(&seed)
233 SigningKey::from_bytes(&seed)
191 }
234 }
192
235
236+ /// Compute the cached X25519 keys from the Ed25519 secret key.
237+ ///
238+ /// Q49: Also checks that the derived X25519 public key is not an all-zero
239+ /// (low-order) point. The probability of generating such a key is negligible,
240+ /// but if it ever happened every encryption to this identity would fail with
241+ /// `InvalidEphemeralKey` and the user would be unable to receive messages
242+ /// with no indication of why. Catching it at construction time gives a
243+ /// clear error instead.
244+ fn with_x25519(secret_key: [u8; ED25519_SECRET_LEN]) -> Result<Self, CryptoError> {
245+ let x25519_priv = *ed25519_sk_to_x25519(&secret_key);
246+ let x25519_pub = x25519_dalek::x25519(x25519_priv, x25519_dalek::X25519_BASEPOINT_BYTES);
247+ // D33: Return an error instead of just printing a warning when the
248+ // derived X25519 public key is all-zero (low-order point). The
249+ // probability of generating such a key is negligible, but if it ever
250+ // happened every encryption to this identity would fail with
251+ // `InvalidEphemeralKey` and the user would be unable to receive
252+ // messages with no indication of why. Catching it at construction time
253+ // gives a clear error instead.
254+ if x25519_pub.iter().all(|b| *b == 0) {
255+ return Err(CryptoError::InvalidPublicKey);
256+ }
257+ Ok(Identity {
258+ secret_key,
259+ x25519_priv,
260+ x25519_pub,
261+ })
262+ }
263+
264+ /// Cached X25519 private key derived from the Ed25519 seed (libsodium
265+ /// `crypto_sign_ed25519_sk_to_curve25519`). Avoids a per-decrypt SHA-512.
266+ fn x25519_private(&self) -> &[u8; 32] {
267+ &self.x25519_priv
268+ }
269+
270+ /// Cached X25519 public key derived from the Ed25519 public key (libsodium
271+ /// `crypto_sign_ed25519_pk_to_curve25519`). Avoids a per-decrypt decompress.
272+ fn x25519_public(&self) -> &[u8; 32] {
273+ &self.x25519_pub
274+ }
275+
193 /// Sign the auth challenge: `ed25519(sk, "skrepka-auth-v1:" + host + ":" + challenge)`.
276 /// Sign the auth challenge: `ed25519(sk, "skrepka-auth-v1:" + host + ":" + challenge)`.
194 /// `server_host` must already be the bare lowercased hostname.
277 /// `server_host` must already be the bare lowercased hostname.
195 ///
278 ///
@@ -211,7 +294,14 @@ fn ed25519_pk_to_x25519(ed_pub: &[u8; 32]) -> Result<[u8; 32], CryptoError> {
211 let point = compressed
294 let point = compressed
212 .decompress()
295 .decompress()
213 .ok_or(CryptoError::InvalidPublicKey)?;
296 .ok_or(CryptoError::InvalidPublicKey)?;
214− 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)
215}
305}
216
306
217/// libsodium `crypto_sign_ed25519_sk_to_curve25519`: clamp(SHA-512(seed)[..32]).
307/// libsodium `crypto_sign_ed25519_sk_to_curve25519`: clamp(SHA-512(seed)[..32]).
@@ -242,7 +332,7 @@ fn x25519(scalar: &[u8; 32], point: &[u8; 32]) -> Result<Zeroizing<[u8; 32]>, Cr
242 Ok(shared)
332 Ok(shared)
243}
333}
244
334
245−/// 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).
246fn derive_key(
336fn derive_key(
247 raw_secret: &[u8; 32],
337 raw_secret: &[u8; 32],
248 eph_pub: &[u8; 32],
338 eph_pub: &[u8; 32],
@@ -271,7 +361,15 @@ fn compress(plaintext: &[u8]) -> Vec<u8> {
271/// *would* overrun is detected rather than silently truncated.
361/// *would* overrun is detected rather than silently truncated.
272fn decompress(data: &[u8]) -> Result<Vec<u8>, CryptoError> {
362fn decompress(data: &[u8]) -> Result<Vec<u8>, CryptoError> {
273 let mut decoder = zstd::stream::Decoder::new(data).map_err(|_| CryptoError::Decompress)?;
363 let mut decoder = zstd::stream::Decoder::new(data).map_err(|_| CryptoError::Decompress)?;
364+ // Cap the decoder's window buffer so a crafted frame declaring a huge
365+ // windowLog (up to 27 = 128 MiB by default) cannot force a matching
366+ // allocation before the output-size cap even applies. 2^21 = 2 MiB is
367+ // comfortably above `MAX_PLAINTEXT_LEN` (1 MiB), so every legitimate frame
368+ // still decodes; frames declaring a larger window are rejected.
369+ decoder
370+ .window_log_max(21)
371+ .map_err(|_| CryptoError::Decompress)?;
274− let mut out = Vec::new();
372+ let mut out = Vec::with_capacity(4096);
275 (&mut decoder)
373 (&mut decoder)
276 .take(MAX_PLAINTEXT_LEN as u64 + 1)
374 .take(MAX_PLAINTEXT_LEN as u64 + 1)
277 .read_to_end(&mut out)
375 .read_to_end(&mut out)
@@ -290,6 +388,12 @@ pub fn encrypt(
290 recipient_ed_pub: &[u8; 32],
388 recipient_ed_pub: &[u8; 32],
291 plaintext: &[u8],
389 plaintext: &[u8],
292) -> Result<Vec<u8>, CryptoError> {
390) -> 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+ }
293 let sender_pub = sender.public_key();
397 let sender_pub = sender.public_key();
294
398
295 // Ephemeral raw X25519 keypair (libsodium crypto_box_keypair).
399 // Ephemeral raw X25519 keypair (libsodium crypto_box_keypair).
@@ -319,7 +423,8 @@ pub fn encrypt(
319
423
320 // Sign recipient_pub || compressed_len_bytes || compressed || padding
424 // Sign recipient_pub || compressed_len_bytes || compressed || padding
321 // (i.e. recipient_pub || everything after the 96-byte header in inner).
425 // (i.e. recipient_pub || everything after the 96-byte header in inner).
322− 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);
323 signed.extend_from_slice(recipient_ed_pub);
428 signed.extend_from_slice(recipient_ed_pub);
324 signed.extend_from_slice(&compressed_len_bytes);
429 signed.extend_from_slice(&compressed_len_bytes);
325 signed.extend_from_slice(&compressed);
430 signed.extend_from_slice(&compressed);
@@ -343,8 +448,18 @@ pub fn encrypt(
343 let mut nonce = [0u8; NONCE_LEN];
448 let mut nonce = [0u8; NONCE_LEN];
344 rng.fill_bytes(&mut nonce);
449 rng.fill_bytes(&mut nonce);
345 let cipher = XChaCha20Poly1305::new(Key::from_slice(&*key));
450 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+ };
346 let ciphertext = cipher
461 let ciphertext = cipher
347− .encrypt(XNonce::from_slice(&nonce), inner.as_ref())
462+ .encrypt(XNonce::from_slice(&nonce), payload)
348 .map_err(|_| CryptoError::Encrypt)?;
463 .map_err(|_| CryptoError::Encrypt)?;
349 inner.zeroize();
464 inner.zeroize();
350
465
@@ -386,15 +501,22 @@ pub fn decrypt(recipient: &Identity, blob: &[u8]) -> Result<Decrypted, CryptoErr
386 nonce.copy_from_slice(&blob[33..33 + NONCE_LEN]);
501 nonce.copy_from_slice(&blob[33..33 + NONCE_LEN]);
387 let ciphertext = &blob[33 + NONCE_LEN..];
502 let ciphertext = &blob[33 + NONCE_LEN..];
388
503
389− let recip_x_priv = ed25519_sk_to_x25519(&recipient.secret_key);
504+ let recip_x_priv = recipient.x25519_private();
390− let raw_secret = x25519(&recip_x_priv, &eph_pub)?;
505+ let raw_secret = x25519(recip_x_priv, &eph_pub)?;
391− let recip_x_pub = ed25519_pk_to_x25519(&recipient.public_key())?;
506+ let recip_x_pub = recipient.x25519_public();
392− let key = derive_key(&raw_secret, &eph_pub, &recip_x_pub);
507+ let key = derive_key(&raw_secret, &eph_pub, recip_x_pub);
393
508
394 let cipher = XChaCha20Poly1305::new(Key::from_slice(&*key));
509 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 };
395 let mut inner = Zeroizing::new(
517 let mut inner = Zeroizing::new(
396 cipher
518 cipher
397− .decrypt(XNonce::from_slice(&nonce), ciphertext)
519+ .decrypt(XNonce::from_slice(&nonce), payload)
398 .map_err(|_| CryptoError::Decrypt)?,
520 .map_err(|_| CryptoError::Decrypt)?,
399 );
521 );
400
522
@@ -413,7 +535,7 @@ pub fn decrypt(recipient: &Identity, blob: &[u8]) -> Result<Decrypted, CryptoErr
413 inner[98],
535 inner[98],
414 inner[99],
536 inner[99],
415 ]) as usize;
537 ]) as usize;
416− if 100 + compressed_len > inner.len() {
538+ if compressed_len > inner.len().saturating_sub(100) {
417 inner.zeroize();
539 inner.zeroize();
418 return Err(CryptoError::Decrypt);
540 return Err(CryptoError::Decrypt);
419 }
541 }
@@ -423,11 +545,12 @@ pub fn decrypt(recipient: &Identity, blob: &[u8]) -> Result<Decrypted, CryptoErr
423 // (covers compressed_len_bytes || compressed || padding).
545 // (covers compressed_len_bytes || compressed || padding).
424 let verifying = VerifyingKey::from_bytes(&sender_pub).map_err(|_| CryptoError::BadSignature)?;
546 let verifying = VerifyingKey::from_bytes(&sender_pub).map_err(|_| CryptoError::BadSignature)?;
425 let signature = ed25519_dalek::Signature::from_bytes(&sig_bytes);
547 let signature = ed25519_dalek::Signature::from_bytes(&sig_bytes);
426− 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);
427 signed.extend_from_slice(&recipient.public_key());
550 signed.extend_from_slice(&recipient.public_key());
428 signed.extend_from_slice(&inner[96..]);
551 signed.extend_from_slice(&inner[96..]);
429 verifying
552 verifying
430− .verify(&signed, &signature)
553+ .verify_strict(&signed, &signature)
431 .map_err(|_| CryptoError::BadSignature)?;
554 .map_err(|_| CryptoError::BadSignature)?;
432
555
433 let plaintext = decompress(compressed)?;
556 let plaintext = decompress(compressed)?;
@@ -455,12 +578,12 @@ mod tests {
455
578
456 #[test]
579 #[test]
457 fn identity_pub_is_last_32_bytes_and_consistent() {
580 fn identity_pub_is_last_32_bytes_and_consistent() {
458− let id = Identity::generate(&mut rng(1));
581+ let id = Identity::generate(&mut rng(1)).unwrap();
459 assert_eq!(&id.secret_key[32..], &id.public_key());
582 assert_eq!(&id.secret_key[32..], &id.public_key());
460 // Re-deriving from the seed yields the same public key.
583 // Re-deriving from the seed yields the same public key.
461 let mut seed = [0u8; 32];
584 let mut seed = [0u8; 32];
462 seed.copy_from_slice(&id.secret_key[..32]);
585 seed.copy_from_slice(&id.secret_key[..32]);
463− let id2 = Identity::from_seed(&seed);
586+ let id2 = Identity::from_seed(&seed).unwrap();
464 assert_eq!(id.public_key(), id2.public_key());
587 assert_eq!(id.public_key(), id2.public_key());
465 }
588 }
466
589
@@ -468,7 +591,7 @@ mod tests {
468 /// under one identity while claiming another.
591 /// under one identity while claiming another.
469 #[test]
592 #[test]
470 fn from_secret_bytes_rejects_a_seed_pubkey_mismatch() {
593 fn from_secret_bytes_rejects_a_seed_pubkey_mismatch() {
471− let id = Identity::from_seed(&[3u8; 32]);
594+ let id = Identity::from_seed(&[3u8; 32]).unwrap();
472 assert!(Identity::from_secret_bytes(&id.secret_key).is_ok());
595 assert!(Identity::from_secret_bytes(&id.secret_key).is_ok());
473
596
474 let mut tampered = id.secret_key;
597 let mut tampered = id.secret_key;
@@ -481,8 +604,8 @@ mod tests {
481
604
482 #[test]
605 #[test]
483 fn round_trip_recovers_plaintext_and_sender() {
606 fn round_trip_recovers_plaintext_and_sender() {
484− let alice = Identity::generate(&mut rng(10));
607+ let alice = Identity::generate(&mut rng(10)).unwrap();
485− let bob = Identity::generate(&mut rng(20));
608+ let bob = Identity::generate(&mut rng(20)).unwrap();
486 let msg = br#"{"type":"text","id":"abc","body":"hello bob","ts":1700000000000}"#;
609 let msg = br#"{"type":"text","id":"abc","body":"hello bob","ts":1700000000000}"#;
487
610
488 let blob = encrypt(&mut rng(99), &alice, &bob.public_key(), msg).unwrap();
611 let blob = encrypt(&mut rng(99), &alice, &bob.public_key(), msg).unwrap();
@@ -495,17 +618,17 @@ mod tests {
495
618
496 #[test]
619 #[test]
497 fn wrong_recipient_cannot_decrypt() {
620 fn wrong_recipient_cannot_decrypt() {
498− let alice = Identity::generate(&mut rng(1));
621+ let alice = Identity::generate(&mut rng(1)).unwrap();
499− let bob = Identity::generate(&mut rng(2));
622+ let bob = Identity::generate(&mut rng(2)).unwrap();
500− let eve = Identity::generate(&mut rng(3));
623+ let eve = Identity::generate(&mut rng(3)).unwrap();
501 let blob = encrypt(&mut rng(4), &alice, &bob.public_key(), b"secret").unwrap();
624 let blob = encrypt(&mut rng(4), &alice, &bob.public_key(), b"secret").unwrap();
502 assert_eq!(decrypt(&eve, &blob).unwrap_err(), CryptoError::Decrypt);
625 assert_eq!(decrypt(&eve, &blob).unwrap_err(), CryptoError::Decrypt);
503 }
626 }
504
627
505 #[test]
628 #[test]
506 fn tampered_ciphertext_is_rejected() {
629 fn tampered_ciphertext_is_rejected() {
507− let alice = Identity::generate(&mut rng(1));
630+ let alice = Identity::generate(&mut rng(1)).unwrap();
508− let bob = Identity::generate(&mut rng(2));
631+ let bob = Identity::generate(&mut rng(2)).unwrap();
509 let mut blob = encrypt(&mut rng(4), &alice, &bob.public_key(), b"secret").unwrap();
632 let mut blob = encrypt(&mut rng(4), &alice, &bob.public_key(), b"secret").unwrap();
510 let last = blob.len() - 1;
633 let last = blob.len() - 1;
511 blob[last] ^= 0xff;
634 blob[last] ^= 0xff;
@@ -514,8 +637,8 @@ mod tests {
514
637
515 #[test]
638 #[test]
516 fn blob_layout_offsets() {
639 fn blob_layout_offsets() {
517− let alice = Identity::generate(&mut rng(1));
640+ let alice = Identity::generate(&mut rng(1)).unwrap();
518− let bob = Identity::generate(&mut rng(2));
641+ let bob = Identity::generate(&mut rng(2)).unwrap();
519 let blob = encrypt(&mut rng(7), &alice, &bob.public_key(), b"x").unwrap();
642 let blob = encrypt(&mut rng(7), &alice, &bob.public_key(), b"x").unwrap();
520 // Version byte at offset 0.
643 // Version byte at offset 0.
521 assert_eq!(blob[0], WIRE_VERSION);
644 assert_eq!(blob[0], WIRE_VERSION);
@@ -532,7 +655,7 @@ mod tests {
532
655
533 #[test]
656 #[test]
534 fn too_short_blob_errors() {
657 fn too_short_blob_errors() {
535− let bob = Identity::generate(&mut rng(2));
658+ let bob = Identity::generate(&mut rng(2)).unwrap();
536 assert_eq!(
659 assert_eq!(
537 decrypt(&bob, &[0u8; 10]).unwrap_err(),
660 decrypt(&bob, &[0u8; 10]).unwrap_err(),
538 CryptoError::BlobTooShort
661 CryptoError::BlobTooShort
@@ -543,7 +666,7 @@ mod tests {
543 /// scalar mult, an AEAD pass, or a decompression.
666 /// scalar mult, an AEAD pass, or a decompression.
544 #[test]
667 #[test]
545 fn too_long_blob_errors() {
668 fn too_long_blob_errors() {
546− let bob = Identity::generate(&mut rng(2));
669+ let bob = Identity::generate(&mut rng(2)).unwrap();
547 let huge = vec![0u8; MAX_BLOB_LEN + 1];
670 let huge = vec![0u8; MAX_BLOB_LEN + 1];
548 assert_eq!(decrypt(&bob, &huge).unwrap_err(), CryptoError::BlobTooLong);
671 assert_eq!(decrypt(&bob, &huge).unwrap_err(), CryptoError::BlobTooLong);
549 }
672 }
@@ -560,25 +683,27 @@ mod tests {
560 );
683 );
561 assert_eq!(decompress(&bomb).unwrap_err(), CryptoError::Decompress);
684 assert_eq!(decompress(&bomb).unwrap_err(), CryptoError::Decompress);
562
685
563− // ...and the same bomb wrapped in a well-formed, correctly signed blob
686+ // ...and the same bomb sent through encrypt is rejected before
564− // (i.e. from a real peer) dies at the same wall.
687+ // compression/encryption work is done (D28).
565− let alice = Identity::generate(&mut rng(1));
688+ let alice = Identity::generate(&mut rng(1)).unwrap();
566− let bob = Identity::generate(&mut rng(2));
689+ let bob = Identity::generate(&mut rng(2)).unwrap();
690+ assert_eq!(
567− let blob = encrypt(
691+ encrypt(
568− &mut rng(3),
692+ &mut rng(3),
569− &alice,
693+ &alice,
570− &bob.public_key(),
694+ &bob.public_key(),
571− &vec![0u8; MAX_PLAINTEXT_LEN + 1],
695+ &vec![0u8; MAX_PLAINTEXT_LEN + 1],
572− )
696+ )
573− .unwrap();
697+ .unwrap_err(),
574− assert_eq!(decrypt(&bob, &blob).unwrap_err(), CryptoError::Decompress);
698+ CryptoError::Encrypt,
699+ );
575 }
700 }
576
701
577 /// A plaintext right at the cap still round-trips: the bound is inclusive.
702 /// A plaintext right at the cap still round-trips: the bound is inclusive.
578 #[test]
703 #[test]
579 fn a_plaintext_at_the_cap_still_round_trips() {
704 fn a_plaintext_at_the_cap_still_round_trips() {
580− let alice = Identity::generate(&mut rng(1));
705+ let alice = Identity::generate(&mut rng(1)).unwrap();
581− let bob = Identity::generate(&mut rng(2));
706+ let bob = Identity::generate(&mut rng(2)).unwrap();
582 let msg = vec![7u8; MAX_PLAINTEXT_LEN];
707 let msg = vec![7u8; MAX_PLAINTEXT_LEN];
583 let blob = encrypt(&mut rng(3), &alice, &bob.public_key(), &msg).unwrap();
708 let blob = encrypt(&mut rng(3), &alice, &bob.public_key(), &msg).unwrap();
584 assert_eq!(decrypt(&bob, &blob).unwrap().plaintext.as_slice(), msg.as_slice());
709 assert_eq!(decrypt(&bob, &blob).unwrap().plaintext.as_slice(), msg.as_slice());
@@ -588,7 +713,7 @@ mod tests {
588 /// secret, so the sender's "encryption" is forgeable. Reject it.
713 /// secret, so the sender's "encryption" is forgeable. Reject it.
589 #[test]
714 #[test]
590 fn a_low_order_ephemeral_point_is_rejected() {
715 fn a_low_order_ephemeral_point_is_rejected() {
591− let bob = Identity::generate(&mut rng(2));
716+ let bob = Identity::generate(&mut rng(2)).unwrap();
592 // eph_pub = 0 is the canonical low-order point; x25519 with it yields
717 // eph_pub = 0 is the canonical low-order point; x25519 with it yields
593 // an all-zero shared secret for any scalar.
718 // an all-zero shared secret for any scalar.
594 let mut blob = vec![0u8; MIN_BLOB_LEN + 1];
719 let mut blob = vec![0u8; MIN_BLOB_LEN + 1];
@@ -603,8 +728,8 @@ mod tests {
603 /// A short message must be padded so the blob size lands on a bucket boundary.
728 /// A short message must be padded so the blob size lands on a bucket boundary.
604 #[test]
729 #[test]
605 fn blob_is_padded_to_bucket_size() {
730 fn blob_is_padded_to_bucket_size() {
606− let alice = Identity::generate(&mut rng(1));
731+ let alice = Identity::generate(&mut rng(1)).unwrap();
607− let bob = Identity::generate(&mut rng(2));
732+ let bob = Identity::generate(&mut rng(2)).unwrap();
608
733
609 // Short message → should round up to the smallest bucket (256).
734 // Short message → should round up to the smallest bucket (256).
610 let blob_small = encrypt(&mut rng(7), &alice, &bob.public_key(), b"x").unwrap();
735 let blob_small = encrypt(&mut rng(7), &alice, &bob.public_key(), b"x").unwrap();
@@ -629,8 +754,8 @@ mod tests {
629 /// same blob size (same bucket) but different blob contents (random padding).
754 /// same blob size (same bucket) but different blob contents (random padding).
630 #[test]
755 #[test]
631 fn padding_is_random_per_message() {
756 fn padding_is_random_per_message() {
632− let alice = Identity::generate(&mut rng(1));
757+ let alice = Identity::generate(&mut rng(1)).unwrap();
633− let bob = Identity::generate(&mut rng(2));
758+ let bob = Identity::generate(&mut rng(2)).unwrap();
634 let msg = b"hello bob, this is a test message";
759 let msg = b"hello bob, this is a test message";
635
760
636 let blob1 = encrypt(&mut rng(100), &alice, &bob.public_key(), msg).unwrap();
761 let blob1 = encrypt(&mut rng(100), &alice, &bob.public_key(), msg).unwrap();
@@ -651,8 +776,8 @@ mod tests {
651 /// never sees trailing padding bytes.
776 /// never sees trailing padding bytes.
652 #[test]
777 #[test]
653 fn compressed_len_field_is_parsed_correctly() {
778 fn compressed_len_field_is_parsed_correctly() {
654− let alice = Identity::generate(&mut rng(1));
779+ let alice = Identity::generate(&mut rng(1)).unwrap();
655− let bob = Identity::generate(&mut rng(2));
780+ let bob = Identity::generate(&mut rng(2)).unwrap();
656
781
657 // Normal round-trip.
782 // Normal round-trip.
658 let msg = b"round trip test with some content";
783 let msg = b"round trip test with some content";
@@ -674,7 +799,7 @@ mod tests {
674 /// error says so rather than blaming decryption.
799 /// error says so rather than blaming decryption.
675 #[test]
800 #[test]
676 fn encrypting_to_an_off_curve_key_reports_an_invalid_public_key() {
801 fn encrypting_to_an_off_curve_key_reports_an_invalid_public_key() {
677− let alice = Identity::generate(&mut rng(1));
802+ let alice = Identity::generate(&mut rng(1)).unwrap();
678 let not_a_point = [0xabu8; 32];
803 let not_a_point = [0xabu8; 32];
679 assert_eq!(
804 assert_eq!(
680 encrypt(&mut rng(2), &alice, &not_a_point, b"x").unwrap_err(),
805 encrypt(&mut rng(2), &alice, &not_a_point, b"x").unwrap_err(),
@@ -684,7 +809,7 @@ mod tests {
684
809
685 #[test]
810 #[test]
686 fn sk_to_x25519_clamps() {
811 fn sk_to_x25519_clamps() {
687− let id = Identity::generate(&mut rng(5));
812+ let id = Identity::generate(&mut rng(5)).unwrap();
688 let scalar = ed25519_sk_to_x25519(&id.secret_key);
813 let scalar = ed25519_sk_to_x25519(&id.secret_key);
689 assert_eq!(scalar[0] & 7, 0);
814 assert_eq!(scalar[0] & 7, 0);
690 assert_eq!(scalar[31] & 64, 64);
815 assert_eq!(scalar[31] & 64, 64);
@@ -693,7 +818,7 @@ mod tests {
693
818
694 #[test]
819 #[test]
695 fn challenge_signature_is_deterministic_and_host_bound() {
820 fn challenge_signature_is_deterministic_and_host_bound() {
696− let id = Identity::from_seed(&[7u8; 32]);
821+ let id = Identity::from_seed(&[7u8; 32]).unwrap();
697 let a = id.sign_challenge("relay.example.com", "deadbeef").unwrap();
822 let a = id.sign_challenge("relay.example.com", "deadbeef").unwrap();
698 let b = id.sign_challenge("relay.example.com", "deadbeef").unwrap();
823 let b = id.sign_challenge("relay.example.com", "deadbeef").unwrap();
699 let c = id.sign_challenge("other.example.com", "deadbeef").unwrap();
824 let c = id.sign_challenge("other.example.com", "deadbeef").unwrap();
@@ -705,7 +830,7 @@ mod tests {
705 /// We sign whatever a not-yet-trusted relay hands us, so its size is bounded.
830 /// We sign whatever a not-yet-trusted relay hands us, so its size is bounded.
706 #[test]
831 #[test]
707 fn an_oversized_challenge_is_not_signed() {
832 fn an_oversized_challenge_is_not_signed() {
708− let id = Identity::from_seed(&[7u8; 32]);
833+ let id = Identity::from_seed(&[7u8; 32]).unwrap();
709 let huge = "a".repeat(MAX_CHALLENGE_LEN + 1);
834 let huge = "a".repeat(MAX_CHALLENGE_LEN + 1);
710 assert_eq!(
835 assert_eq!(
711 id.sign_challenge("relay.example.com", &huge).unwrap_err(),
836 id.sign_challenge("relay.example.com", &huge).unwrap_err(),
@@ -715,4 +840,55 @@ mod tests {
715 let at_cap = "a".repeat(MAX_CHALLENGE_LEN);
840 let at_cap = "a".repeat(MAX_CHALLENGE_LEN);
716 assert!(id.sign_challenge("relay.example.com", &at_cap).is_ok());
841 assert!(id.sign_challenge("relay.example.com", &at_cap).is_ok());
717 }
842 }
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+ }
718}
894}
core/src/ffi.rs+5
@@ -39,6 +39,11 @@ impl Default for CoreFFI {
39/// `AssertUnwindSafe` is sound here because a poisoned `Bridge` is never read
39/// `AssertUnwindSafe` is sound here because a poisoned `Bridge` is never read
40/// again for correctness: the worst case is a dropped effect batch, and the next
40/// again for correctness: the worst case is a dropped effect batch, and the next
41/// event starts a fresh `update`.
41/// 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.
42fn guard(f: impl FnOnce() -> Vec<u8>) -> Vec<u8> {
47fn guard(f: impl FnOnce() -> Vec<u8>) -> Vec<u8> {
43 catch_unwind(AssertUnwindSafe(f)).unwrap_or_default()
48 catch_unwind(AssertUnwindSafe(f)).unwrap_or_default()
44}
49}
core/src/phonemic.rs+8−3
@@ -96,6 +96,11 @@ fn suffix_index(s: &str) -> Option<u8> {
96/// Keys are always 32 bytes, so no caller is affected — but a display helper
96/// Keys are always 32 bytes, so no caller is affected — but a display helper
97/// must not quietly invent a byte.
97/// must not quietly invent a byte.
98pub fn to_ob(bytes: &[u8]) -> Option<String> {
98pub 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+ }
99 if !bytes.len().is_multiple_of(2) {
104 if !bytes.len().is_multiple_of(2) {
100 return None;
105 return None;
101 }
106 }
@@ -151,7 +156,7 @@ pub fn try_parse_pubkey(input: &str) -> Option<String> {
151 if let Some(hex) = from_ob(trimmed).as_deref().and_then(valid_pubkey_hex) {
156 if let Some(hex) = from_ob(trimmed).as_deref().and_then(valid_pubkey_hex) {
152 return Some(hex);
157 return Some(hex);
153 }
158 }
154− if let Ok(bytes) = hex::decode(trimmed.to_lowercase()) {
159+ if let Ok(bytes) = hex::decode(trimmed) {
155 return valid_pubkey_hex(&bytes);
160 return valid_pubkey_hex(&bytes);
156 }
161 }
157 None
162 None
@@ -207,13 +212,13 @@ mod tests {
207 fn odd_length_input_has_no_spelling() {
212 fn odd_length_input_has_no_spelling() {
208 assert_eq!(to_ob(&[7u8]), None);
213 assert_eq!(to_ob(&[7u8]), None);
209 assert_eq!(to_ob(&[7u8, 0]), Some("hidzod".to_string()));
214 assert_eq!(to_ob(&[7u8, 0]), Some("hidzod".to_string()));
210− assert_eq!(to_ob(&[]), Some(String::new()));
215+ assert_eq!(to_ob(&[]), None);
211 }
216 }
212
217
213 /// A real Ed25519 public key. `try_parse_pubkey` now checks that a key is a
218 /// A real Ed25519 public key. `try_parse_pubkey` now checks that a key is a
214 /// curve point, so tests can't use an arbitrary byte pattern.
219 /// curve point, so tests can't use an arbitrary byte pattern.
215 fn real_key() -> [u8; 32] {
220 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()
217 }
222 }
218
223
219 /// 32 bytes that decode fine but are *not* on the curve — the case that used
224 /// 32 bytes that decode fine but are *not* on the curve — the case that used
core/src/protocol.rs+72−31
@@ -4,6 +4,8 @@
4//! millisecond `ts`. Unknown types are ignored (return `None`).
4//! millisecond `ts`. Unknown types are ignored (return `None`).
5
5
6use serde::{Deserialize, Serialize};
6use serde::{Deserialize, Serialize};
7+use base64::prelude::*;
8+use base64::Engine;
7
9
8/// Caps on the attacker-chosen fields of an incoming payload.
10/// Caps on the attacker-chosen fields of an incoming payload.
9///
11///
@@ -33,8 +35,8 @@ pub enum Payload {
33 ack_ids: Vec<String>,
35 ack_ids: Vec<String>,
34 },
36 },
35 Profile {
37 Profile {
36− display_name: String,
38+ display_name: Option<String>,
37− bio: String,
39+ bio: Option<String>,
38 photo: Option<String>,
40 photo: Option<String>,
39 },
41 },
40}
42}
@@ -75,10 +77,16 @@ pub fn serialize_payload(payload: &Payload, ts: i64) -> String {
75 } => {
77 } => {
76 let mut obj = serde_json::json!({
78 let mut obj = serde_json::json!({
77 "type": "profile",
79 "type": "profile",
78− "display_name": display_name,
79− "bio": bio,
80 "ts": ts,
80 "ts": ts,
81 });
81 });
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+ }
82 // Omit `photo` entirely when there isn't one, rather than sending an
90 // Omit `photo` entirely when there isn't one, rather than sending an
83 // explicit null: "no photo" and "field absent" mean the same thing to
91 // explicit null: "no photo" and "field absent" mean the same thing to
84 // a reader, and the null costs bytes in every broadcast.
92 // a reader, and the null costs bytes in every broadcast.
@@ -91,6 +99,38 @@ pub fn serialize_payload(payload: &Payload, ts: i64) -> String {
91 value.to_string()
99 value.to_string()
92}
100}
93
101
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+
94/// Parse a plaintext payload. Returns `None` for unknown/malformed types so the
134/// Parse a plaintext payload. Returns `None` for unknown/malformed types so the
95/// caller silently ignores them (forward compatibility, PROTOCOL.md §4).
135/// caller silently ignores them (forward compatibility, PROTOCOL.md §4).
96pub fn parse_payload(bytes: &[u8]) -> Option<ParsedPayload> {
136pub fn parse_payload(bytes: &[u8]) -> Option<ParsedPayload> {
@@ -100,7 +140,7 @@ pub fn parse_payload(bytes: &[u8]) -> Option<ParsedPayload> {
100 let payload = match ty {
140 let payload = match ty {
101 "text" => {
141 "text" => {
102 let id = v.get("id")?.as_str()?.to_string();
142 let id = v.get("id")?.as_str()?.to_string();
103− let body = v.get("body")?.as_str()?.to_string();
143+ let body = sanitize_text(v.get("body")?.as_str()?);
104 if id.len() > MAX_ID_LEN || body.len() > MAX_BODY_LEN {
144 if id.len() > MAX_ID_LEN || body.len() > MAX_BODY_LEN {
105 return None;
145 return None;
106 }
146 }
@@ -125,23 +165,24 @@ pub fn parse_payload(bytes: &[u8]) -> Option<ParsedPayload> {
125 Payload::DeliveryAck { ack_ids }
165 Payload::DeliveryAck { ack_ids }
126 }
166 }
127 "profile" => {
167 "profile" => {
168+ // D13: Use Option<String> — None when the field is absent (don't
169+ // change the recipient's cached value), Some(value) when present
170+ // (even if empty, meaning "set to this value").
128− let display_name = v
171+ let display_name = v.get("display_name").and_then(|x| x.as_str()).map(sanitize_text);
129− .get("display_name")
130− .and_then(|x| x.as_str())
131− .unwrap_or("")
132− .to_string();
133− let bio = v
134− .get("bio")
135− .and_then(|x| x.as_str())
172+ let bio = v.get("bio").and_then(|x| x.as_str()).map(sanitize_text);
136− .unwrap_or("")
137− .to_string();
138 let photo = v
173 let photo = v
139 .get("photo")
174 .get("photo")
140 .and_then(|x| x.as_str())
175 .and_then(|x| x.as_str())
141 .map(str::to_string);
176 .map(str::to_string);
142− if display_name.chars().count() > MAX_DISPLAY_NAME_LEN
177+ if display_name.as_ref().is_some_and(|d| d.chars().count() > MAX_DISPLAY_NAME_LEN)
143− || bio.chars().count() > MAX_BIO_LEN
178+ || bio.as_ref().is_some_and(|b| b.chars().count() > MAX_BIO_LEN)
144 || photo.as_ref().is_some_and(|p| p.len() > MAX_PHOTO_LEN)
179 || photo.as_ref().is_some_and(|p| p.len() > MAX_PHOTO_LEN)
180+ || photo.as_ref().is_some_and(|p| {
181+ // Reject a photo that isn't valid base64 — a peer can put
182+ // any string in the field, and the shell attempts to decode
183+ // it as an image on every render.
184+ BASE64_STANDARD.decode(p).is_err()
185+ })
145 {
186 {
146 return None;
187 return None;
147 }
188 }
@@ -187,14 +228,14 @@ mod tests {
187 #[test]
228 #[test]
188 fn profile_round_trip_with_and_without_photo() {
229 fn profile_round_trip_with_and_without_photo() {
189 let with = Payload::Profile {
230 let with = Payload::Profile {
190− display_name: "Alice".into(),
231+ display_name: Some("Alice".into()),
191− bio: "writing".into(),
232+ bio: Some("writing".into()),
192 photo: Some("aGVsbG8=".into()),
233 photo: Some("aGVsbG8=".into()),
193 };
234 };
194 assert_eq!(reparse(&with, 1).payload, with);
235 assert_eq!(reparse(&with, 1).payload, with);
195 let without = Payload::Profile {
236 let without = Payload::Profile {
196− display_name: "Bob".into(),
237+ display_name: Some("Bob".into()),
197− bio: "".into(),
238+ bio: Some("".into()),
198 photo: None,
239 photo: None,
199 };
240 };
200 assert_eq!(reparse(&without, 1).payload, without);
241 assert_eq!(reparse(&without, 1).payload, without);
@@ -234,8 +275,8 @@ mod tests {
234
275
235 let long_bio = serialize_payload(
276 let long_bio = serialize_payload(
236 &Payload::Profile {
277 &Payload::Profile {
237− display_name: "A".into(),
278+ display_name: Some("A".into()),
238− bio: "b".repeat(MAX_BIO_LEN + 1),
279+ bio: Some("b".repeat(MAX_BIO_LEN + 1)),
239 photo: None,
280 photo: None,
240 },
281 },
241 1,
282 1,
@@ -244,8 +285,8 @@ mod tests {
244
285
245 let long_name = serialize_payload(
286 let long_name = serialize_payload(
246 &Payload::Profile {
287 &Payload::Profile {
247− display_name: "n".repeat(MAX_DISPLAY_NAME_LEN + 1),
288+ display_name: Some("n".repeat(MAX_DISPLAY_NAME_LEN + 1)),
248− bio: String::new(),
289+ bio: Some(String::new()),
249 photo: None,
290 photo: None,
250 },
291 },
251 1,
292 1,
@@ -254,8 +295,8 @@ mod tests {
254
295
255 let big_photo = serialize_payload(
296 let big_photo = serialize_payload(
256 &Payload::Profile {
297 &Payload::Profile {
257− display_name: String::new(),
298+ display_name: Some(String::new()),
258− bio: String::new(),
299+ bio: Some(String::new()),
259 photo: Some("p".repeat(MAX_PHOTO_LEN + 1)),
300 photo: Some("p".repeat(MAX_PHOTO_LEN + 1)),
260 },
301 },
261 1,
302 1,
@@ -289,8 +330,8 @@ mod tests {
289 fn a_photoless_profile_omits_the_field() {
330 fn a_photoless_profile_omits_the_field() {
290 let json = serialize_payload(
331 let json = serialize_payload(
291 &Payload::Profile {
332 &Payload::Profile {
292− display_name: "A".into(),
333+ display_name: Some("A".into()),
293− bio: "B".into(),
334+ bio: Some("B".into()),
294 photo: None,
335 photo: None,
295 },
336 },
296 1,
337 1,
@@ -301,8 +342,8 @@ mod tests {
301 #[test]
342 #[test]
302 fn snake_case_field_names_on_wire() {
343 fn snake_case_field_names_on_wire() {
303 let p = Payload::Profile {
344 let p = Payload::Profile {
304− display_name: "A".into(),
345+ display_name: Some("A".into()),
305− bio: "B".into(),
346+ bio: Some("B".into()),
306 photo: None,
347 photo: None,
307 };
348 };
308 let json = serialize_payload(&p, 1);
349 let json = serialize_payload(&p, 1);
install.sh+13−9
@@ -154,18 +154,21 @@ fetch_to "$DOWNLOAD_URL" "$TMP_BIN"
154# HTTPS, but that authenticates GitHub's CDN, not the artifact — a compromised or
154# HTTPS, but that authenticates GitHub's CDN, not the artifact — a compromised or
155# swapped release asset would otherwise be installed and launched unquestioned.
155# swapped release asset would otherwise be installed and launched unquestioned.
156#
156#
157−# The check is best-effort by necessity: releases cut before CI began publishing
157+# N15: Verification is mandatory. A missing checksum, a missing sha256sum tool,
158−# .sha256 files have no checksum to compare against, and refusing to install from
158+# or a mismatch are all fatal — the binary is refused.
159−# them would break upgrades from every existing deployment. So a *missing*
159+# N15: Checksum verification is mandatory for all installs. A missing
160−# checksum warns and proceeds, while a checksum that is present and does *not*
160+# checksum file indicates either a pre-checksum release or a supply-chain
161−# match is fatal — the case that actually indicates tampering.
161+# attack that removed the checksum. We refuse to install without verification
162+# rather than silently proceeding.
162if fetch_to "$CHECKSUM_URL" "$TMP_SUM" 2>/dev/null; then
163if fetch_to "$CHECKSUM_URL" "$TMP_SUM" 2>/dev/null; then
163 EXPECTED=$(cut -d' ' -f1 < "$TMP_SUM")
164 EXPECTED=$(cut -d' ' -f1 < "$TMP_SUM")
164 ACTUAL=$(sha256_of "$TMP_BIN")
165 ACTUAL=$(sha256_of "$TMP_BIN")
165 if [ -z "$ACTUAL" ]; then
166 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
167 elif [ -z "$EXPECTED" ]; then
169 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
169 elif [ "$EXPECTED" != "$ACTUAL" ]; then
172 elif [ "$EXPECTED" != "$ACTUAL" ]; then
170 echo "error: checksum verification FAILED for ${ASSET}" >&2
173 echo "error: checksum verification FAILED for ${ASSET}" >&2
171 echo " expected: ${EXPECTED}" >&2
174 echo " expected: ${EXPECTED}" >&2
@@ -176,8 +179,9 @@ if fetch_to "$CHECKSUM_URL" "$TMP_SUM" 2>/dev/null; then
176 echo "Checksum verified (${ACTUAL})"
179 echo "Checksum verified (${ACTUAL})"
177 fi
180 fi
178else
181else
179− echo "warning: no published checksum at ${CHECKSUM_URL}; skipping verification" >&2
182+ echo "error: no published checksum at ${CHECKSUM_URL}; refusing to install unverified binary" >&2
180− echo " (releases predating checksum publication have none)" >&2
183+ echo " (releases predating checksum publication have none — install from a recent release)" >&2
184+ exit 1
181fi
185fi
182
186
183chmod 755 "$TMP_BIN"
187chmod 755 "$TMP_BIN"
server.knot+261−86
@@ -87,12 +87,29 @@ maxBatchSize = 100
87-- Cap on gossip events accepted or emitted in a single federation exchange.
87-- Cap on gossip events accepted or emitted in a single federation exchange.
88maxGossipEvents : Int
88maxGossipEvents : Int
89maxGossipEvents = 100
89maxGossipEvents = 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
90-- Cap on events returned by one long-poll response.
99-- Cap on events returned by one long-poll response.
91maxPollEvents : Int
100maxPollEvents : Int
92maxPollEvents = 50
101maxPollEvents = 50
93-- Per-peer ceiling on outstanding forward attempts.
102-- Per-peer ceiling on outstanding forward attempts.
94maxForwardsPerServer : Int
103maxForwardsPerServer : Int
95maxForwardsPerServer = 2000
104maxForwardsPerServer = 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
96-- Global ceiling on the `*forwards` outbox. `maxForwardsPerServer` bounds any one
113-- Global ceiling on the `*forwards` outbox. `maxForwardsPerServer` bounds any one
97-- peer, but a recipient gossiped as present on many servers fans one blob out to
114-- peer, but a recipient gossiped as present on many servers fans one blob out to
98-- all of them, so the total is bounded only by (peers x per-peer cap). This caps
115-- all of them, so the total is bounded only by (peers x per-peer cap). This caps
@@ -180,6 +197,15 @@ maxPresencePerServer = 5000
180-- the same argument for sessions on `maxSessions`, and PROTOCOL.md §10.
197-- the same argument for sessions on `maxSessions`, and PROTOCOL.md §10.
181maxMessagesPerRecipient : Int
198maxMessagesPerRecipient : Int
182maxMessagesPerRecipient = 10000
199maxMessagesPerRecipient = 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
183-- Validity window for an auth challenge before it must be re-issued (1 min).
209-- Validity window for an auth challenge before it must be re-issued (1 min).
184challengeExpiry : Int<Ms>
210challengeExpiry : Int<Ms>
185challengeExpiry = 60000
211challengeExpiry = 60000
@@ -518,8 +544,15 @@ capPerServer = \items server maxN -> union (filter (\x -> not (x.server == serve
518-- no timestamp (the same reason `retryPendingForwards` has to sort on the blob).
544-- no timestamp (the same reason `retryPendingForwards` has to sort on the blob).
519-- Applied by both writers that grow `*forwards` (`forwardQueuedToServer` and
545-- Applied by both writers that grow `*forwards` (`forwardQueuedToServer` and
520-- `handleSendMessages`); the retry sweep only ever shrinks it.
546-- `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.
521capForwards : [ForwardEntry] -> [ForwardEntry]
554capForwards : [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)
523
556
524-- Random hex token: one random hex char per recursive call. `n` is the
557-- Random hex token: one random hex char per recursive call. `n` is the
525-- desired length in characters (so n=48 yields 24 random bytes of entropy).
558-- desired length in characters (so n=48 yields 24 random bytes of entropy).
@@ -744,7 +777,7 @@ isBadServerName : Text -> Bool
744isBadServerName = \raw ->
777isBadServerName = \raw ->
745 let sn = stripTrailingDot raw in
778 let sn = stripTrailingDot raw in
746 let host = beforeColon sn in
779 let host = beforeColon sn in
747− sn == "" || sn == "localhost" || hasPrefix "localhost:" sn || isNumericHost host || isReservedOctetPrefix host || isReservedIPv4 host || hasLeadingZeroOctet host || hasPrefix "0x" sn || any (\c -> not contains c "0123456789abcdefghijklmnopqrstuvwxyz-.:") (chars sn) || countChar ":" sn > 1 || any (\p -> hasPrefix p sn) [".", ":", "192.0.2.", "203.0.113."] || any (\s -> hasSuffix s sn) [".localhost", ".local", ".internal", ".arpa", ".onion"]
780+ sn == "" || sn == "localhost" || hasPrefix "localhost:" sn || isNumericHost host || isReservedOctetPrefix host || isReservedIPv4 host || hasLeadingZeroOctet host || hasPrefix "0x" sn || any (\c -> not contains c "0123456789abcdefghijklmnopqrstuvwxyz-.:") (chars sn) || countChar ":" sn > 1 || any (\p -> hasPrefix p sn) [".", ":", "192.0.2.", "198.51.100.", "203.0.113."] || any (\s -> hasSuffix s sn) [".localhost", ".local", ".internal", ".arpa", ".onion"]
748
781
749-- ============================================
782-- ============================================
750-- Auth helpers
783-- Auth helpers
@@ -916,6 +949,13 @@ currentSeq = \rows -> fold (\acc r -> max acc r.value) (0 : Timestamp) rows
916-- and the fresh `seqTs` below would otherwise make `union` keep both rows and
949-- and the fresh `seqTs` below would otherwise make `union` keep both rows and
917-- double-deliver. Re-reading `*messages` per call also collapses duplicate blobs
950-- double-deliver. Re-reading `*messages` per call also collapses duplicate blobs
918-- appearing twice within one batch.
951-- 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.
919appendMessage : PubkeyHex -> BlobHex -> Int<Ms> -> IO {rw *messages, rw *seqCounter} {}
959appendMessage : PubkeyHex -> BlobHex -> Int<Ms> -> IO {rw *messages, rw *seqCounter} {}
920appendMessage = \toKey encryptedBlob t -> do
960appendMessage = \toKey encryptedBlob t -> do
921 msgs <- *messages
961 msgs <- *messages
@@ -927,14 +967,32 @@ appendMessage = \toKey encryptedBlob t -> do
927 -- for this recipient so the post-append count lands exactly on the ceiling.
967 -- for this recipient so the post-append count lands exactly on the ceiling.
928 -- `- 1` reserves the slot the new row is about to take.
968 -- `- 1` reserves the slot the new row is about to take.
929 --
969 --
930− -- Eviction keys on `encryptedBlob` rather than the row itself because
970+ -- S4: Instead of building an `evicted` list and checking membership (which
931− -- (toKey, encryptedBlob) is unique by this function's own dedup invariant
971+ -- is O(|messages| × |evicted|)), compute the eviction threshold — the
932− -- (the `when` guard above), so a blob identifies at most one row per key.
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.
933 let forRecipient = filter (\m -> m.toKey == toKey) msgs
975 let forRecipient = filter (\m -> m.toKey == toKey) msgs
934 let excess = max 0 (count forRecipient - (maxMessagesPerRecipient - 1))
976 let excess = max 0 (count forRecipient - (maxMessagesPerRecipient - 1))
935− let evicted = map (\m -> m.encryptedBlob) (take excess (sortBy (\m -> m.receivedAt) forRecipient))
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
936− let kept = filter (\m -> not (m.toKey == toKey && elem m.encryptedBlob evicted)) msgs
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
937− *messages = union kept [{toKey, encryptedBlob, receivedAt: seqTs, deliveredTo: []}])
995+ *messages = union globallyCapped [{toKey, encryptedBlob, receivedAt: seqTs, deliveredTo: []}])
938
996
939-- Record that `server` has accepted a federated forward of this message, so a
997-- Record that `server` has accepted a federated forward of this message, so a
940-- later presence re-announce of `toKey` doesn't re-forward the same blob to a peer
998-- later presence re-announce of `toKey` doesn't re-forward the same blob to a peer
@@ -988,6 +1046,12 @@ pruneExpired = \t -> atomic do
988 -- Prune forwards whose message was removed by the TTL sweep above.
1046 -- Prune forwards whose message was removed by the TTL sweep above.
989 -- ForwardEntry carries no timestamp, so match against the surviving
1047 -- ForwardEntry carries no timestamp, so match against the surviving
990 -- messages (the post-sweep list, not the pre-sweep snapshot).
1048 -- 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.
991 fw <- *forwards
1055 fw <- *forwards
992 *forwards = filter (\f -> any (\m -> m.toKey == f.toKey && m.encryptedBlob == f.encryptedBlob) surviving) fw
1056 *forwards = filter (\f -> any (\m -> m.toKey == f.toKey && m.encryptedBlob == f.encryptedBlob) surviving) fw
993 ff <- *forwardFailures
1057 ff <- *forwardFailures
@@ -1050,56 +1114,70 @@ sendBatchedGossip = \peers events -> case localServerName of
1050-- comes online: any messages already buffered for that pubkey are enqueued
1114-- comes online: any messages already buffered for that pubkey are enqueued
1051-- as forwards to the newly-known server.
1115-- as forwards to the newly-known server.
1052
1116
1053−-- Attempt every forward in `batch` to one peer, in order, and report whether any
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
1054−-- attempt was a genuine peer failure. Drops successful entries from `*forwards` and
1119+-- genuinely succeeded (`anySucceeded`). Drops successful entries from `*forwards`
1055−-- records the peer on the message's `deliveredTo`; on failure bumps the retry count
1120+-- and records the peer on the message's `deliveredTo`; every attempt (success,
1056−-- and gives up after `maxForwardRetries`.
1121+-- failure, or 404) bumps the retry count and gives up after `maxForwardRetries`.
1057--
1122--
1058-- INVARIANT — this must not touch `*forwardFailures`. The caller records at most one
1123-- 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
1124+-- failure per peer per sweep, from the `anyFailed` flag returned here; recording per
1060−-- *message* would let one unreachable peer with N buffered messages take N backoff
1125+-- failing *message* would let one unreachable peer with N buffered messages take N
1061−-- steps in a single sweep and pin itself at `retryBackoffMax` for `forwardFailureMaxAge`.
1126+-- backoff steps in a single sweep and pin itself at `retryBackoffMax` for
1062−-- The counter counts consecutive failed *sweeps*.
1127+-- `forwardFailureMaxAge`. The counter counts consecutive failed *sweeps*.
1063--
1128--
1064-- A 404 is NOT a peer failure. `handleForwardMessage` answers 404 `no_presence`
1129-- A 404 is NOT a peer failure. `handleForwardMessage` answers 404 `no_presence`
1065-- whenever the recipient is not *currently* online at that peer, which is an
1130-- whenever the recipient is not *currently* online at that peer, which is an
1066-- expected, retryable outcome (PROTOCOL.md §7) — the peer is healthy and answering.
1131-- 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
1132+-- `fetch` returns `Err` for every status >= 400, so treating any `Err` as a failure
1068−-- failure would feed a routine 404 into `recordFailure`, and `*forwardFailures` is
1133+-- would feed a routine 404 into `recordFailure`, and `*forwardFailures` is shared
1069−-- shared with gossip: one stale-presence 404 would suppress all forwards *and* all
1134+-- with gossip: one stale-presence 404 would suppress all forwards *and* all gossip to
1070−-- gossip to that peer for a full backoff step (30 s–8 min). So a 404 leaves the peer
1135+-- that peer for a full backoff step (30 s–8 min). So a 404 leaves the peer's backoff
1071−-- unpenalized and the forward row untouched — no retry bump either, since the row is
1136+-- untouched (`anyFailed` stays false for it) and never clears it (`anySucceeded`
1072−-- waiting on the recipient's presence, not on a delivery that went wrong.
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.
1073--
1145--
1074-- Recursive rather than `forEach` because the result has to be accumulated across
1146-- Recursive rather than `forEach` because the result has to be accumulated across
1075-- iterations; `sortBy` fixes the iteration order that `head`/`drop 1` then walk.
1147-- 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}
1077retryForwardsTo = \server batch -> case head batch of
1149retryForwardsTo = \server batch -> case head batch of
1078− Nothing {} -> yield (False {})
1150+ Nothing {} -> yield {anyFailed: False {}, anySucceeded: False {}}
1079 Just {value: f} -> do
1151 Just {value: f} -> do
1080 result <- fetch (federationUrl server) (ForwardMessage {f.toKey, f.encryptedBlob})
1152 result <- fetch (federationUrl server) (ForwardMessage {f.toKey, f.encryptedBlob})
1081 let status = case result of
1153 let status = case result of
1082 Err {error: e} -> e.status
1154 Err {error: e} -> e.status
1083 Ok _ -> 200
1155 Ok _ -> 200
1084 let success = status < 400
1156 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).
1086 let deferred = status == 404
1159 let deferred = status == 404
1087 let isMatch = forwardMatches f.toKey f.encryptedBlob server
1160 let isMatch = forwardMatches f.toKey f.encryptedBlob server
1088 let isDone = \x -> isMatch x && (success || x.retries + 1 >= maxForwardRetries)
1161 let isDone = \x -> isMatch x && (success || x.retries + 1 >= maxForwardRetries)
1089 atomic (do
1162 atomic (do
1090− when (not deferred) (do
1091− cur <- *forwards
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.
1092− *forwards = map (\x -> if isMatch x
1168+ *forwards = map (\x -> if isMatch x
1093− then {x.toServer, x.toKey, x.encryptedBlob, retries: x.retries + 1}
1169+ then {x.toServer, x.toKey, x.encryptedBlob, retries: x.retries + 1}
1094− else x) (filter (\x -> not isDone x) cur))
1170+ else x) (filter (\x -> not isDone x) cur)
1095 when success (markDelivered f.toKey f.encryptedBlob server))
1171 when success (markDelivered f.toKey f.encryptedBlob server))
1096− restFailed <- retryForwardsTo server (drop 1 batch)
1172+ rest <- retryForwardsTo server (drop 1 batch)
1097− yield ((not success && not deferred) || restFailed)
1173+ yield {anyFailed: (not success && not deferred) || rest.anyFailed, anySucceeded: success || rest.anySucceeded}
1098
1174
1099-- Walk pending forwards grouped by destination and retry each peer's backlog (up
1175-- Walk pending forwards grouped by destination and retry each peer's backlog (up
1100-- to maxBatchSize per peer) via `/federation/forward`. One failure record per peer
1176-- 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`.
1103retryPendingForwards : IO {rw *forwardFailures, rw *forwards, rw *messages, network, clock} {}
1181retryPendingForwards : IO {rw *forwardFailures, rw *forwards, rw *messages, network, clock} {}
1104retryPendingForwards = do
1182retryPendingForwards = do
1105 t <- now
1183 t <- now
@@ -1121,13 +1199,17 @@ retryPendingForwards = do
1121 -- `maxForwardRetries` (dropped). `ForwardEntry` carries no timestamp, so key on
1199 -- `maxForwardRetries` (dropped). `ForwardEntry` carries no timestamp, so key on
1122 -- the unique blob.
1200 -- the unique blob.
1123 let batch = take maxBatchSize (sortBy (\f -> f.encryptedBlob) (filter (\f -> f.toServer == server) fwds))
1201 let batch = take maxBatchSize (sortBy (\f -> f.encryptedBlob) (filter (\f -> f.toServer == server) fwds))
1124− anyFailed <- retryForwardsTo server batch
1202+ result <- retryForwardsTo server batch
1125− -- `count batch > 0` guards the clear: `maxBatchSize` is startup-overridable,
1203+ -- Clear the peer's backoff only on a sweep with at least one genuine success
1126− -- and at 0 every batch is empty — an empty sweep must not be read as a
1204+ -- (S1). An all-404 sweep (recipient not online at the peer) is neither a
1127− -- success and reset a failing peer's backoff.
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.
1128− if anyFailed
1210+ if result.anyFailed
1129 then recordFailure server t
1211 then recordFailure server t
1130− else when (count batch > 0) (clearFailure server t)))
1212+ else when (count batch > 0 && result.anySucceeded) (clearFailure server t)))
1131
1213
1132-- Forward queued messages to a server when a remote key comes online.
1214-- Forward queued messages to a server when a remote key comes online.
1133-- Skips messages already delivered to `server` (tracked via Message.deliveredTo)
1215-- Skips messages already delivered to `server` (tracked via Message.deliveredTo)
@@ -1150,6 +1232,32 @@ forwardQueuedToServer = \pubkey serverName -> atomic (do
1150 let newFwds = map (\m -> {toServer: server, m.toKey, m.encryptedBlob, retries: 0}) (take budget (sortBy (\m -> m.receivedAt) candidates))
1232 let newFwds = map (\m -> {toServer: server, m.toKey, m.encryptedBlob, retries: 0}) (take budget (sortBy (\m -> m.receivedAt) candidates))
1151 *forwards = capForwards (union fwds newFwds))
1233 *forwards = capForwards (union fwds newFwds))
1152
1234
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+
1153-- ============================================
1261-- ============================================
1154-- Handler: challenge / verify
1262-- Handler: challenge / verify
1155-- ============================================
1263-- ============================================
@@ -1199,47 +1307,83 @@ handleVerifyAuth = \pubkey challenge signature revokeOthers clientIp -> do
1199 -- The client signs (target host, challenge) under a domain-separation tag so a
1307 -- The client signs (target host, challenge) under a domain-separation tag so a
1200 -- relay server cannot replay the signature to a different server. We verify
1308 -- relay server cannot replay the signature to a different server. We verify
1201 -- against our own configured hostname (port stripped), which a relay cannot forge.
1309 -- against our own configured hostname (port stripped), which a relay cannot forge.
1202− -- Computed outside the atomic block: `verify` is not a DB effect, and computing
1310+ --
1203− -- it here avoids re-running crypto on every STM retry.
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.
1204 let signedText = "skrepka-auth-v1:" ++ authHost serverHost ++ ":" ++ challenge
1320 let signedText = "skrepka-auth-v1:" ++ authHost serverHost ++ ":" ++ challenge
1205− let sigValid = case bytesFromHex pubkey of
1206− Just {value: pk} -> case bytesFromHex signature of
1207− Just {value: sigBytes} -> verify pk (textToBytes signedText) sigBytes
1208− _ -> False {}
1209− _ -> False {}
1210− result <- atomic (do
1321+ challengeOk <- atomic (do
1211 ch <- *challenges
1322 ch <- *challenges
1212 let same = \c -> c.pubkey == pubkey && c.challenge == challenge
1323 let same = \c -> c.pubkey == pubkey && c.challenge == challenge
1324+ case findFirst ch same of
1325+ Nothing {} -> yield (False {})
1213− let valid = any (\c -> same c && c.expiresAt > t && matchIp clientIp c.ip) ch && sigValid
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 {}
1214− if not valid
1337+ if not sigValid
1215− then yield (Nothing {})
1338+ then yield (Err {error: {status: 401, message: "invalid"}})
1216 else do
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
1217− *challenges = filter (\c -> not same c) ch
1359+ else filter (\x -> not same x) ch
1218− ses <- *sessions
1360+ ses <- *sessions
1219− let pruned = if revokeOthers
1361+ let pruned = if revokeOthers
1220− then filter (\s -> s.pubkey != pubkey) ses
1362+ then filter (\s -> s.pubkey != pubkey) ses
1221− else capPerKey ses pubkey maxSessionsPerKey
1363+ else capPerKey ses pubkey maxSessionsPerKey
1222− let newSession = {token, pubkey, expiresAt: expiry, ip: clientIp}
1364+ let newSession = {token, pubkey, expiresAt: expiry, ip: clientIp}
1223− -- LRU-evicts the soonest-to-expire session when the pool is full; this global
1365+ -- LRU-evicts the soonest-to-expire session when the pool is full; this global
1224− -- eviction is Sybil-floodable, see the LIMITATION note on `maxSessions`.
1366+ -- eviction is Sybil-floodable, see the LIMITATION note on `maxSessions`.
1225− let pooled = union pruned [newSession]
1367+ let pooled = union pruned [newSession]
1226− -- Count what `capList` is about to shed, so the handler can warn about it
1368+ -- Count what `capList` is about to shed, so the handler can warn about it
1227− -- below. That flood has no other observable signature: evicted users just
1369+ -- below. That flood has no other observable signature: evicted users just
1228− -- start seeing 401s and silently re-authenticate, which looks like normal
1370+ -- start seeing 401s and silently re-authenticate, which looks like normal
1229− -- traffic in the access log. A sustained nonzero rate here is the alert.
1371+ -- traffic in the access log. A sustained nonzero rate here is the alert.
1230− let evicted = max 0 (count pooled - maxSessions)
1372+ let evicted = max 0 (count pooled - maxSessions)
1231− *sessions = capList pooled maxSessions
1373+ *sessions = capList pooled maxSessions
1232− peers <- refreshLocalPresence pubkey expiry
1374+ peers <- refreshLocalPresence pubkey expiry
1233− yield (Just {value: {peers, evicted}}))
1375+ yield (Just {value: {peers, evicted}}))
1234− case result of
1376+ case result of
1235− Nothing {} -> yield (Err {error: {status: 401, message: "invalid"}})
1377+ Nothing {} -> yield (Err {error: {status: 401, message: "invalid"}})
1236− Just {value: r} -> do
1378+ Just {value: r} -> do
1237− -- Warn outside the `atomic` block, never inside: an STM transaction can
1379+ -- Warn outside the `atomic` block, never inside: an STM transaction can
1238− -- retry, and a console write is not rolled back with it — logging inside
1380+ -- retry, and a console write is not rolled back with it — logging inside
1239− -- would emit one line per retry attempt rather than one per eviction.
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.
1240− when (r.evicted > 0) (logWarn ("session pool full: evicted " ++ show r.evicted ++ " session(s) to seat a new login (maxSessions=" ++ show maxSessions ++ ")"))
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 ++ ")"))
1241− when federationEnabled (fork (sendBatchedGossip r.peers [{eventType: "online", pubkey: Just {value: pubkey}}]))
1385+ when federationEnabled (fork (sendBatchedGossip r.peers [{eventType: "online", pubkey: Just {value: pubkey}}]))
1242− yield (Ok {value: {token, expiresAt: expiry}})
1386+ yield (Ok {value: {token, expiresAt: expiry}})
1243
1387
1244-- ============================================
1388-- ============================================
1245-- Handler: poll
1389-- Handler: poll
@@ -1278,11 +1422,16 @@ handlePoll = \cursor authorization clientIp -> do
1278 -- so peers don't time us out mid-session.
1422 -- so peers don't time us out mid-session.
1279 let stale = not any (\p -> p.pubkey == pubkey && isLocalServer p.server && p.expiresAt - t >= sessionExpiry / 2) pres
1423 let stale = not any (\p -> p.pubkey == pubkey && isLocalServer p.server && p.expiresAt - t >= sessionExpiry / 2) pres
1280 refreshPeers <- if stale then refreshLocalPresence pubkey (t + sessionExpiry) else yield []
1424 refreshPeers <- if stale then refreshLocalPresence pubkey (t + sessionExpiry) else yield []
1425+ -- S3: Track whether we actually refreshed so the gossip line below
1426+ -- only fires on a refresh, not on every poll. Without this, every
1427+ -- poll gossips to all seed peers via gossipTargets [], creating
1428+ -- O(clients × seeds) federation traffic per poll cycle.
1281− yield (Just {value: {pubkey, clampedCursor, refreshPeers}}))
1429+ yield (Just {value: {pubkey, clampedCursor, refreshPeers, didRefresh: stale}}))
1282 case setup of
1430 case setup of
1283 Nothing {} -> yield (Err {error: {status: 401, message: "unauthorized"}})
1431 Nothing {} -> yield (Err {error: {status: 401, message: "unauthorized"}})
1284 Just {value: s} -> do
1432 Just {value: s} -> do
1433+ -- S3: Only gossip when we actually refreshed presence — not on every poll.
1285− when federationEnabled (fork (sendBatchedGossip s.refreshPeers [{eventType: "online", pubkey: Just {value: s.pubkey}}]))
1434+ when (federationEnabled && s.didRefresh) (fork (sendBatchedGossip s.refreshPeers [{eventType: "online", pubkey: Just {value: s.pubkey}}]))
1286 pollResult <- doPoll s t
1435 pollResult <- doPoll s t
1287 yield (Ok {value: pollResult})
1436 yield (Ok {value: pollResult})
1288
1437
@@ -1393,10 +1542,15 @@ handleSendMessages = \messages authorization clientIp -> do
1393 Nothing {} -> do
1542 Nothing {} -> do
1394 committed <- atomic (do
1543 committed <- atomic (do
1395 pres <- *presence
1544 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.
1396 let staged = map (\m -> {
1550 let staged = map (\m -> {
1397 m.to,
1551 m.to,
1398 m.encryptedBlob,
1552 m.encryptedBlob,
1399− 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))
1400 }) messages
1554 }) messages
1401 forEach staged (\c -> do
1555 forEach staged (\c -> do
1402 -- Read `*messages` *before* the append: `delivered` below must see the
1556 -- Read `*messages` *before* the append: `delivered` below must see the
@@ -1501,7 +1655,7 @@ forkForwardsTo = \to encryptedBlob servers -> forEach servers (\server -> when (
1501-- fromServer) row touched by this batch, then re-adds fresh-TTL rows for
1655-- fromServer) row touched by this batch, then re-adds fresh-TTL rows for
1502-- pubkeys with an `online` event (so Online wins on collision). For keys
1656-- pubkeys with an `online` event (so Online wins on collision). For keys
1503-- that transition offline → online, flushes any locally-buffered messages via
1657-- that transition offline → online, flushes any locally-buffered messages via
1504−-- `forwardQueuedToServer`. Events with an unrecognized `eventType` or no
1658+-- `forwardQueuedToAll`. Events with an unrecognized `eventType` or no
1505-- `pubkey` are skipped (forward-compat) rather than rejecting the batch.
1659-- `pubkey` are skipped (forward-compat) rather than rejecting the batch.
1506--
1660--
1507-- SECURITY: this is the gossip-redirect ingress described in the section header
1661-- SECURITY: this is the gossip-redirect ingress described in the section header
@@ -1512,7 +1666,7 @@ handleRecvGossip : [GossipEvent] -> ServerName -> IO {r *messages, rw *forwards,
1512handleRecvGossip = \events fromServer -> do
1666handleRecvGossip = \events fromServer -> do
1513 t <- now
1667 t <- now
1514 -- Widen the peer-supplied `ServerName` once (see `type ServerName`);
1668 -- Widen the peer-supplied `ServerName` once (see `type ServerName`);
1515− -- `forwardQueuedToServer` below still takes the refined name.
1669+ -- `forwardQueuedToAll` below still takes the refined name.
1516 let from = (fromServer : Text)
1670 let from = (fromServer : Text)
1517 if not federationEnabled
1671 if not federationEnabled
1518 then yield (Err {error: {status: 403, message: "federation_disabled"}})
1672 then yield (Err {error: {status: 403, message: "federation_disabled"}})
@@ -1535,12 +1689,13 @@ handleRecvGossip = \events fromServer -> do
1535 Ok {value: pk} -> if e.eventType == "online"
1689 Ok {value: pk} -> if e.eventType == "online"
1536 then {
1690 then {
1537 touched: union acc.touched [pk],
1691 touched: union acc.touched [pk],
1538− 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
1539 }
1694 }
1540 else if e.eventType == "offline"
1695 else if e.eventType == "offline"
1541− then {acc.added, touched: union acc.touched [pk]}
1696+ then {acc.added, touched: union acc.touched [pk], offline: union acc.offline [pk]}
1542 else acc
1697 else acc
1543− Nothing {} -> acc) {touched: ([] : [PubkeyHex]), added: ([] : [PresenceEntry])} events
1698+ Nothing {} -> acc) {touched: ([] : [PubkeyHex]), added: ([] : [PresenceEntry]), offline: ([] : [PubkeyHex])} events
1544 let kept = filter (\p -> p.server != from || not elem p.pubkey collected.touched) pres
1699 let kept = filter (\p -> p.server != from || not elem p.pubkey collected.touched) pres
1545 let merged = union kept collected.added
1700 let merged = union kept collected.added
1546 -- Cap the gossip-learned rows: per-peer first, so one peer cannot crowd every
1701 -- Cap the gossip-learned rows: per-peer first, so one peer cannot crowd every
@@ -1552,8 +1707,28 @@ handleRecvGossip = \events fromServer -> do
1552 let localRows = filter (\p -> isLocalServer p.server) merged
1707 let localRows = filter (\p -> isLocalServer p.server) merged
1553 let remoteRows = filter (\p -> not isLocalServer p.server) merged
1708 let remoteRows = filter (\p -> not isLocalServer p.server) merged
1554 *presence = union localRows (capList (capPerServer remoteRows from maxPresencePerServer) maxPresence)
1709 *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)
1555 yield (filter (\pk -> not any (\p -> p.pubkey == pk && p.server == from && p.expiresAt > t) pres) (map (\p -> p.pubkey) collected.added)))
1724 yield (filter (\pk -> not any (\p -> p.pubkey == pk && p.server == from && p.expiresAt > t) pres) (map (\p -> p.pubkey) collected.added)))
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.
1556− fork (forEach newlyOnline (\pk -> forwardQueuedToServer pk fromServer))
1731+ fork (forwardQueuedToAll newlyOnline fromServer)
1557 yield (Ok {value: {}})
1732 yield (Ok {value: {}})
1558
1733
1559-- POST /federation/forward: receive a single federated message destined for
1734-- POST /federation/forward: receive a single federated message destined for
server.onew file

Binary file not shown.