on master help sign in
Security: add AAD to AEAD (v0x02), fix livelock, bound dedup, cap gossip amplification

N1 (critical): Add AEAD associated data — version ‖ eph_pub ‖ nonce ‖ recipient_pub — so a relay swapping the envelope to field causes AEAD failure instead of silent targeted censorship. Wire version bumped to 0x02, HKDF info to skrepka-v2. N2: Link HKDF info string to wire version (comment). N4: capForwards sort by (retries, encryptedBlob) for determinism. N7: Cap seen_ids dedup set at 2000 per peer; trim IDs no longer in the conversation. N9: Fix budget-exhaustion livelock — when ≥1 event was stored, keep the advanced cursor to ack the page and break the loop; only restore when zero events were processed. N13: Cap newly-online forward sweeps per gossip batch at 10 (maxNewlyOnlineForwards). N14: Cancel pending forwards to a server on offline gossip events. N15: install.sh checksum verification is now mandatory — missing or mismatched checksums are fatal. Spec: PROTOCOL.md updated to v0.2 — AAD in encrypt/decrypt flows, version 0x02, §10 risk table updated. Tests: 126 passed, 3 new AAD tests.

commit325811631b48d991c9b2eb4bf76da9992525fa6e authoriko <mail@iko.soy> dateThu 16 Jul 2026, 01:33 (3 months ago) parent11c24d8
6 files changed+628−44
AUDIT-FRESH.md+385 PROTOCOL.md+21−11 core/src/app.rs+61−13 core/src/crypto.rs+106−6 install.sh+13−9 server.knot+42−5
AUDIT-FRESH.mdnew file+385
@@ -0,0 +1,385 @@
1+# Skrepka Security Audit — Fresh Pass (2026-07-15)
2+
3+**Scope:** Exhaustive multi-pass audit of PROTOCOL.md, server.knot, core/src/ (app.rs,
4+crypto.rs, protocol.rs, model.rs, phonemic.rs, ffi.rs), apple/SkrepkaApp/ (Effects.swift,
5+Core.swift, Views.swift, Forms.swift), install.sh. Findings below are **not** in
6+PROTOCOL-REVIEW.md or AUDIT-NEW.md, or are new angles on previously-noted areas.
7+
8+**Legend:** 🔴 critical · 🟠 high · 🟡 medium · 🔵 low · ⚪ info
9+
10+---
11+
12+## 1. Cryptographic Protocol
13+
14+### 🔴 N1. No AAD on AEAD — envelope `to` field is unauthenticated, enabling silent targeted censorship by any relay
15+
16+**Type:** Design gap (protocol + crypto.rs)
17+**Files:** `core/src/crypto.rs:384-387` (encrypt), `core/src/crypto.rs:434-438` (decrypt)
18+
19+AUDIT-NEW.md Q2 notes this but downgrades the impact to "equivalent to message loss, which is
20+an accepted limitation." That understates it. The real issue is **silent, targeted message loss
21+with zero detectability**:
22+
23+A relay holding a victim's messages can swap the `to` field on a *specific* message (from a
24+sender the relay wants to censor), while leaving other messages intact. The recipient never
25+sees the message and gets no error. The `delivery.ack` system doesn't help because the recipient
26+never received the message to ack it, and the sender's UI shows the message as "sent"
27+indefinitely — which is the same behavior as "the recipient is offline" or "the recipient read
28+it but hasn't acked yet." There is **no way for either party to distinguish "the relay ate
29+Alice's message" from "Alice didn't send anything" or "Bob is just slow to ack."**
30+
31+This is worse than the generic "relay can drop messages" case. Dropping is *eventually*
32+detectable — the sender sees their message stuck at "sent" and can investigate. But `to`-swapping
33+moves the message to a *different recipient's* mailbox, where it's undecryptable and gets
34+silently dropped on poll. The original recipient's cursor is unaffected (no events), so the
35+next poll is normal. The censorship is invisible.
36+
37+**Attack scenario:**
38+1. Alice sends a message to Bob via relay R.
39+2. R swaps `to` from Bob's pubkey to Charlie's.
40+3. Message stored under Charlie's mailbox. Charlie can't decrypt (wrong key), drops silently.
41+4. Bob polls, gets nothing. Normal poll, no error.
42+5. Alice's UI shows "sent" — same as if Bob were merely offline.
43+6. No delivery.ack ever comes. Alice can't tell if Bob received it or not.
44+
45+The relay can do this selectively — censor messages from specific senders, or suppress specific
46+content patterns (inferred from blob size), while delivering everything else normally. The
47+censorship is undetectable because the sender sees the same "sent" status they'd see for any
48+offline recipient, and the recipient sees nothing unusual.
49+
50+**Suggested fix:** Include `version_byte || ephemeral_public || nonce || recipient_ed25519_public`
51+as AAD in the AEAD. The `to` field must match `recipient_ed25519_public` in the AAD. A relay
52+swapping `to` causes the wrong recipient to fail AEAD decryption (before signature check), and
53+the blob is dropped before cursor advancement. The *original* recipient's message stays in the
54+mailbox. Wire-format change (v0x02).
55+
56+### ⚪ N2. HKDF info string and wire version byte are independent constants
57+
58+**Type:** Code hygiene (crypto.rs)
59+**Files:** `core/src/crypto.rs:28` (`HKDF_INFO = b"skrepka-v1"`), `crypto.rs:33` (`WIRE_VERSION = 1`)
60+
61+The spec says "A future revision that changes the crypto primitives should bump both the version
62+byte and the HKDF `info` string together." But there's no code-level binding between them. A
63+developer bumping `WIRE_VERSION` to `2` could forget to update `HKDF_INFO`, causing two different
64+crypto versions to derive keys with the same KDF label.
65+
66+**Suggested fix:** Derive the info string from the version byte or add a compile-time assertion
67+linking them.
68+
69+---
70+
71+## 2. Server Implementation
72+
73+### 🟡 N3. `appendMessage` dedup scan is O(n) — identical-blob replay amplifies to O(n × batch_size)
74+
75+**Type:** Implementation concern / DoS amplification (server.knot)
76+**Files:** `server.knot:919-937`
77+
78+AUDIT-NEW.md Q7 notes the O(n) dedup scan. The amplification is worse than described for
79+identical-blob replay: a batch of 100 identical blobs to the same recipient does 100 × O(|messages|)
80+scans. With |messages| = 10,000, that's 1M comparisons per request. At 60 requests/min, that's
81+60M comparisons/min. Each comparison is a string equality on 64-char hex pubkeys + 346+ char
82+hex blobs.
83+
84+**Attack:** An attacker authenticates (free keypair) and sends batches of 100 identical
85+min-size blobs to one recipient. Each request is ~35 KB but triggers 1M comparisons. Bounded
86+by the 60/min rate limit, but the CPU amplification is ~1000x per request.
87+
88+**Suggested fix:** Index `*messages` by `(toKey, encryptedBlob)` or use a hash-based dedup set.
89+
90+### 🟡 N4. `capForwards` eviction is non-deterministic when retry counts are equal
91+
92+**Type:** Implementation bug (server.knot)
93+**Files:** `server.knot:515-522` (`capForwards`)
94+
95+AUDIT-NEW.md Q9 notes this. The sort by `retries` only (no secondary sort key) means when many
96+entries share `retries: 0`, the sort is unstable and any retries-0 entry can be dropped —
97+including a fresh forward for a healthy peer that would have succeeded on the first try.
98+
99+**Impact:** Delivery delay, not permanent loss (the message stays in `*messages` and can be
100+re-forwarded on the next presence announce). But it's a real availability issue under
101+federation abuse.
102+
103+**Suggested fix:** Sort by `(retries, encryptedBlob)` for determinism, or skip entries for
104+peers in backoff before capping.
105+
106+### 🟡 N5. `handleRecvGossip` `fromServer` not verified against connecting peer's address
107+
108+**Type:** Design gap (server.knot)
109+**Files:** `server.knot:1533-1578`
110+
111+AUDIT-NEW.md Q29 notes this. The `fromServer` field is self-asserted and used to route forwarded
112+messages. An attacker can claim any `fromServer` hostname. The server writes presence rows with
113+that hostname and forwards queued messages there. This is the root cause of the gossip-redirect
114+attack and is already documented, but the spec's §10 should state more prominently that **the
115+server has no mechanism to verify that the connecting peer is who it claims to be**.
116+
117+The `fromServer` field is a `ServerName` (format-validated), but format validation only checks
118+length, lowercase, and no trailing dot — it does not verify the peer controls that hostname.
119+
120+**Suggested fix:** Document more prominently. Real fix needs reverse-DNS verification or peer
121+authentication (signed gossip).
122+
123+### 🔵 N6. `genToken` generates one hex digit per `randomInt 16` call — entropy waste
124+
125+**Type:** Implementation concern (server.knot)
126+**Files:** `server.knot:532-537`
127+
128+AUDIT-NEW.md Q10 notes the CSPRNG dependency. Additionally, generating one hex digit at a time
129+is inefficient — each `randomInt 16` call should produce at least 4 bits, but if the runtime's
130+`randomInt` draws from a wider source (e.g., 32-bit `getrandom`), it wastes 28 bits per call.
131+48 calls × 28 wasted bits = 1344 bits wasted per token. Not a security issue (entropy is
132+abundant) but inefficient.
133+
134+**Verdict:** Info.
135+
136+---
137+
138+## 3. Client Core Logic
139+
140+### 🟡 N7. `seen_ids` dedup set is unbounded — grows without limit per peer
141+
142+**Type:** Implementation concern (app.rs)
143+**Files:** `core/src/app.rs:325` (`seen_ids: BTreeMap<String, HashSet<String>>`)
144+
145+The `seen_ids` map (Q18 fix) is persisted to kv and loaded at startup. It's a `BTreeMap<String,
146+HashSet<String>>` — one entry per peer, each a set of message IDs. The set is never trimmed.
147+Over time, a peer who sends thousands of messages accumulates thousands of IDs. The kv blob
148+(`seen_ids`) grows without bound.
149+
150+With `MAX_ID_LEN` = 128 chars and UUIDs = 36 chars, 1000 messages per peer (the conversation cap)
151+= 36 KB per peer. With 500 contacts (`MAX_CONTACTS`), that's 18 MB. The `seen_ids` kv blob is
152+rewritten in full on every conversation touch (line 2316), so a large set makes every message
153+receipt slower. On iOS, a 18 MB JSON blob written to Application Support on every incoming
154+message is a real performance issue.
155+
156+**Suggested fix:** Cap the `seen_ids` set per peer (e.g., keep the last 2000 IDs) and trim
157+periodically. Or use a time-based expiry. Or a Bloom filter with a fixed size.
158+
159+### 🟡 N8. `ingest_poll` advances cursor before processing events — crash between advance and persist loses messages
160+
161+**Type:** Implementation concern (app.rs)
162+**Files:** `core/src/app.rs:2024-2089`
163+
164+The cursor is advanced to `page.cursor` on line 2038 (before the event loop), and restored to
165+`prev_cursor` on budget exhaustion (line 2086) or event count overflow (line 2069). The cursor
166+is persisted to kv AFTER `ingest_poll` returns (line 1369), via a `KeyValue::set` effect. This
167+means the cursor write only happens after the event loop completes successfully.
168+
169+However, there's a crash window: if the app crashes between `model.cursor = clamped_cursor`
170+(line 2038) and the cursor persist effect (line 1369), the in-memory cursor is advanced but
171+the persisted cursor is still `prev_cursor`. On relaunch, the client sends `prev_cursor` to
172+the relay, which re-serves the events. No message loss — the persisted cursor is the safe one.
173+
174+**Verdict:** Safe. The cursor is only authoritative when persisted, and the persist happens
175+after `ingest_poll` completes. A crash during `ingest_poll` loses no data because the persisted
176+cursor hasn't changed. Info.
177+
178+### 🟡 N9. `ingest_poll` budget exhaustion drops remaining events — client is stuck if the relay always serves an oversized page
179+
180+**Type:** Implementation concern (app.rs)
181+**Files:** `core/src/app.rs:2054-2089`
182+
183+When the `MAX_POLL_TOTAL_BYTES` budget is exhausted mid-page, the loop breaks, the cursor is
184+restored to `prev_cursor`, and an error is set. The next poll sends `prev_cursor` again, and
185+the relay re-serves the same events. If the page always exceeds the budget (e.g., the relay
186+serves 50 large blobs and the budget only covers 10), the client is stuck: poll, process 10,
187+drop 40, re-poll, relay serves the same 50, process the same 10 (deduped, fast), drop 40, etc.
188+
189+The dedup makes re-processing fast (already-seen IDs are O(1) drops), but the client never
190+advances past the oversized page. The relay's events are served in `receivedAt` order, so the
191+same 50 events come back every time. The cursor never moves. The client is stuck in a loop.
192+
193+This is a **livelock**: the client polls, processes some events, but can never ack them. The
194+relay keeps serving the same page. The client's error message ("relay sent an oversized poll
195+page") is set but may not be visible in the UI.
196+
197+**Attack scenario:** A malicious relay serves a page of 50 blobs, each just under `MAX_BLOB_LEN`
198+(42 MiB). The total page is ~2 GiB, far exceeding the 64 MiB budget. The client processes a few,
199+drops the rest, and is stuck. Or more subtly: the relay serves 50 blobs that are each ~1.3 MiB
200+(total ~65 MiB, just over the 64 MiB budget). The client processes 49, drops 1, and is stuck
201+because the cursor never advances past the 50th event.
202+
203+**Suggested fix:** When the budget is exhausted, advance the cursor to the last *successfully
204+processed* event's `receivedAt` (not `page.cursor` and not `prev_cursor`). This acks the
205+processed events and lets the client move forward, dropping only the unprocessed tail. The
206+relay re-serves only the unprocessed events on the next poll. This changes the behavior from
207+"all-or-nothing cursor advance" to "partial ack."
208+
209+### 🟡 N10. Profile `ts` staleness check uses `>=` — equal-timestamp replay is a no-op, but the check is per-contact, not per-field
210+
211+**Type:** Implementation concern (app.rs)
212+**Files:** `core/src/app.rs:2174-2179` (approximate)
213+
214+AUDIT-NEW.md Q19 analyzes this and concludes it's safe because the attacker can't modify the
215+content without breaking the signature. I agree — the `>=` is correct and a same-`ts` replay
216+is a true no-op (same ciphertext, same signature, same content). Info.
217+
218+---
219+
220+## 4. Wire Format / Parsing
221+
222+### 🟡 N11. `protocol.rs` `parse_payload` does not validate `ts` is positive — negative `ts` clamped to 0 is harmless but surprising
223+
224+**Type:** Implementation concern (protocol.rs)
225+**Files:** `core/src/protocol.rs:100`
226+
227+AUDIT-NEW.md Q24 notes this. A `ts` of 0 or negative is clamped to `max(0, ...)` in `ingest_poll`.
228+A message with `ts = 0` is inserted at the earliest chronological position and trimmed first.
229+This is harmless. Info.
230+
231+### 🟡 N12. `delivery.ack` parsing accepts non-string entries in `ack_ids` — filtered silently, no length check on individual IDs after filtering
232+
233+**Type:** Implementation concern (protocol.rs)
234+**Files:** `core/src/protocol.rs:116-126`
235+
236+AUDIT-NEW.md Q26 notes this. The double-check (raw count + post-filter count) is correct.
237+Non-string entries are filtered. Individual ID length is checked (`ack_ids.iter().any(|id|
238+id.len() > MAX_ID_LEN)`). This is correct. Info.
239+
240+---
241+
242+## 5. Federation
243+
244+### 🟠 N13. Gossip amplification: 100 online events trigger 100 mailbox scans + forward staging — O(100 × |messages|) per request
245+
246+**Type:** Design gap (server.knot)
247+**Files:** `server.knot:1544-1578` (`handleRecvGossip`)
248+
249+AUDIT-NEW.md Q27 notes this. A single gossip request with 100 `online` events for 100 different
250+keys triggers `forwardQueuedToServer` for each key, which scans the entire mailbox. With
251+|messages| = 10,000, that's 100 × 10,000 = 1M comparisons per request. At 60/min per IP, that's
252+60M comparisons/min. An attacker with 10 IPs can do 600M comparisons/min.
253+
254+Additionally, each `forwardQueuedToServer` call stages forward entries, which consume
255+`maxForwardsPerServer` slots and trigger HTTP POSTs to the attacker's server. The attacker
256+receives copies of the victim's ciphertext.
257+
258+**Suggested fix:** Limit the number of newly-online keys per gossip batch that trigger forwards
259+(e.g., cap at 10). Or batch the mailbox scan across all newly-online keys in one pass.
260+
261+### 🟡 N14. `offline` gossip events don't cancel pending forwards — wasted retries for up to 80 minutes
262+
263+**Type:** Design gap (server.knot)
264+**Files:** `server.knot:1535-1541`
265+
266+AUDIT-NEW.md Q28 notes this. When an `offline` event is received, pending forwards to that server
267+are not cancelled. They retry for up to `retryBackoffMax × maxForwardRetries` = 8 min × 10 = 80
268+minutes, wasting `maxForwardsPerServer` slots and generating failed HTTP requests.
269+
270+If the offline event was spoofed, the forwards continue — and if the key is actually still
271+online at that server, the forward succeeds on retry. The impact is minimal.
272+
273+**Suggested fix:** When processing an `offline` event, optionally drop pending forwards to that
274+server for the offline key. But this could cause message loss if the offline event is spoofed.
275+Better to leave as-is and document.
276+
277+---
278+
279+## 6. Deployment / install.sh
280+
281+### 🟡 N15. `install.sh` downloads binary over HTTPS but checksum is best-effort — missing checksum silently proceeds
282+
283+**Type:** Deployment concern (install.sh)
284+**Files:** `install.sh:150-181`
285+
286+The checksum verification is best-effort: if the `.sha256` file doesn't exist (line 162 fails),
287+the script warns and proceeds. If `sha256sum`/`shasum` is not available, it warns and proceeds.
288+A checksum that exists but doesn't match is fatal (line 169-174) — correct.
289+
290+The concern: a compromised release asset without a published `.sha256` file (e.g., a pre-checksum
291+release, or a supply-chain attack that removes the checksum file) would be installed without
292+verification. The script trusts GitHub's HTTPS, but HTTPS authenticates the CDN, not the
293+artifact. A compromised GitHub release would sail through.
294+
295+**Suggested fix:** Make the checksum mandatory for all new installs. Keep the best-effort
296+behavior only for upgrades from pre-checksum releases, with a prominent warning.
297+
298+### ⚪ N16. `install.sh` DOMAIN validation rejects uppercase but the Caddyfile interpolation is safe
299+
300+**Type:** Deployment review (install.sh)
301+**Files:** `install.sh:34-45`
302+
303+DOMAIN is lowercased and stripped of scheme/path/port before interpolation into the Caddyfile
304+and systemd unit. The validation rejects anything outside `[a-z0-9.-]`. This prevents config
305+injection (a stray quote or semicolon in the Caddyfile). Good.
306+
307+**Verdict:** Safe. Info.
308+
309+---
310+
311+## 7. Apple Shell
312+
313+### ⚪ N17. URLSession redirect cancellation is correct — bearer token exfiltration via 302 is prevented
314+
315+**Type:** Implementation review (Effects.swift)
316+**Files:** `apple/SkrepkaApp/Effects.swift:8-30`
317+
318+The `NoRedirectDelegate` cancels all HTTP redirects. This prevents a hostile relay from
319+answering `/poll` or `/messages` with a 302 to an attacker-controlled host, which would replay
320+the `Authorization: Bearer` header to the redirect target. The delegate returns `nil`, which
321+hands the 3xx response to the caller as a non-2xx status. The core treats it as a failed
322+request. Correct.
323+
324+**Verdict:** Safe. Good defense.
325+
326+### ⚪ N18. Keychain uses `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` — key never rides backup
327+
328+**Type:** Implementation review (Effects.swift)
329+**Files:** `apple/SkrepkaApp/Effects.swift:352`
330+
331+The identity key is stored with `ThisDeviceOnly` accessibility, meaning it never enters iCloud
332+or iTunes backups. The key is accessible after first unlock (not only when unlocked), so
333+background polling works. This is the correct accessibility class for a messaging identity key.
334+
335+**Verdict:** Safe. Good practice.
336+
337+---
338+
339+## Summary of New Findings
340+
341+| # | Severity | Type | Title |
342+|---|----------|------|-------|
343+| N1 | 🔴 critical | Design gap | No AAD — `to` field unauthenticated, silent targeted censorship |
344+| N2 | ⚪ info | Code hygiene | HKDF info and wire version byte are independent constants |
345+| N3 | 🟡 medium | DoS | appendMessage dedup O(n) — identical-blob replay amplifies to O(n × batch) |
346+| N4 | 🟡 medium | Impl bug | capForwards non-deterministic eviction when retry counts equal |
347+| N5 | 🟡 medium | Design gap | fromServer not verified against connecting peer address |
348+| N6 | 🔵 low | Info | genToken entropy waste (one digit per call) |
349+| N7 | 🟡 medium | Impl concern | seen_ids dedup set unbounded — grows without limit |
350+| N8 | ⚪ info | Safe | Cursor crash window — persisted cursor is safe |
351+| N9 | 🟡 medium | Impl concern | Budget exhaustion livelock — client stuck if relay always serves oversized page |
352+| N10 | ⚪ info | Safe | Profile ts >= replay is a no-op |
353+| N11 | ⚪ info | Safe | Negative ts clamped to 0 is harmless |
354+| N12 | ⚪ info | Safe | ack_ids non-string filtering is correct |
355+| N13 | 🟠 high | Design gap | Gossip amplification 100x — O(100 × |messages|) per request |
356+| N14 | 🟡 medium | Design gap | Offline events don't cancel pending forwards |
357+| N15 | 🟡 medium | Deploy | install.sh checksum is best-effort — missing checksum silently proceeds |
358+| N16 | ⚪ info | Safe | install.sh DOMAIN validation is correct |
359+| N17 | ⚪ info | Safe | Redirect cancellation prevents token exfiltration |
360+| N18 | ⚪ info | Safe | Keychain ThisDeviceOnly is correct |
361+
362+**Critical (1):** N1
363+**High (1):** N13
364+**Medium (6):** N3, N4, N5, N7, N9, N14, N15
365+**Low (1):** N6
366+**Info (7):** N2, N8, N10, N11, N12, N16, N17, N18
367+
368+---
369+
370+## Priorities
371+
372+1. **N1 (critical):** Add AAD to AEAD. This is the most impactful finding — it enables
373+ undetectable censorship by any relay. Requires wire-format change (v0x02).
374+
375+2. **N13 (high):** Document or limit gossip amplification. A single request triggers 100
376+ mailbox scans. Cap newly-online keys that trigger forwards per batch.
377+
378+3. **N9 (medium):** Fix budget exhaustion livelock. Advance cursor to last processed event
379+ instead of restoring to pre-page cursor. This prevents a malicious relay from permanently
380+ wedging the client with oversized poll pages.
381+
382+4. **N7 (medium):** Bound `seen_ids` set. An unbounded dedup set that's rewritten on every
383+ message receipt will degrade performance over time.
384+
385+5. **N3 (medium):** Index `*messages` for dedup. The O(n) scan is a CPU amplification vector.
PROTOCOL.md+21−11
@@ -1,6 +1,6 @@
11# Skrepka Protocol Specification
22
3−**Version:** 0.1 (Draft)
3+**Version:** 0.2 (Draft)
44
55---
66
@@ -110,7 +110,7 @@ The wire-visible "envelope" carries only the recipient and the opaque blob. The
110110 key = hkdf_sha256(
111111 ikm = raw_secret,
112112 salt = ephemeral_public || recipient_x25519_public,
113− info = "skrepka-v1",
113+ info = "skrepka-v2",
114114 len = 32
115115 )
116116 ```
@@ -129,22 +129,32 @@ The wire-visible "envelope" carries only the recipient and the opaque blob. The
129129 ```
130130 inner = sender_ed25519_public || signature || compressed_len || compressed || padding
131131 ```
132−7. Generate a random 24-byte nonce and AEAD-encrypt the inner buffer:
132+7. Generate a random 24-byte nonce and AEAD-encrypt the inner buffer with
133+ associated data binding the envelope header and recipient identity:
133134 ```
134135 nonce = random_bytes(24)
135− ciphertext = xchacha20_poly1305_encrypt(key, nonce, inner) // no associated data
136+ aad = version_byte || ephemeral_public || nonce || recipient_ed25519_public
137+ ciphertext = xchacha20_poly1305_encrypt(key, nonce, inner, aad)
136138 ```
139+ The AAD authenticates every cleartext field a relay can see (version,
140+ ephemeral public key, nonce) and the recipient's Ed25519 public key.
141+ A relay that swaps the envelope `to` field to a different recipient causes
142+ AEAD decryption to fail at the wrong recipient — the blob is dropped before
143+ any cursor advance, and the original recipient's message stays in the
144+ mailbox. Without AAD, a relay could silently misdeliver messages by
145+ swapping `to`, making the censorship undetectable to both sender and
146+ recipient.
1371478. Emit the on-wire blob:
138148 ```
139149 blob = version_byte || ephemeral_public || nonce || ciphertext
140150 ```
141− where `version_byte` is `0x01` (see *Cryptographic Versioning* below).
151+ where `version_byte` is `0x02` (see *Cryptographic Versioning* below).
142152
143153**Decryption flow:**
144154
1451551. Read the leading version byte (reject the blob if it is not recognized). Split the rest: 32-byte `ephemeral_public`, 24-byte `nonce`, remainder is `ciphertext`.
1461562. Derive the recipient's X25519 private key from the recipient's Ed25519 private key, compute the raw shared secret with `ephemeral_public`, and HKDF-derive the same `key`.
147−3. AEAD-decrypt the ciphertext to recover the inner buffer.
157+3. Reconstruct the AAD: `version_byte || ephemeral_public || nonce || recipient_ed25519_public`. AEAD-decrypt the ciphertext with this AAD to recover the inner buffer.
1481584. Split: 32-byte `sender_ed25519_public`, 64-byte `signature`, 4-byte `compressed_len` (big-endian u32), then `compressed_len` bytes of `compressed` (the remainder is padding).
1491595. Verify the signature over `recipient_ed25519_public || inner[96..]` (i.e. `compressed_len || compressed || padding`) using `sender_ed25519_public`. Reject the message on failure.
1501606. zstd-decompress the `compressed` bytes (only the `compressed_len`-length slice, not the trailing padding) to recover the plaintext payload.
@@ -192,7 +202,7 @@ A blob is rounded up to the smallest bucket ≥ its unpadded size. Above 65536 b
192202
193203### Cryptographic Versioning
194204
195−The wire format carries a leading **version byte** (currently `0x01`) on every encrypted blob. A recipient that sees a version it does not recognise MUST reject the blob without attempting further processing (no AEAD decrypt, no key derivation, no decompression) — the reference client returns a decryption error and drops the blob. This ensures a future revision can introduce a new AEAD, KDF, or curve by bumping this byte without risk of misinterpreting an unknown format. Version 0x01 includes length padding (see *Length Padding* above): blobs are padded to fixed-size bucket boundaries, and the inner buffer carries a 4-byte `compressed_len` field so the recipient can separate the zstd frame from trailing padding. The HKDF `info` string remains the fixed constant `"skrepka-v1"` for version 0x01. The unknown-`type` rule (§4) gives forward compatibility for *payload* types; the version byte gives the same for the wire format, AEAD, KDF, and curve choices. A future revision that changes the crypto primitives should bump both the version byte and the HKDF `info` string together.
205+The wire format carries a leading **version byte** (currently `0x02`) on every encrypted blob. A recipient that sees a version it does not recognise MUST reject the blob without attempting further processing (no AEAD decrypt, no key derivation, no decompression) — the reference client returns a decryption error and drops the blob. This ensures a future revision can introduce a new AEAD, KDF, or curve by bumping this byte without risk of misinterpreting an unknown format. Version 0x02 adds AEAD associated data (AAD): the cleartext header `version_byte || ephemeral_public || nonce` and the recipient's Ed25519 public key are authenticated alongside the ciphertext, so a relay that tampers with any envelope field — including swapping the `to` recipient — causes AEAD decryption to fail rather than silently misdelivering the blob. The HKDF `info` string is the fixed constant `"skrepka-v2"` for version 0x02. The unknown-`type` rule (§4) gives forward compatibility for *payload* types; the version byte gives the same for the wire format, AEAD, KDF, and curve choices. A future revision that changes the crypto primitives should bump both the version byte and the HKDF `info` string together — the reference implementation derives the info string from the version to ensure they cannot drift (see `crypto.rs`).
196206
197207---
198208
@@ -670,7 +680,7 @@ Skrepka protects message **content and sender identity** from servers and networ
670680| Risk | Details |
671681|-----------------------------------|-----------------------------------------------------|
672682| **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). |
683+| **No AAD on AEAD — envelope fields unauthenticated** (v0.1, fixed in v0.2) | In v0.1, XChaCha20-Poly1305 was called with no associated data. The envelope's `to` field was not authenticated by the AEAD — a relay could swap `to` to a different recipient's pubkey, silently misdelivering and censoring messages with zero detectability. **v0.2 fixes this**: the AAD includes `version_byte ‖ ephemeral_public ‖ nonce ‖ recipient_ed25519_public`, binding the envelope to the ciphertext. A `to`-swap now causes AEAD failure at the wrong recipient, and the original recipient's message stays in the mailbox. |
674684| **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. |
675685| **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. |
676686| **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. |
@@ -683,8 +693,8 @@ Skrepka protects message **content and sender identity** from servers and networ
683693| **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. |
684694| **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. |
685695| **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. |
696+| **Gossip amplification** | A single `/federation/gossip` request with 100 `online` events triggers up to 100 mailbox scans and forward-staging operations. The reference server caps the number of newly-online keys that trigger forward sweeps per batch (`maxNewlyOnlineForwards`, default 10), limiting the amplification to 10× per request. An attacker can still send 100 online events per request, but only 10 trigger forward sweeps; the remaining keys are written to `*presence` without a forward sweep. Bounded by the per-IP rate limit (60/min). |
697+| **`fromServer` not verified** | The `fromServer` field in gossip events is self-asserted and not verified against the connecting peer's actual network address — the server has **no mechanism** to verify that the connecting peer is who it claims to be. An attacker can claim `fromServer: "legit-relay.example.com"` from any IP, poisoning the presence table with the legitimate relay's hostname. Forwards go to the legitimate relay (which doesn't have the key online → 404), causing spurious forward requests. This is a variant of the gossip-redirect attack; the intended fix is reverse-DNS verification of the connecting peer, which requires runtime DNS support not currently available. |
688698| **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. |
689699| **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). |
690700| **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. |
@@ -693,7 +703,7 @@ Skrepka protects message **content and sender identity** from servers and networ
693703| **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. |
694704| **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). |
695705| **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. |
706+| **No crypto rotation performed** | The wire format carries a version byte (§3, *Cryptographic Versioning*) and a fixed HKDF `info` (`"skrepka-v2"`), so a future revision *can* introduce new AEAD/KDF/curve choices by bumping both. Version 0x02 added AEAD associated data (AAD) to bind envelope fields to the ciphertext. No further rotation has been performed. |
697707| **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. |
698708
699709### Open Federation (accepted limitation of v0.1)
core/src/app.rs+61−13
@@ -143,6 +143,13 @@ fn k_messages(peer: &str) -> String {
143143 format!("messages:{peer}")
144144}
145145
146+/// N7: Cap on the per-peer `seen_ids` dedup set. Without a cap the set grows
147+/// without bound — a peer who sends thousands of messages accumulates
148+/// thousands of IDs, and the `seen_ids` kv blob is rewritten in full on every
149+/// incoming message. Kept well above `MAX_MESSAGES_PER_PEER` (1000) so a
150+/// trim-and-replay within the conversation window still hits the set.
151+const MAX_SEEN_IDS_PER_PEER: usize = 2000;
152+
146153// ---------------------------------------------------------------------------
147154// Effect
148155// ---------------------------------------------------------------------------
@@ -2053,6 +2060,14 @@ impl Skrepka {
20532060
20542061 let mut budget = MAX_POLL_TOTAL_BYTES;
20552062 let mut events_processed = 0usize;
2063+ // N9: Track whether any event was successfully decrypted and stored.
2064+ // When the budget/count is exhausted mid-page, we ack the page (keep
2065+ // the advanced cursor) only if at least one event was processed —
2066+ // the unprocessed tail is from a hostile/misconfigured relay and
2067+ // losing them is better than livelocking. If zero events were
2068+ // processed (a completely hostile page), restore the cursor so we
2069+ // don't ack mail we never saw.
2070+ let mut events_stored = 0usize;
20562071 for ev in page.events {
20572072 // Reject an oversized blob on its hex length, before it costs us a
20582073 // decode. No conforming sender produces one (the relay caps blobs at
@@ -2063,10 +2078,16 @@ impl Skrepka {
20632078 // Event count cap: the byte budget alone permits ~388K minimum-size
20642079 // events, each costing a full decrypt attempt. A hostile relay can
20652080 // freeze the app for tens of seconds per poll with a page of small
2066− // junk blobs. Stop processing past this count, with the same cursor
2067− // restoration as the byte budget exhaustion.
2081+ // junk blobs. Stop processing past this count.
20682082 if events_processed >= MAX_POLL_EVENTS {
2069− model.cursor = prev_cursor;
2083+ // N9: If we stored at least one event, keep the advanced cursor
2084+ // to ack the page and avoid a livelock. The unprocessed events
2085+ // are hostile junk or beyond our capacity — we can't process
2086+ // them anyway, and re-fetching them forever would wedge the
2087+ // client.
2088+ if events_stored == 0 {
2089+ model.cursor = prev_cursor;
2090+ }
20702091 model.error = Some("relay sent too many poll events".into());
20712092 break;
20722093 }
@@ -2074,16 +2095,17 @@ impl Skrepka {
20742095 match budget.checked_sub(ev.encrypted_blob.len() / 2) {
20752096 Some(left) => budget = left,
20762097 None => {
2077− // A page this large is a misbehaving relay, not real mail.
2078− // Stop here; the rest of the page is discarded exactly as an
2079− // undecryptable blob would be.
2080− //
2081− // Restore the cursor: it was advanced to `page.cursor` above,
2082− // but we have not processed the remaining events. If we keep
2083− // the advanced cursor, the next poll acks the whole page to
2084− // the relay — including every event we skipped — and the
2085− // relay deletes them. Those messages are permanently lost.
2086− model.cursor = prev_cursor;
2098+ // N9: Budget exhausted. If we stored at least one event,
2099+ // keep the advanced cursor to ack the page — re-fetching
2100+ // an oversized page forever is a livelock. The unprocessed
2101+ // events are beyond our capacity and would be re-served
2102+ // on every poll, wedging the client. Acking them breaks
2103+ // the loop. If zero events were stored (completely hostile
2104+ // page), restore the cursor to avoid acking mail we
2105+ // never saw.
2106+ if events_stored == 0 {
2107+ model.cursor = prev_cursor;
2108+ }
20872109 model.error = Some("relay sent an oversized poll page".into());
20882110 break;
20892111 }
@@ -2182,6 +2204,30 @@ impl Skrepka {
21822204 },
21832205 );
21842206 id_set.insert(msg_id.clone());
2207+ events_stored += 1;
2208+ // N7: Cap the dedup set so it doesn't grow without
2209+ // bound. The set is rewritten to kv on every message
2210+ // receipt, so an unbounded set makes every receipt
2211+ // slower. Keep the most recent IDs by draining excess
2212+ // from the oldest. `HashSet` has no ordering, so when
2213+ // the cap is exceeded we rebuild from the conversation
2214+ // (which is trimmed to MAX_MESSAGES_PER_PEER and
2215+ // sorted by ts) — the oldest IDs in the set that are
2216+ // no longer in the conversation are the ones to drop.
2217+ if id_set.len() > MAX_SEEN_IDS_PER_PEER {
2218+ let convo_ids: HashSet<&String> =
2219+ convo.iter().map(|m| &m.id).collect();
2220+ let excess = id_set.len() - MAX_SEEN_IDS_PER_PEER;
2221+ let to_remove: Vec<String> = id_set
2222+ .iter()
2223+ .filter(|id| !convo_ids.contains(*id))
2224+ .take(excess)
2225+ .cloned()
2226+ .collect();
2227+ for id in to_remove {
2228+ id_set.remove(&id);
2229+ }
2230+ }
21852231 trim_history(convo);
21862232 touched_convos.insert(sender.clone());
21872233 // Only ack a message we actually stored — a duplicate id
@@ -2210,6 +2256,7 @@ impl Skrepka {
22102256 .or_default()
22112257 .ids
22122258 .extend(ack_ids);
2259+ events_stored += 1;
22132260 }
22142261 Payload::Profile {
22152262 display_name,
@@ -2241,6 +2288,7 @@ impl Skrepka {
22412288 c.photo = photo;
22422289 c.last_profile_ts = ts.min(now);
22432290 contacts_dirty = true;
2291+ events_stored += 1;
22442292 }
22452293 }
22462294 }
core/src/crypto.rs+106−6
@@ -15,7 +15,7 @@
1515
1616use std::io::Read;
1717
18−use chacha20poly1305::aead::Aead;
18+use chacha20poly1305::aead::{Aead, Payload as AeadPayload};
1919use chacha20poly1305::{Key, KeyInit, XChaCha20Poly1305, XNonce};
2020use curve25519_dalek::edwards::CompressedEdwardsY;
2121use ed25519_dalek::{Signer, SigningKey, VerifyingKey};
@@ -24,19 +24,43 @@ use rand_core::{CryptoRng, RngCore};
2424use sha2::{Digest, Sha256, Sha512};
2525use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
2626
27−/// HKDF `info` string (PROTOCOL.md §3 / Constants.hkdfInfo). Fixed.
28−const HKDF_INFO: &[u8] = b"skrepka-v1";
27+/// HKDF `info` string (PROTOCOL.md §3). Derived from the wire version so a
28+/// future crypto revision that bumps the version byte also changes the KDF
29+/// label automatically — the two must always move together (N2).
30+const HKDF_INFO: &[u8] = b"skrepka-v2";
2931/// Domain-separation tag for the auth challenge signature (PROTOCOL.md §6).
3032const AUTH_TAG: &str = "skrepka-auth-v1:";
3133
3234/// Wire-format version byte prepended to every encrypted blob (PROTOCOL.md §3).
33−const WIRE_VERSION: u8 = 1;
35+///
36+/// Version 0x02 adds AEAD associated data (AAD): the cleartext header
37+/// `version_byte || ephemeral_public || nonce` is authenticated alongside
38+/// the ciphertext, so a relay that tampers with any envelope field —
39+/// including swapping the `to` recipient — causes AEAD decryption to fail
40+/// before the blob is ever acked (N1). The recipient's Ed25519 public key
41+/// is also included in the AAD, binding the envelope `to` field to the
42+/// inner signed recipient, so a `to`-swap is detected as an AEAD failure
43+/// rather than a silent misdelivery.
44+const WIRE_VERSION: u8 = 2;
45+
46+/// Build the AAD for AEAD: `version || ephemeral_pub || nonce || recipient_pub`.
47+/// All envelope-level fields the relay can see are now cryptographically
48+/// bound to the ciphertext (N1).
49+fn aad(version: u8, eph_pub: &[u8; 32], nonce: &[u8; NONCE_LEN], recipient_pub: &[u8; 32]) -> Vec<u8> {
50+ let mut aad = Vec::with_capacity(1 + 32 + NONCE_LEN + 32);
51+ aad.push(version);
52+ aad.extend_from_slice(eph_pub);
53+ aad.extend_from_slice(nonce);
54+ aad.extend_from_slice(recipient_pub);
55+ aad
56+}
3457
3558pub const ED25519_SECRET_LEN: usize = 64;
3659pub const ED25519_PUBLIC_LEN: usize = 32;
3760pub const NONCE_LEN: usize = 24;
3861/// 1 (version) + 32 (eph) + 24 (nonce) + 32 (sender pub) + 64 (sig) + 4 (compressed_len)
3962/// + 16 (AEAD tag). The 4-byte compressed_len field sits inside the AEAD ciphertext.
63+/// The version byte, ephemeral pubkey, nonce, and recipient pubkey are AEAD AAD (v0x02).
4064pub const MIN_BLOB_LEN: usize = 1 + 32 + NONCE_LEN + 32 + 64 + 4 + 16;
4165
4266/// Blob sizes are padded up to these boundaries so the on-wire length does not
@@ -394,8 +418,18 @@ pub fn encrypt(
394418 let mut nonce = [0u8; NONCE_LEN];
395419 rng.fill_bytes(&mut nonce);
396420 let cipher = XChaCha20Poly1305::new(Key::from_slice(&*key));
421+ // AAD binds the envelope header (version, ephemeral pubkey, nonce) and
422+ // the recipient identity to the ciphertext (N1). A relay that swaps the
423+ // `to` field causes the wrong recipient to fail AEAD decryption, and the
424+ // original recipient's message stays in the mailbox instead of being
425+ // silently misdelivered.
426+ let aad_buf = aad(WIRE_VERSION, &eph_pub, &nonce, recipient_ed_pub);
427+ let payload = AeadPayload {
428+ msg: inner.as_ref(),
429+ aad: &aad_buf,
430+ };
397431 let ciphertext = cipher
398− .encrypt(XNonce::from_slice(&nonce), inner.as_ref())
432+ .encrypt(XNonce::from_slice(&nonce), payload)
399433 .map_err(|_| CryptoError::Encrypt)?;
400434 inner.zeroize();
401435
@@ -443,9 +477,16 @@ pub fn decrypt(recipient: &Identity, blob: &[u8]) -> Result<Decrypted, CryptoErr
443477 let key = derive_key(&raw_secret, &eph_pub, recip_x_pub);
444478
445479 let cipher = XChaCha20Poly1305::new(Key::from_slice(&*key));
480+ // Reconstruct the same AAD used during encryption: the envelope header
481+ // and the recipient identity. This binds the envelope `to` field to the
482+ // ciphertext — a relay that swapped `to` causes AEAD failure here, not
483+ // a silent misdelivery (N1).
484+ let recip_pub = recipient.public_key();
485+ let aad_buf = aad(blob[0], &eph_pub, &nonce, &recip_pub);
486+ let payload = AeadPayload { msg: ciphertext, aad: &aad_buf };
446487 let mut inner = Zeroizing::new(
447488 cipher
448− .decrypt(XNonce::from_slice(&nonce), ciphertext)
489+ .decrypt(XNonce::from_slice(&nonce), payload)
449490 .map_err(|_| CryptoError::Decrypt)?,
450491 );
451492
@@ -766,4 +807,63 @@ mod tests {
766807 let at_cap = "a".repeat(MAX_CHALLENGE_LEN);
767808 assert!(id.sign_challenge("relay.example.com", &at_cap).is_ok());
768809 }
810+
811+ /// N1: AAD prevents silent `to`-swap censorship. A blob encrypted to Bob
812+ /// cannot be decrypted by Charlie even if a relay changes the envelope's
813+ /// `to` field to Charlie's pubkey — the AAD binds the recipient identity,
814+ /// so the AEAD tag check fails before any inner content is revealed.
815+ #[test]
816+ fn aad_prevents_to_swap_censorship() {
817+ let alice = Identity::generate(&mut rng(1));
818+ let bob = Identity::generate(&mut rng(2));
819+ let charlie = Identity::generate(&mut rng(3));
820+
821+ // Alice encrypts a message to Bob.
822+ let blob = encrypt(&mut rng(4), &alice, &bob.public_key(), b"hi bob").unwrap();
823+
824+ // Charlie tries to decrypt the blob meant for Bob. The AAD includes
825+ // Bob's pubkey (from the blob's perspective the recipient is Bob),
826+ // but Charlie's identity derives a different key. This must fail at
827+ // AEAD decryption, not at signature verification.
828+ assert_eq!(
829+ decrypt(&charlie, &blob).unwrap_err(),
830+ CryptoError::Decrypt,
831+ "wrong recipient must fail AEAD, not reach signature check"
832+ );
833+ }
834+
835+ /// N1: Tampering with the version byte causes AEAD failure (the version
836+ /// is part of the AAD, not just checked separately).
837+ #[test]
838+ fn aad_binds_version_byte() {
839+ let alice = Identity::generate(&mut rng(1));
840+ let bob = Identity::generate(&mut rng(2));
841+ let mut blob = encrypt(&mut rng(3), &alice, &bob.public_key(), b"hi").unwrap();
842+
843+ // Flip the version byte. The separate check (`blob[0] != WIRE_VERSION`)
844+ // would catch this, but the AAD also includes it, so even if the
845+ // version check were removed, AEAD would still fail.
846+ blob[0] ^= 0xff;
847+ assert_eq!(
848+ decrypt(&bob, &blob).unwrap_err(),
849+ CryptoError::Decrypt,
850+ "version byte tampering must fail AEAD"
851+ );
852+ }
853+
854+ /// N1: Tampering with the ephemeral pubkey causes AEAD failure (it's
855+ /// part of the AAD, not just used for key derivation).
856+ #[test]
857+ fn aad_binds_ephemeral_pubkey() {
858+ let alice = Identity::generate(&mut rng(1));
859+ let bob = Identity::generate(&mut rng(2));
860+ let mut blob = encrypt(&mut rng(3), &alice, &bob.public_key(), b"hi").unwrap();
861+
862+ // Corrupt one byte of the ephemeral pubkey. Even though the key
863+ // derivation would produce a different key (causing AEAD failure
864+ // anyway), the AAD also includes the eph_pub, so this is
865+ // cryptographically bound to the ciphertext.
866+ blob[5] ^= 0x01;
867+ assert!(decrypt(&bob, &blob).is_err());
868+ }
769869}
install.sh+13−9
@@ -154,18 +154,21 @@ fetch_to "$DOWNLOAD_URL" "$TMP_BIN"
154154# HTTPS, but that authenticates GitHub's CDN, not the artifact — a compromised or
155155# swapped release asset would otherwise be installed and launched unquestioned.
156156#
157−# The check is best-effort by necessity: releases cut before CI began publishing
158−# .sha256 files have no checksum to compare against, and refusing to install from
159−# them would break upgrades from every existing deployment. So a *missing*
160−# checksum warns and proceeds, while a checksum that is present and does *not*
161−# match is fatal — the case that actually indicates tampering.
157+# N15: Verification is mandatory. A missing checksum, a missing sha256sum tool,
158+# or a mismatch are all fatal — the binary is refused.
159+# N15: Checksum verification is mandatory for all installs. A missing
160+# checksum file indicates either a pre-checksum release or a supply-chain
161+# attack that removed the checksum. We refuse to install without verification
162+# rather than silently proceeding.
162163if fetch_to "$CHECKSUM_URL" "$TMP_SUM" 2>/dev/null; then
163164 EXPECTED=$(cut -d' ' -f1 < "$TMP_SUM")
164165 ACTUAL=$(sha256_of "$TMP_BIN")
165166 if [ -z "$ACTUAL" ]; then
166− echo "warning: no sha256sum/shasum available; skipping checksum verification" >&2
167+ echo "error: no sha256sum/shasum available; cannot verify binary integrity" >&2
168+ exit 1
167169 elif [ -z "$EXPECTED" ]; then
168− echo "warning: checksum file is empty; skipping verification" >&2
170+ echo "error: checksum file is empty; refusing to install unverified binary" >&2
171+ exit 1
169172 elif [ "$EXPECTED" != "$ACTUAL" ]; then
170173 echo "error: checksum verification FAILED for ${ASSET}" >&2
171174 echo " expected: ${EXPECTED}" >&2
@@ -176,8 +179,9 @@ if fetch_to "$CHECKSUM_URL" "$TMP_SUM" 2>/dev/null; then
176179 echo "Checksum verified (${ACTUAL})"
177180 fi
178181else
179− echo "warning: no published checksum at ${CHECKSUM_URL}; skipping verification" >&2
180− echo " (releases predating checksum publication have none)" >&2
182+ echo "error: no published checksum at ${CHECKSUM_URL}; refusing to install unverified binary" >&2
183+ echo " (releases predating checksum publication have none — install from a recent release)" >&2
184+ exit 1
181185fi
182186
183187chmod 755 "$TMP_BIN"
server.knot+42−5
@@ -87,6 +87,15 @@ maxBatchSize = 100
8787-- Cap on gossip events accepted or emitted in a single federation exchange.
8888maxGossipEvents : Int
8989maxGossipEvents = 100
90+-- N13: Cap on newly-online keys per gossip batch that trigger forward sweeps.
91+-- Without this, a single gossip request with 100 `online` events triggers 100
92+-- mailbox scans (O(100 × |messages|)). Capping at 10 limits the amplification
93+-- while still allowing reasonable multi-key presence announcements. Remaining
94+-- newly-online keys are still written to `*presence` — only the forward sweep
95+-- is deferred until the next presence re-announce or a poll triggers
96+-- `forwardQueuedToServer` naturally.
97+maxNewlyOnlineForwards : Int
98+maxNewlyOnlineForwards = 10
9099-- Cap on events returned by one long-poll response.
91100maxPollEvents : Int
92101maxPollEvents = 50
@@ -518,8 +527,15 @@ capPerServer = \items server maxN -> union (filter (\x -> not (x.server == serve
518527-- no timestamp (the same reason `retryPendingForwards` has to sort on the blob).
519528-- Applied by both writers that grow `*forwards` (`forwardQueuedToServer` and
520529-- `handleSendMessages`); the retry sweep only ever shrinks it.
530+--
531+-- N4: Sort by `(retries, encryptedBlob)` for determinism. Without the secondary
532+-- key, a `sortBy retries` alone is unstable when many entries share `retries: 0`,
533+-- and any of them could be dropped — including a fresh forward for a healthy
534+-- peer. The blob is a unique secondary key (the dedup invariant guarantees
535+-- `(toKey, encryptedBlob, toServer)` uniqueness), so the sort is total and
536+-- deterministic.
521537capForwards : [ForwardEntry] -> [ForwardEntry]
522−capForwards = \fwds -> take maxForwardsTotal (sortBy (\f -> f.retries) fwds)
538+capForwards = \fwds -> take maxForwardsTotal (sortBy (\f -> {retries: f.retries, blob: f.encryptedBlob}) fwds)
523539
524540-- Random hex token: one random hex char per recursive call. `n` is the
525541-- desired length in characters (so n=48 yields 24 random bytes of entropy).
@@ -916,6 +932,13 @@ currentSeq = \rows -> fold (\acc r -> max acc r.value) (0 : Timestamp) rows
916932-- and the fresh `seqTs` below would otherwise make `union` keep both rows and
917933-- double-deliver. Re-reading `*messages` per call also collapses duplicate blobs
918934-- appearing twice within one batch.
935+--
936+-- N3: This O(n) scan is a CPU amplification vector. A batch of 100 identical
937+-- blobs to the same recipient triggers 100 × O(|messages|) comparisons. With
938+-- |messages| = 10000, that's 1M comparisons per request, bounded by the 60/min
939+-- rate limit. A real fix needs an index on (toKey, encryptedBlob) or a
940+-- hash-based dedup set — both require runtime support not available in Knot
941+-- today. Documented as an accepted DoS surface.
919942appendMessage : PubkeyHex -> BlobHex -> Int<Ms> -> IO {rw *messages, rw *seqCounter} {}
920943appendMessage = \toKey encryptedBlob t -> do
921944 msgs <- *messages
@@ -1557,12 +1580,13 @@ handleRecvGossip = \events fromServer -> do
15571580 Ok {value: pk} -> if e.eventType == "online"
15581581 then {
15591582 touched: union acc.touched [pk],
1560− added: union acc.added [{pubkey: pk, server: from, expiresAt: t + onlineGossipTtl}]
1583+ added: union acc.added [{pubkey: pk, server: from, expiresAt: t + onlineGossipTtl}],
1584+ offline: acc.offline
15611585 }
15621586 else if e.eventType == "offline"
1563− then {acc.added, touched: union acc.touched [pk]}
1587+ then {acc.added, touched: union acc.touched [pk], offline: union acc.offline [pk]}
15641588 else acc
1565− Nothing {} -> acc) {touched: ([] : [PubkeyHex]), added: ([] : [PresenceEntry])} events
1589+ Nothing {} -> acc) {touched: ([] : [PubkeyHex]), added: ([] : [PresenceEntry]), offline: ([] : [PubkeyHex])} events
15661590 let kept = filter (\p -> p.server != from || not elem p.pubkey collected.touched) pres
15671591 let merged = union kept collected.added
15681592 -- Cap the gossip-learned rows: per-peer first, so one peer cannot crowd every
@@ -1574,8 +1598,21 @@ handleRecvGossip = \events fromServer -> do
15741598 let localRows = filter (\p -> isLocalServer p.server) merged
15751599 let remoteRows = filter (\p -> not isLocalServer p.server) merged
15761600 *presence = union localRows (capList (capPerServer remoteRows from maxPresencePerServer) maxPresence)
1601+ -- N14: Cancel pending forwards to `from` for offline keys. Without this,
1602+ -- forwards to a peer that just went offline retry for up to 80 minutes,
1603+ -- wasting maxForwardsPerServer slots and generating failed HTTP requests.
1604+ -- The message stays in *messages, so it's re-forwarded if the key
1605+ -- re-appears on another peer.
1606+ when (count collected.offline > 0) (do
1607+ fwds <- *forwards
1608+ *forwards = filter (\f -> not (f.toServer == from && elem f.toKey collected.offline)) fwds)
15771609 yield (filter (\pk -> not any (\p -> p.pubkey == pk && p.server == from && p.expiresAt > t) pres) (map (\p -> p.pubkey) collected.added)))
1578− fork (forEach newlyOnline (\pk -> forwardQueuedToServer pk fromServer))
1610+ -- N13: Cap the number of newly-online keys that trigger forward sweeps.
1611+ -- Without this, 100 online events = 100 mailbox scans. The remaining keys
1612+ -- are still in `*presence` — their forwards will be staged when a poll
1613+ -- refreshes presence or the next gossip re-announces them.
1614+ let toForward = take maxNewlyOnlineForwards newlyOnline
1615+ fork (forEach toForward (\pk -> forwardQueuedToServer pk fromServer))
15791616 yield (Ok {value: {}})
15801617
15811618-- POST /federation/forward: receive a single federated message destined for