on master help sign in
Security audit fixes: Q16 cursor poisoning, Q17 ack ordering, Q18 persistent seen_ids, Q25 malformed JSON, Q35 revokeOthers challenges, Q37 sig inside atomic, Q38 audit log, Q49 low-order X25519 check, spec gaps Q39-Q44, §10 risk table updates
commit11c24d893b6f3ba7845750aeb5395292c4356c24 authorLancelot <montelot-dev@typeable.io> dateWed 15 Jul 2026, 22:22 (3 months ago) parentda51700
5 files changed+1064−87
AUDIT-NEW.md+816 PROTOCOL.md+37−15 core/src/app.rs+148−43 core/src/crypto.rs+12 server.knot+51−29
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.
PROTOCOL.md+37−15
@@ -170,7 +170,7 @@ compressed_plaintext (variable, zstd; exactly compressed_len bytes)
170170padding (variable, random)
171171```
172172
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.
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 (346 hex chars). A relay rejects any blob shorter than this with `400 invalid_message`, since it cannot possibly carry a valid envelope.
174174
175175### Length Padding
176176
@@ -211,10 +211,10 @@ The envelope is what servers see. It carries only the recipient and the opaque e
211211
212212| Field | Type | Description |
213213|-----------------|--------|--------------------------------------------------|
214−| `to` | string | Recipient's Ed25519 public key (64 lowercase hex)|
215−| `encryptedBlob` | string | Hex-encoded blob from §3 |
214+| `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. Must be at least 346 hex chars (173 bytes); the server rejects shorter blobs with `400 invalid_message`. |
216216
217−Servers route based on `to`. They cannot inspect `encryptedBlob` and they do not know the sender.
217+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*).
218218
219219### Plaintext Payload
220220
@@ -328,7 +328,12 @@ This file is a static suggestion, not a federation directory. Federation between
328328
329329### Content Type
330330
331−All request and response bodies use `application/json`.
331+All request and response bodies use `application/json`. The following encoding rules apply to every JSON body (request and response):
332+
333+- **Encoding:** UTF-8. Implementations MUST NOT use UTF-16 or UTF-32.
334+- **Key ordering:** Object keys are unordered. A sender may emit them in any order; a receiver MUST NOT depend on key ordering for correctness.
335+- **Whitespace:** Inter-token whitespace (spaces, tabs, newlines) is allowed and does not affect semantics. Pretty-printed JSON is valid.
336+- **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.
332337
333338### Federation Transport
334339
@@ -361,6 +366,7 @@ When a client connects, it proves ownership of its keypair:
361366 ```json
362367 { "challenge": "<random_hex>", "expiresAt": 1679003660000 }
363368 ```
369+ 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).
364370
3653713. 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):
366372 ```
@@ -373,7 +379,7 @@ When a client connects, it proves ownership of its keypair:
373379 }
374380 ```
375381 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").
382+ 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.)
377383
3783844. Server verifies the signature. On success, returns a session token valid for **1 hour**:
379385 ```json
@@ -429,7 +435,7 @@ The client makes a blocking POST. The server holds the connection open until new
429435{ "cursor": 0 }
430436```
431437
432−`cursor` is the maximum `receivedAt` value the client has already seen (`0` on first poll).
438+`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.
433439
434440**Response:**
435441```json
@@ -663,16 +669,32 @@ Skrepka protects message **content and sender identity** from servers and networ
663669
664670| Risk | Details |
665671|-----------------------------------|-----------------------------------------------------|
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. |
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. |
672+| **No forward secrecy** | A captured blob plus a later compromise of the recipient's long-term key **is enough to decrypt that message** — the per-message ephemeral key is the *sender's*, and the recipient's half of the exchange is their long-term identity key, which never rotates. So an adversary who records ciphertext today (a network observer, or any relay it passed through) and obtains the recipient's key at any point in the future can read everything it recorded. There is no ratchet, so there is also no post-compromise recovery: once the key leaks, future messages are readable too until the user rotates identity. Do not read the per-message ephemeral keys as forward secrecy; they are not. **Additionally**, because the HKDF salt (`ephemeral_public ‖ recipient_x25519_public`) omits the sender's identity, compromising the recipient's private key also enables a *key-compromise impersonation (KCI)* vector: the attacker can decrypt a captured blob, replace the sender identity and signature inside the inner buffer with their own, re-encrypt with the same derived key, and re-deliver. The recipient sees a valid message apparently from a different sender. Including the sender pubkey in the HKDF salt or info would close this by construction, but requires a wire-format change. The signature already prevents forgery *without* key compromise, so KCI is an additional consequence of an already-catastrophic key leak, not a standalone attack. |
673+| **No AAD on AEAD — envelope fields unauthenticated** | XChaCha20-Poly1305 is called with no associated data (§3). The version byte, ephemeral public key, and nonce are in the clear and not cryptographically bound to the ciphertext. More importantly, the envelope's `to` field is not authenticated by the AEAD — a relay can swap `to` to a different recipient's pubkey. The new recipient cannot decrypt the blob (wrong key), but the *original* recipient's message is gone from the mailbox. This is message loss, which is equivalent in impact to dropping — a relay can already drop any message — so it is an accepted consequence of v0.1's lack of AAD. A future revision should include `version_byte ‖ ephemeral_public ‖ nonce ‖ recipient_ed25519_public` as AAD (wire-format change). |
674+| **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. |
675+| **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. |
676+| **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. |
677+| **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. |
668678| **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. |
669679| **No durable delivery** | If the sender's server goes down or all forward retries fail, messages may be lost. |
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. |
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. |
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. |
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. |
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. |
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. |
680+| **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. |
681+| **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. |
682+| **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. |
683+| **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. |
684+| **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. |
685+| **Presence table fillable with fake keys** | The presence table (`maxPresence`, default 100 000) can be filled with fake keys via gossip (100 events per request, 60/min per IP). Local rows are exempt from the cap, so local users' presence is never evicted, but legitimate *remote* presence (from honest peers) can be evicted, causing forwards to those peers to fail. Requires federation to be enabled. |
686+| **Gossip amplification** | A single `/federation/gossip` request with 100 `online` events triggers 100 mailbox scans and forward-staging operations. An attacker can repeatedly send batches of 100 online events, amplifying CPU and forward-queue load by 100× per request. Bounded by the per-IP rate limit (60/min), so 6 000 redirected keys per minute per IP. |
687+| **`fromServer` not verified** | The `fromServer` field in gossip events is self-asserted and not verified against the connecting peer's actual network address. An attacker can claim `fromServer: "legit-relay.example.com"` from any IP, poisoning the presence table with the legitimate relay's hostname. Forwards go to the legitimate relay (which doesn't have the key online → 404), causing spurious forward requests. This is a variant of the gossip-redirect attack; the intended fix is reverse-DNS verification of the connecting peer. |
688+| **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. |
689+| **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). |
690+| **Same-recipient replay** | Nothing stops a captured blob from being re-delivered to its original recipient. The damage is bounded per payload type: `text` is deduplicated by `id` (the reference client maintains a persistent `seen_ids` set that survives `trim_history`), `delivery.ack` is idempotent, and `profile` is guarded by the per-contact `ts` staleness check of §4. |
691+| **Open federation — gossip redirect** | `/federation/gossip` is unauthenticated; an attacker announces the victim online at a host it controls and origin relays forward the victim's queued ciphertext there. See *Open federation* below. |
692+| **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. |
693+| **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. |
694+| **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). |
695+| **Non-curve-point `to` keys accepted** | The server validates that `to` is 64 lowercase hex chars but does not check that the decoded bytes form a valid Ed25519 curve point. Blobs addressed to non-existent keys are stored and forwarded normally, consuming storage until TTL expiry. |
696+| **No crypto rotation performed** | The wire format carries a version byte (§3, *Cryptographic Versioning*) and a fixed HKDF `info` (`"skrepka-v1"`), so a future revision *can* introduce new AEAD/KDF/curve choices by bumping both. No such rotation has been performed. |
697+| **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. |
676698
677699### Open Federation (accepted limitation of v0.1)
678700
core/src/app.rs+148−43
@@ -138,6 +138,7 @@ const K_PROFILE: &str = "profile";
138138const K_CONTACTS: &str = "contacts";
139139const K_CURSOR: &str = "cursor";
140140const K_OUTBOX: &str = "outbox";
141+const K_SEEN_IDS: &str = "seen_ids";
141142fn k_messages(peer: &str) -> String {
142143 format!("messages:{peer}")
143144}
@@ -209,6 +210,9 @@ pub enum Event {
209210 LoadedOutbox(#[facet(opaque)] KvData),
210211 #[serde(skip)]
211212 #[facet(skip)]
213+ LoadedSeenIds(#[facet(opaque)] KvData),
214+ #[serde(skip)]
215+ #[facet(skip)]
212216 LoadedMessages(String, #[facet(opaque)] KvData),
213217 #[serde(skip)]
214218 #[facet(skip)]
@@ -285,9 +289,9 @@ pub enum Page {
285289 EditProfile,
286290}
287291
288−/// The five kv keys loaded at startup (`settings`, `profile`, `contacts`,
289−/// `cursor`, `outbox`). `Connect` waits for all of them.
290−const STARTUP_LOADS: u8 = 5;
292+/// The six kv keys loaded at startup (`settings`, `profile`, `contacts`,
293+/// `cursor`, `outbox`, `seen_ids`). `Connect` waits for all of them.
294+const STARTUP_LOADS: u8 = 6;
291295
292296/// The batch a live `/messages` request is carrying: the first `count` items of
293297/// the outbox, all bound for `recipient`.
@@ -313,6 +317,12 @@ pub struct Model {
313317 profile: OwnProfile,
314318 contacts: BTreeMap<String, Contact>,
315319 messages: BTreeMap<String, Vec<StoredMessage>>,
320+ /// Q18: Per-peer dedup ID set, independent of the trimmed conversation.
321+ /// Without this, a replayed blob whose `id` was aged out by `trim_history`
322+ /// (past the 1000-message cap) passes the dedup check and re-enters the
323+ /// conversation. This set is loaded from and persisted to kv alongside
324+ /// messages, so it survives restarts.
325+ seen_ids: BTreeMap<String, HashSet<String>>,
316326 outbox: VecDeque<OutboxItem>,
317327 cursor: i64,
318328 token: Option<String>,
@@ -425,6 +435,7 @@ impl Default for Model {
425435 profile: OwnProfile::default(),
426436 contacts: BTreeMap::new(),
427437 messages: BTreeMap::new(),
438+ seen_ids: BTreeMap::new(),
428439 outbox: VecDeque::new(),
429440 cursor: 0,
430441 token: None,
@@ -640,6 +651,16 @@ struct PollEvent {
640651 #[serde(rename = "encryptedBlob")]
641652 encrypted_blob: String,
642653}
654+
655+/// Q17: Deferred delivery-ack accumulator. Collects ack_ids and the max `ts`
656+/// across all `delivery.ack` payloads from one sender in a single poll page,
657+/// so the staleness check and watermark update run once per sender after the
658+/// event loop — immune to relay page ordering.
659+#[derive(Default)]
660+struct PendingAck {
661+ ts: i64,
662+ ids: HashSet<String>,
663+}
643664#[derive(Serialize)]
644665struct SendBatch {
645666 messages: Vec<Envelope>,
@@ -683,6 +704,7 @@ impl App for Skrepka {
683704 KeyValue::get(K_CONTACTS).then_send(Event::LoadedContacts),
684705 KeyValue::get(K_CURSOR).then_send(Event::LoadedCursor),
685706 KeyValue::get(K_OUTBOX).then_send(Event::LoadedOutbox),
707+ KeyValue::get(K_SEEN_IDS).then_send(Event::LoadedSeenIds),
686708 ])
687709 .and(render())
688710 }
@@ -760,6 +782,18 @@ impl App for Skrepka {
760782 }
761783 self.startup_done(model).and(render())
762784 }
785+ Event::LoadedSeenIds(res) => {
786+ match parse_kv::<BTreeMap<String, HashSet<String>>>(res) {
787+ Ok(Some(map)) => model.seen_ids = map,
788+ Ok(None) => {}
789+ Err(()) => {
790+ // Non-fatal: dedup falls back to the conversation-derived
791+ // set, which is weaker but functional.
792+ model.seen_ids = BTreeMap::new();
793+ }
794+ }
795+ self.startup_done(model).and(render())
796+ }
763797 Event::LoadedMessages(peer, res) => {
764798 match parse_kv::<Vec<StoredMessage>>(res) {
765799 Ok(Some(list)) => {
@@ -990,6 +1024,7 @@ impl App for Skrepka {
9901024 Event::DeleteContact { peer } => {
9911025 model.contacts.remove(&peer);
9921026 model.messages.remove(&peer);
1027+ model.seen_ids.remove(&peer);
9931028 model.outbox.retain(|item| item.recipient != peer);
9941029 // The send in flight may be carrying the very items we just
9951030 // dropped; if so its result must pop nothing.
@@ -1006,6 +1041,8 @@ impl App for Skrepka {
10061041 // message the user just asked us to forget. Delete the key.
10071042 KeyValue::delete(k_messages(&peer)).then_send(Event::Saved),
10081043 self.persist_outbox(model),
1044+ // Q18: persist the seen_ids map without the removed peer.
1045+ self.persist_seen_ids(model),
10091046 ])
10101047 .and(render())
10111048 }
@@ -1306,7 +1343,17 @@ impl App for Skrepka {
13061343 model.error = Some("relay sent an oversized poll response".into());
13071344 return self.backoff_poll(model);
13081345 }
1309− let parsed: PollResp = serde_json::from_slice(&bytes).unwrap_or_default();
1346+ // Q25: A malformed or unparseable poll response must not be
1347+ // treated as an empty page — `unwrap_or_default()` would adopt
1348+ // cursor 0, forcing a re-fetch from the beginning and wasting
1349+ // bandwidth on already-seen (deduped) messages. Back off instead.
1350+ let parsed: PollResp = match serde_json::from_slice(&bytes) {
1351+ Ok(p) => p,
1352+ Err(_) => {
1353+ model.error = Some("relay sent a malformed poll response".into());
1354+ return self.backoff_poll(model);
1355+ }
1356+ };
13101357 let cmd = self.ingest_poll(model, parsed);
13111358 // The re-poll hangs off the *cursor write*, not off this event.
13121359 // Polling again is what acks the batch we just took — the server
@@ -1632,6 +1679,16 @@ impl Skrepka {
16321679 KeyValue::set(k_messages(peer), json_bytes(&list)).then_send(Event::Saved)
16331680 }
16341681
1682+ /// Q18: Persist the per-peer dedup ID set. Written alongside messages so
1683+ /// the set survives restarts and covers IDs that `trim_history` has aged
1684+ /// out of the conversation.
1685+ fn persist_seen_ids(&self, model: &mut Model) -> Command<Effect, Event> {
1686+ if self.refuse_write(model) {
1687+ return Command::done();
1688+ }
1689+ KeyValue::set(K_SEEN_IDS, json_bytes(&model.seen_ids)).then_send(Event::Saved)
1690+ }
1691+
16351692 fn schedule_reconnect(&self, model: &mut Model) -> Command<Effect, Event> {
16361693 let delay = backoff_ms(model.poll_retries);
16371694 model.poll_retries = model.poll_retries.saturating_add(1);
@@ -1970,7 +2027,14 @@ impl Skrepka {
19702027 // relay. Adopting `i64::MAX` would ack (delete) all queued mail on the
19712028 // next poll and persist that poison across relaunches.
19722029 let clamped_cursor = page.cursor.min(now_ms().saturating_add(60_000));
1973− if clamped_cursor > model.cursor {
2030+ // Q16: Only advance the cursor when the page actually carried events.
2031+ // An empty page with a cursor higher than ours is suspicious — a
2032+ // compromised relay could poison the cursor to ack (delete) messages
2033+ // the client hasn't seen. When there are no events, there is nothing
2034+ // to acknowledge, so the cursor should not move. (An honest relay's
2035+ // empty-page cursor equals what the client sent, so this is a no-op
2036+ // in the normal case.)
2037+ if !page.events.is_empty() && clamped_cursor > model.cursor {
19742038 model.cursor = clamped_cursor;
19752039 }
19762040 let Some(id) = model.identity() else {
@@ -1982,8 +2046,10 @@ impl Skrepka {
19822046 let mut contacts_dirty = false;
19832047 // sender hex -> message ids needing acks
19842048 let mut ack_targets: BTreeMap<String, Vec<String>> = BTreeMap::new();
1985− // Lazily-built per-sender sets of known message IDs for O(1) text dedup.
1986− let mut seen_ids: HashMap<String, HashSet<String>> = HashMap::new();
2049+ // Q17: Per-sender deferred ack accumulation. Collected during the event
2050+ // loop, applied after it — so relay page ordering cannot cause an older
2051+ // ack to be dropped as stale by a newer one processed first.
2052+ let mut pending_acks: HashMap<String, PendingAck> = HashMap::new();
19872053
19882054 let mut budget = MAX_POLL_TOTAL_BYTES;
19892055 let mut events_processed = 0usize;
@@ -2084,10 +2150,21 @@ impl Skrepka {
20842150 contacts_dirty = true;
20852151 }
20862152 let convo = model.messages.entry(sender.clone()).or_default();
2087− // Lazily build the per-sender ID set on first touch.
2088− let id_set = seen_ids.entry(sender.clone()).or_insert_with(|| {
2089− convo.iter().map(|m| m.id.clone()).collect()
2090− });
2153+ // Q18: Use the persistent per-peer dedup set from
2154+ // `model.seen_ids` (loaded from kv at startup) instead of
2155+ // deriving it from the trimmed conversation. Without this,
2156+ // a replayed blob whose `id` was aged out by `trim_history`
2157+ // (past the 1000-message cap) passes the dedup check and
2158+ // re-enters the conversation.
2159+ let id_set = model
2160+ .seen_ids
2161+ .entry(sender.clone())
2162+ .or_default();
2163+ // Also seed from the conversation in case the kv load was
2164+ // missing or partial — covers the upgrade path.
2165+ if id_set.is_empty() {
2166+ id_set.extend(convo.iter().map(|m| m.id.clone()));
2167+ }
20912168 // dedup by id — O(1) via HashSet
20922169 if !id_set.contains(&msg_id) {
20932170 // The conversation is *kept* sorted rather than re-sorted:
@@ -2113,39 +2190,26 @@ impl Skrepka {
21132190 }
21142191 }
21152192 Payload::DeliveryAck { ack_ids } => {
2116− // Staleness check: drop a replayed ack whose ts predates the
2117− // last one we processed for this contact.
2118− let stale = model
2119− .contacts
2193+ // Q17: Defer the staleness check until after the event loop.
2194+ // Within a single page, a relay can reorder events so that a
2195+ // newer ack (higher ts) is processed before an older one. If
2196+ // we applied the watermark immediately, the older ack would be
2197+ // dropped as stale — even if it acks different message IDs.
2198+ // Instead, accumulate all ack_ids per sender and the max ts,
2199+ // then apply them in a second pass below the loop.
2200+ let prev_ts = pending_acks
21202201 .get(&sender)
2121− .is_some_and(|c| ts < c.last_ack_ts);
2122− if stale {
2123− continue;
2124− }
2125− // The watermark is clamped to *now*, not merely into the skew
2126− // window: `ts` is the peer's to choose, and `last_ack_ts` is
2127− // what every later ack is measured against. A single ack at
2128− // `ts = i64::MAX` would otherwise park the watermark a minute
2129− // into the future, and every honest ack that followed — the
2130− // ones that actually mark our messages delivered — would read
2131− // as stale and be dropped.
2132− if let Some(c) = model.contacts.get_mut(&sender) {
2133− c.last_ack_ts = ts.min(now);
2134− contacts_dirty = true;
2135− }
2136− let ack_set: HashSet<String> = ack_ids.iter().cloned().collect();
2137− if let Some(convo) = model.messages.get_mut(&sender) {
2138− let mut changed = false;
2139− for m in convo.iter_mut() {
2140− if m.outgoing && ack_set.contains(&m.id) && !m.delivered {
2141− m.delivered = true;
2142− changed = true;
2143− }
2144− }
2145− if changed {
2146− touched_convos.insert(sender);
2147− }
2202+ .map(|p| p.ts)
2203+ .unwrap_or(0);
2204+ if ts > prev_ts {
2205+ let entry = pending_acks.entry(sender.clone()).or_default();
2206+ entry.ts = ts;
21482207 }
2208+ pending_acks
2209+ .entry(sender.clone())
2210+ .or_default()
2211+ .ids
2212+ .extend(ack_ids);
21492213 }
21502214 Payload::Profile {
21512215 display_name,
@@ -2182,6 +2246,42 @@ impl Skrepka {
21822246 }
21832247 }
21842248
2249+ // Q17: Apply deferred delivery acks. Collected during the event loop
2250+ // above, now applied with a single staleness check per sender using the
2251+ // highest `ts` seen — so relay page ordering cannot cause an older ack
2252+ // to be dropped as stale by a newer one processed first.
2253+ for (sender, pending) in &pending_acks {
2254+ let stale = model
2255+ .contacts
2256+ .get(sender)
2257+ .is_some_and(|c| pending.ts < c.last_ack_ts);
2258+ if stale {
2259+ continue;
2260+ }
2261+ // The watermark is clamped to *now*, not merely into the skew window:
2262+ // `ts` is the peer's to choose, and `last_ack_ts` is what every later
2263+ // ack is measured against. A single ack at `ts = i64::MAX` would
2264+ // otherwise park the watermark a minute into the future, and every
2265+ // honest ack that followed — the ones that actually mark our messages
2266+ // delivered — would read as stale and be dropped.
2267+ if let Some(c) = model.contacts.get_mut(sender) {
2268+ c.last_ack_ts = pending.ts.min(now);
2269+ contacts_dirty = true;
2270+ }
2271+ if let Some(convo) = model.messages.get_mut(sender) {
2272+ let mut changed = false;
2273+ for m in convo.iter_mut() {
2274+ if m.outgoing && pending.ids.contains(&m.id) && !m.delivered {
2275+ m.delivered = true;
2276+ changed = true;
2277+ }
2278+ }
2279+ if changed {
2280+ touched_convos.insert(sender.clone());
2281+ }
2282+ }
2283+ }
2284+
21852285 // Queue delivery acks, in batches the recipient will actually accept.
21862286 //
21872287 // `parse_payload` drops a `delivery.ack` carrying more than `MAX_ACK_IDS`
@@ -2211,6 +2311,10 @@ impl Skrepka {
22112311 for peer in &touched_convos {
22122312 cmds.push(self.persist_messages(model, peer));
22132313 }
2314+ // Q18: Persist the dedup ID set whenever we touched conversations.
2315+ if !touched_convos.is_empty() {
2316+ cmds.push(self.persist_seen_ids(model));
2317+ }
22142318 if contacts_dirty {
22152319 cmds.push(self.persist_contacts(model));
22162320 }
@@ -3877,6 +3981,7 @@ mod tests {
38773981 Event::LoadedProfile(Ok(None)),
38783982 Event::LoadedContacts(Ok(None)),
38793983 Event::LoadedCursor(Ok(None)),
3984+ Event::LoadedSeenIds(Ok(None)),
38803985 ]
38813986 .into_iter()
38823987 .enumerate()
@@ -3890,7 +3995,7 @@ mod tests {
38903995 assert_eq!(connects(&mut cmd), 0, "and none of them connects");
38913996 }
38923997
3893− // The fifth — and only the fifth — emits Connect.
3998+ // The sixth — and only the sixth — emits Connect.
38943999 let mut cmd = app.update(Event::LoadedOutbox(Ok(None)), &mut m);
38954000 assert_eq!(m.loads_pending, 0);
38964001 assert_eq!(connects(&mut cmd), 1, "the last load connects, exactly once");
core/src/crypto.rs+12
@@ -199,9 +199,21 @@ impl Identity {
199199 }
200200
201201 /// Compute the cached X25519 keys from the Ed25519 secret key.
202+ ///
203+ /// Q49: Also checks that the derived X25519 public key is not an all-zero
204+ /// (low-order) point. The probability of generating such a key is negligible,
205+ /// but if it ever happened every encryption to this identity would fail with
206+ /// `InvalidEphemeralKey` and the user would be unable to receive messages
207+ /// with no indication of why. Catching it at construction time gives a
208+ /// clear error instead.
202209 fn with_x25519(secret_key: [u8; ED25519_SECRET_LEN]) -> Self {
203210 let x25519_priv = *ed25519_sk_to_x25519(&secret_key);
204211 let x25519_pub = x25519_dalek::x25519(x25519_priv, x25519_dalek::X25519_BASEPOINT_BYTES);
212+ debug_assert!(
213+ !x25519_pub.iter().all(|b| *b == 0),
214+ "derived X25519 public key is all-zero (low-order point) — \
215+ this identity cannot receive messages"
216+ );
205217 Identity {
206218 secret_key,
207219 x25519_priv,
server.knot+51−29
@@ -1199,45 +1199,67 @@ handleVerifyAuth = \pubkey challenge signature revokeOthers clientIp -> do
11991199 -- The client signs (target host, challenge) under a domain-separation tag so a
12001200 -- relay server cannot replay the signature to a different server. We verify
12011201 -- 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
1203− -- it here avoids re-running crypto on every STM retry.
1202+ --
1203+ -- Signature verification is deferred until *after* the challenge lookup confirms
1204+ -- a matching challenge exists (inside the atomic below). This avoids spending
1205+ -- expensive Ed25519 CPU on /auth/verify calls that have no matching challenge,
1206+ -- which would otherwise be a cheap CPU-DoS vector. The trade-off is that
1207+ -- `verify` runs inside the STM transaction and may re-run on retries — but
1208+ -- retries only happen on concurrent writes to *challenges/*sessions, which
1209+ -- are rare, so the expected retry count is ~1.
12041210 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 {}
12101211 result <- atomic (do
12111212 ch <- *challenges
12121213 let same = \c -> c.pubkey == pubkey && c.challenge == challenge
1213− let valid = any (\c -> same c && c.expiresAt > t && matchIp clientIp c.ip) ch && sigValid
1214− if not valid
1215− then yield (Nothing {})
1216− else do
1217− *challenges = filter (\c -> not same c) ch
1218− ses <- *sessions
1219− let pruned = if revokeOthers
1220− then filter (\s -> s.pubkey != pubkey) ses
1221− else capPerKey ses pubkey maxSessionsPerKey
1222− let newSession = {token, pubkey, expiresAt: expiry, ip: clientIp}
1223− -- 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`.
1225− let pooled = union pruned [newSession]
1226− -- 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
1228− -- 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.
1230− let evicted = max 0 (count pooled - maxSessions)
1231− *sessions = capList pooled maxSessions
1232− peers <- refreshLocalPresence pubkey expiry
1233− yield (Just {value: {peers, evicted}}))
1214+ let matchingChallenge = findFirst same ch
1215+ case matchingChallenge of
1216+ Nothing {} -> yield (Nothing {})
1217+ Just {value: c} -> do
1218+ if not (c.expiresAt > t && matchIp clientIp c.ip)
1219+ then yield (Nothing {})
1220+ else do
1221+ -- Verify the signature only now that a matching, unexpired, IP-bound
1222+ -- challenge exists.
1223+ let sigValid = case bytesFromHex pubkey of
1224+ Just {value: pk} -> case bytesFromHex signature of
1225+ Just {value: sigBytes} -> verify pk (textToBytes signedText) sigBytes
1226+ _ -> False {}
1227+ _ -> False {}
1228+ if not sigValid
1229+ then yield (Nothing {})
1230+ else do
1231+ -- Consume the challenge and optionally revoke other sessions + challenges.
1232+ -- When `revokeOthers` is set, drop all other sessions AND outstanding
1233+ -- challenges for this pubkey, so a captured challenge cannot be
1234+ -- redeemed for a new session after revocation (Q35).
1235+ *challenges = if revokeOthers
1236+ then filter (\x -> not (x.pubkey == pubkey)) ch
1237+ else filter (\x -> not same x) ch
1238+ ses <- *sessions
1239+ let pruned = if revokeOthers
1240+ then filter (\s -> s.pubkey != pubkey) ses
1241+ else capPerKey ses pubkey maxSessionsPerKey
1242+ let newSession = {token, pubkey, expiresAt: expiry, ip: clientIp}
1243+ -- LRU-evicts the soonest-to-expire session when the pool is full; this global
1244+ -- eviction is Sybil-floodable, see the LIMITATION note on `maxSessions`.
1245+ let pooled = union pruned [newSession]
1246+ -- Count what `capList` is about to shed, so the handler can warn about it
1247+ -- below. That flood has no other observable signature: evicted users just
1248+ -- start seeing 401s and silently re-authenticate, which looks like normal
1249+ -- traffic in the access log. A sustained nonzero rate here is the alert.
1250+ let evicted = max 0 (count pooled - maxSessions)
1251+ *sessions = capList pooled maxSessions
1252+ peers <- refreshLocalPresence pubkey expiry
1253+ yield (Just {value: {peers, evicted}}))
12341254 case result of
12351255 Nothing {} -> yield (Err {error: {status: 401, message: "invalid"}})
12361256 Just {value: r} -> do
12371257 -- Warn outside the `atomic` block, never inside: an STM transaction can
12381258 -- retry, and a console write is not rolled back with it — logging inside
12391259 -- would emit one line per retry attempt rather than one per eviction.
1240− when (r.evicted > 0) (logWarn ("session pool full: evicted " ++ show r.evicted ++ " session(s) to seat a new login (maxSessions=" ++ show maxSessions ++ ")"))
1260+ -- Include the requesting pubkey and IP for audit (Q38): an operator
1261+ -- investigating a Sybil flood needs to know which identities are responsible.
1262+ when (r.evicted > 0) (logWarn ("session pool full: evicted " ++ show r.evicted ++ " session(s) to seat a new login (pubkey=" ++ pubkey ++ ", ip=" ++ clientIp ++ ", maxSessions=" ++ show maxSessions ++ ")"))
12411263 when federationEnabled (fork (sendBatchedGossip r.peers [{eventType: "online", pubkey: Just {value: pubkey}}]))
12421264 yield (Ok {value: {token, expiresAt: expiry}})
12431265