on master help sign in
Fix auth session relay attack by binding challenge signature to target server hostname
commitec51d84cc27554fabb1749d10d56ca82c0c58e96 authoriko <mail@iko.soy> dateSat 20 Jun 2026, 23:20 (4 months ago) parent65c98c8
5 files changed+212−11
PROTOCOL-REVIEW.md+172 PROTOCOL.md+25−7 client/ApiClient.fs+4−1 client/Crypto.fs+6−2 server.knot+5−1
PROTOCOL-REVIEW.mdnew file+172
@@ -0,0 +1,172 @@
1+# PROTOCOL.md Review — Holes vs. Implementation
2+
3+**Date:** 2026-06-20
4+**Scope:** Cross-check of gaps found in `PROTOCOL.md` against the actual code
5+(`server.knot`, `client/`). Each item records whether the implementation already
6+handles it, partially handles it, or leaves it open.
7+
8+Legend: ✅ fixed · 🟡 partial / mitigated · ❌ open · ⚪ not-a-bug (spec wording only)
9+
10+---
11+
12+## ✅ 1. Auth challenge is not bound to the server (session relay) — fixed
13+
14+**Severity: high — fixed.**
15+
16+The client signed the bare challenge string and the server verified the bare
17+challenge string. Nothing bound the signed material to a server identity, so a
18+relay could forward another server's challenge and replay the signature.
19+
20+**Attack (historical):** a malicious/relay server the client connects to fetched
21+a challenge for the client's pubkey from a legit server A, handed it to the
22+client, collected the signature, and replayed it to A's `/auth/verify` —
23+obtaining a session **as the client** on A (could `/poll` the client's mailbox
24+and send as them). The challenge's IP-binding (`matchIp`) did not help, because
25+the relay server was the client-of-record to A throughout.
26+
27+**Fix (applied):** the client now signs
28+`"skrepka-auth-v1:" + server_host + ":" + challenge` where `server_host` is the
29+bare lowercased hostname it dialed (`client/ApiClient.fs`, `Crypto.fs`
30+`signChallenge`), and the server verifies against
31+`"skrepka-auth-v1:" ++ beforeColon serverHost ++ ":" ++ challenge` using its own
32+configured `serverHost` (`server.knot` `handleVerifyAuth`). The `skrepka-auth-v1:`
33+prefix is a domain-separation tag so an auth signature can never be confused with
34+a message signature (`recipientPub || compressed`, `Crypto.fs`). Breaking change:
35+client and server must be upgraded together.
36+
37+---
38+
39+## ❌ 2. Gossip presence spoofing redirects a victim's mail to the attacker
40+
41+**Severity: high — open (consequence of open federation).**
42+
43+`/federation/gossip` is unauthenticated. On a newly-`online` event the origin
44+forwards the recipient's queued messages **to the announcing `fromServer`**.
45+
46+- `server.knot:1090` — `fork (forEach newlyOnline (\pk -> forwardQueuedToServer pk fromServer))`.
47+
48+**Attack:** a hostile peer announces `{eventType:"online", pubkey:<victim>,
49+fromServer:<attacker>}`. The origin forwards all of the victim's queued
50+ciphertext to the attacker, who harvests ciphertext copies + recipient/timing/size
51+metadata. The receiver-side "recipient must be locally online" gate is checked by
52+the attacker's own server, so it is not a real gate.
53+
54+**Existing partial defense:** `isBadServerName` SSRF deny-list (`server.knot:505`)
55+blocks internal targets; rate-limited 60/window/IP. Neither authenticates the peer.
56+
57+`PROTOCOL.md` §10 understates this as "a hostile server can observe gossip" — it
58+can actively **redirect delivery**. At minimum the spec should say so; a real fix
59+needs signed/authenticated presence or peer allow-listing.
60+
61+---
62+
63+## 🟡 3. Replay protection is narrower than §10 claims
64+
65+**Severity: medium — partially fixed.**
66+
67+§10 claims "Replay protection"; the only mechanism is the recipient-bound
68+signature, which stops replay to a *different* recipient, not re-delivery of the
69+same blob to the same recipient. Per-type status:
70+
71+- `text` — ✅ protected: deduped by `id` (`client/App.fs:38`).
72+- `delivery.ack` — ✅ harmless: `markDelivered` only flips `Sent → Delivered`,
73+ idempotent (`client/App.fs:43`).
74+- `profile` — ❌ **open**: `withProfile` overwrites name/bio/photo
75+ unconditionally (`client/App.fs:66`). The payload `ts` is parsed
76+ (`client/Protocol.fs:53`) but **not** propagated to `ProfileMessage` or
77+ compared, so a replayed *older* profile blob silently rolls a contact's
78+ profile back.
79+
80+**Fix:** carry `ts` into `Envelope.ProfileMessage` and ignore a profile whose
81+`ts` is older than the last one stored for that contact. Tighten §10 wording to
82+"replay against a *different* recipient."
83+
84+---
85+
86+## 🟡 4. Open `/federation/forward` allows mailbox injection
87+
88+**Severity: medium — mitigated, not closed.**
89+
90+Any host can POST a blob for any locally-online `toKey`; the endpoint is
91+unauthenticated.
92+
93+- Mitigations present: per-IP rate limit 600/window (`server.knot:140/314`),
94+ SSRF filter on origins, `no_presence` gate.
95+- Residual: junk blobs still reach an online recipient's poll stream; the spec
96+ never says what a client does with an undecryptable blob (it is dropped at
97+ `Crypto.decrypt`, `client/Protocol.fs:88`). Worth documenting as an accepted,
98+ rate-limited spam surface.
99+
100+---
101+
102+## ⚪ 5. `receivedAt` "millisecond timestamp" vs "monotonic" — wording only
103+
104+**Not a bug.** `appendMessage` assigns `seqTs = max t (currentSeq seqRows + 1)`
105+(`server.knot:653`), which is **strictly monotonic**, so no two messages share a
106+`receivedAt` and the `cursor >= receivedAt` implicit-ack cannot drop an unseen
107+message. The spec just describes the same value as both a "millisecond timestamp"
108+and "monotonic," which reads as ambiguous. Recommend: describe it as "a
109+monotonic sequence seeded from millisecond wall-clock; treat as an opaque
110+checkpoint, not a clock."
111+
112+---
113+
114+## ✅ 6. Total request-body cap — fixed in deployment, undocumented in spec
115+
116+The per-blob cap is `maxBlobLen = 41943040` (40 MiB hex) enforced by the
117+`BlobHex` type (`server.knot:53/198`), and `maxBatchSize = 100`
118+(`server.knot:56`). The naive reading "100 × 40 MiB = 4 GiB request" is
119+**prevented** by a runtime body cap set in deployment:
120+
121+- `install.sh` — `ExecStart=… --http-max-body-bytes=42M` caps the *entire*
122+ request body (chosen just above `maxBlobLen` + envelope; the runtime default is
123+ 16 MiB).
124+
125+So a batch can carry one max-size blob or many small ones, but total ≤ 42 MiB.
126+`PROTOCOL.md` §5 should state this total-body cap (and that operators must keep
127+`--http-max-body-bytes` > `maxBlobLen`).
128+
129+---
130+
131+## ❌ 7. No crypto / wire-format version negotiation
132+
133+**Severity: low — open.**
134+
135+`info = "skrepka-v1"` is hardcoded (`client/Crypto.fs:178` / `Constants`), and
136+neither the envelope nor the plaintext payload carries a version field. "Ignore
137+unknown `type`" gives payload-type agility but no path to rotate the
138+AEAD/KDF/curve. Recommend a stated version field or an explicit "no crypto
139+agility in v0.1" note.
140+
141+---
142+
143+## ⚪ 8. X-Forwarded-For trust is spoofable without a proxy — by design
144+
145+`trustForwardedFor = True` by default (`server.knot:148`); the session/rate-limit
146+IP is the last XFF hop (`pickClientIp`, `server.knot:302`), and `matchIp` treats
147+an empty bound IP as a wildcard (`server.knot:546`). This is correct **behind a
148+trusted proxy** but spoofable when the server is directly exposed. The flag and
149+its `--trustForwardedFor=False` override exist; the spec presents IP-binding as a
150+protection without the proxy caveat. Recommend documenting it.
151+
152+---
153+
154+## Minor / noted
155+
156+- HKDF salt is `ephemeral_pub || recipient_x25519_pub` (both public), binds no
157+ sender — fine for confidentiality, no KCI/UKS guarantee beyond the signature.
158+- Presence TTL (90 min, `onlineGossipTtl`) outlives the 1 h session, so location
159+ metadata and stale forwards can persist ~90 min after disconnect.
160+- `/auth/challenge` is unauthenticated; fillable by anyone but capped
161+ (`maxChallenges = 10000`, `maxChallengesPerKey = 5`).
162+
163+---
164+
165+## Suggested follow-ups
166+
167+1. **Spec edits (safe, no behavior change):** fold #2, #3, #6, #8 caveats into
168+ `PROTOCOL.md` (§6 auth, §7 federation, §5 size, §10 security).
169+2. **Code fixes worth doing:** #1 (bind challenge to server) is **done**;
170+ #3-profile (`ts` staleness guard) remains a genuine, fixable defect.
171+3. #2 and #4 are inherent to open, unauthenticated federation — decide whether to
172+ document-as-accepted or add peer authentication.
PROTOCOL.md+25−7
@@ -149,6 +149,10 @@ compressed_plaintext (variable, LZ4)
149149
150150The 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 minimum blob size is `32 + 24 + 32 + 64 + 16 = 168` bytes.
151151
152+### Cryptographic Versioning
153+
154+There is currently **no version field** on the wire or in the plaintext payload, and the HKDF `info` string is the fixed constant `"skrepka-v1"`. The unknown-`type` rule (§4) gives forward compatibility for *payload* types, but the wire format, AEAD, KDF, and curve choices have no negotiated version, so there is no in-band path to migrate them without a flag day. A future revision should introduce an explicit protocol version (e.g. a leading version byte on the blob, or a `v` field) to allow cryptographic agility.
155+
152156---
153157
154158## 4. Message Format
@@ -215,6 +219,8 @@ Users share a display name, bio, and photo with contacts by sending a `profile`
215219
216220Clients SHOULD send a `profile` message to a contact on first interaction and whenever the user updates their name, bio, or photo. The recipient's client caches the profile locally. `photo` is optional and MAY be omitted.
217221
222+Because `profile` messages carry no `id`, they are not deduplicated, and a captured `profile` blob can be replayed to the same recipient (see §10, *Same-recipient replay*). To prevent an old profile from clobbering a newer one, clients SHOULD ignore an incoming `profile` whose `ts` is older than the `ts` of the last profile already stored for that contact. **The reference client does not yet enforce this** — it applies every `profile` it decrypts unconditionally — so a replayed stale profile currently rolls a contact's cached name, bio, or photo back.
223+
218224### Delivery Acks
219225
220226When a recipient successfully decrypts an incoming `text` message, the recipient's client SHOULD send a `delivery.ack` message back to the sender:
@@ -276,6 +282,11 @@ Servers contact peers over `https://<peer-host>`. Federation traffic carries no
276282
277283The on-wire `encryptedBlob` (hex) is capped at **40 MiB** (about 20 MiB of binary payload). A blob over the cap is rejected at validation as a malformed message (`400`), not with `413`.
278284
285+Two limits apply to a `/messages` request, independently of the per-blob cap:
286+
287+- **Batch count** — at most 100 messages per request (`413 batch_too_large`).
288+- **Total request body** — the runtime enforces a single HTTP body-size cap (`--http-max-body-bytes`, returning `413` with no JSON error code). Operators **must** set this above `maxBlobLen` plus the JSON envelope — the reference deployment uses `42M` — otherwise the runtime's default 16 MiB cap would reject a maximum-size blob before the per-blob `400` check is reached. Because this caps the whole body, a batch cannot smuggle 100 × 40 MiB: the total payload across all messages in one request is bounded by this cap.
289+
279290---
280291
281292## 6. Client ↔ Server Protocol
@@ -295,16 +306,17 @@ When a client connects, it proves ownership of its keypair:
295306 { "challenge": "<random_hex>", "expiresAt": 1679003660000 }
296307 ```
297308
298−3. Client signs the **UTF-8 bytes of the challenge string** with its Ed25519 private key and submits:
309+3. Client signs the **UTF-8 bytes of `"skrepka-auth-v1:" + server_host + ":" + challenge`** with its Ed25519 private key and submits. `server_host` is the bare lowercased hostname of the server the client dialed (no scheme, no port, no trailing dot). The `skrepka-auth-v1:` prefix is a domain-separation tag distinguishing auth signatures from message signatures (which sign `recipientPub ++ compressed`, §4):
299310 ```
300311 POST /auth/verify
301312 {
302313 "pubkey": "<hex(ed25519_pubkey)>",
303314 "challenge": "<random_hex>",
304− "signature": "<hex(ed25519_sign(private_key, utf8(challenge)))>",
315+ "signature": "<hex(ed25519_sign(private_key, utf8(\"skrepka-auth-v1:\" + server_host + \":\" + challenge)))>",
305316 "revokeOthers": false
306317 }
307318 ```
319+ 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*).
308320 Setting `revokeOthers: true` invalidates every other live session for this pubkey (useful for "log out other devices").
309321
3103224. Server verifies the signature. On success, returns a session token valid for **1 hour**:
@@ -321,6 +333,8 @@ When the server trusts proxy headers (`trustForwardedFor`, on by default), sessi
321333
322334When a token expires, the server responds with `401`. The client re-authenticates by repeating the challenge-response flow.
323335
336+> **Channel binding.** The signed payload includes the target server's hostname under the `skrepka-auth-v1:` domain-separation tag (step 3). Because the client binds to the host it actually dialed and the server verifies against its own `serverHost`, a relay server cannot forward a challenge it obtained from a *different* server and replay the resulting signature to impersonate the client there: the client's signature commits to the relay's hostname, not the victim server's, so verification at the victim fails. This binding is a **breaking change** — client and server must agree on the signed payload. The host comparison is on the bare hostname only (port and scheme excluded), so the same identity authenticates regardless of the port a server listens on.
337+
324338A successful `/auth/verify` also installs the pubkey in the local presence table and broadcasts an `online` gossip event to known peers (§7).
325339
326340### Error Responses
@@ -367,7 +381,7 @@ The client makes a blocking POST. The server holds the connection open until new
367381}
368382```
369383
370−- `cursor` is a server-side monotonic millisecond timestamp.
384+- `cursor` is a server-side **strictly-monotonic sequence**, seeded from the wall-clock millisecond at assignment but bumped to `previous + 1` whenever messages arrive within the same millisecond. Two messages therefore never share a `receivedAt`, so advancing the cursor past one message can never silently drop another. Treat it as an opaque checkpoint, not a clock.
371385- Advancing past a message acts as an **implicit ack**: when the next poll arrives with a `cursor` greater than or equal to a stored message's `receivedAt`, the server drops the stored message **and any pending federation forwards for it**.
372386- If events are available immediately, the server responds right away. Otherwise it parks the connection on a row-level wait against the mailbox and returns when a new matching message lands or after 25 s, whichever comes first.
373387- An empty page (`events: []`) is returned on timeout, with the cursor echoed back.
@@ -545,7 +559,7 @@ Note: Alice does not need to stay online for steps 4–9. Her server handles fed
545559
546560### Message Ordering
547561
548−Message ordering is **best-effort** and guaranteed only within a single server. Each server's `receivedAt` provides strict ordering for that server's mailbox, and the `cursor` returned by `/poll` reflects this sequence.
562+Message ordering is **best-effort** and guaranteed only within a single server. Each server assigns every message a strictly-monotonic `receivedAt` sequence (unique per message, never colliding even under bursts within one millisecond), which provides total ordering for that server's mailbox; the `cursor` returned by `/poll` reflects this sequence.
549563
550564Across federated servers, messages may arrive out of order due to network latency, retry delays, or differing reception times. Clients SHOULD use the sender-provided `ts` field (from the decrypted plaintext payload, §4) for display ordering and treat `cursor` as a polling checkpoint only, not a global ordering guarantee.
551565
@@ -564,7 +578,7 @@ Skrepka protects message **content and sender identity** from servers and networ
564578| Message confidentiality | E2E encryption (X25519 + HKDF-SHA256 + XChaCha20-Poly1305) |
565579| Sender authenticity | Ed25519 signature on every message |
566580| Sender anonymity vs. server | Sender pubkey is inside the AEAD ciphertext — servers see the recipient but not the sender |
567−| Replay protection | Unique nonce per message; signature binds the recipient pubkey, so a captured blob cannot be replayed against a different recipient |
581+| Replay protection (cross-recipient) | Unique nonce per message; signature binds the recipient pubkey, so a captured blob cannot be replayed against a *different* recipient. This does **not** stop re-delivery of the same blob to the *same* recipient — see *Same-recipient replay* below |
568582| Partial post-compromise | Ephemeral X25519 keys per message — once the blob is no longer stored anywhere, even compromising the recipient's long-term key does not recover the message |
569583
570584### What Is NOT Protected
@@ -573,9 +587,13 @@ Skrepka protects message **content and sender identity** from servers and networ
573587|-----------------------------------|-----------------------------------------------------|
574588| **No forward secrecy** | If a message blob is captured *and* the recipient's long-term X25519 key is later compromised, that specific message can be decrypted. No ratchet means no post-compromise recovery. |
575589| **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. |
576−| **Presence gossip leaks location** | Federated peers learn which server a user is currently connected to. |
590+| **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. |
577591| **No durable delivery** | If the sender's server goes down or all forward retries fail, messages may be lost. |
578−| **Open federation** | Federation endpoints are unauthenticated. The SSRF deny-list and `no_presence` gate limit abuse but do not authenticate peers; a hostile server can join the mesh and observe gossip. |
592+| **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. |
593+| **Same-recipient replay** | Nothing stops a captured blob from being re-delivered to its original recipient. `text` is deduplicated by `id` and `delivery.ack` is idempotent, but `profile` carries no `id` and (in the reference client) is applied unconditionally, so a replayed stale `profile` rolls a contact's cached profile back (§4). |
594+| **Open federation — gossip redirect** | Federation endpoints are unauthenticated. The SSRF deny-list and `no_presence` gate limit abuse but do not authenticate peers. Beyond passively observing gossip, a hostile server can *actively redirect delivery*: by announcing `{eventType:"online", pubkey:<victim>, fromServer:<attacker>}` it causes origin servers to forward the victim's queued ciphertext to the attacker, which harvests ciphertext copies plus recipient/timing/size metadata. |
595+| **Open federation — forward injection** | `/federation/forward` is unauthenticated, so any host can inject arbitrary blobs into a currently-online recipient's mailbox (rate-limited per source IP, and undecryptable blobs are dropped client-side, but they still consume the recipient's poll stream). |
596+| **No cryptographic agility** | The wire format has no version field and a fixed HKDF `info` (§3); the AEAD/KDF/curve cannot be migrated in-band. |
579597| **TOFU only** | A first-time public key is trusted on encounter; users must compare fingerprints out-of-band to detect MITM at first contact. |
580598
581599### Recommendations for Implementers
client/ApiClient.fs+4−1
@@ -75,7 +75,10 @@ module ApiClient =
7575 let body = JsonSerializer.Serialize({| pubkey = identity.PubKeyHex |})
7676 use! challengeDoc = sendRequest client $"{serverUrl}/auth/challenge" body None
7777 let challenge = challengeDoc.RootElement.GetProperty("challenge").GetString()
78− let sigHex = Crypto.signChallenge identity.PrivKey challenge
78+ // Bind the signature to the host we actually dialed, so a relay server
79+ // cannot replay it to a different server (see PROTOCOL.md §6, §10).
80+ let serverHost = Uri(serverUrl).Host.ToLowerInvariant().TrimEnd('.')
81+ let sigHex = Crypto.signChallenge identity.PrivKey serverHost challenge
7982 let body = JsonSerializer.Serialize({| pubkey = identity.PubKeyHex; challenge = challenge; signature = sigHex |})
8083 use! tokenDoc = sendRequest client $"{serverUrl}/auth/verify" body None
8184 let token = tokenDoc.RootElement.GetProperty("token").GetString()
client/Crypto.fs+6−2
@@ -169,8 +169,12 @@ module Crypto =
169169 let _, sk = generateSignKeyPair ()
170170 sk |> identityFromPrivKey |> Option.get
171171
172− let signChallenge (privKey: byte[]) (challenge: string) : string =
173− let message = Encoding.UTF8.GetBytes(challenge)
172+ // Domain-separation tag binding an auth signature to (target server, challenge).
173+ // Distinguishes auth signatures from message signatures (recipientPub ++ compressed)
174+ // and prevents a relay server from replaying the signature to a *different* server.
175+ // `serverHost` is the bare lowercased hostname of the server being authenticated to.
176+ let signChallenge (privKey: byte[]) (serverHost: string) (challenge: string) : string =
177+ let message = Encoding.UTF8.GetBytes($"skrepka-auth-v1:{serverHost}:{challenge}")
174178 signDetached message privKey |> toHex
175179
176180 let private deriveKey ephPub recipientX25519Pub rawSecret =
server.knot+5−1
@@ -824,9 +824,13 @@ handleVerifyAuth = \pubkey challenge signature revokeOthers clientIp -> do
824824 t <- now
825825 let expiry = t + sessionExpiry
826826 token <- genToken 48
827+ -- The client signs (target host, challenge) under a domain-separation tag so a
828+ -- relay server cannot replay the signature to a different server. We verify
829+ -- against our own configured hostname (port stripped), which a relay cannot forge.
830+ let signedText = "skrepka-auth-v1:" ++ beforeColon serverHost ++ ":" ++ challenge
827831 let sigValid = case bytesFromHex pubkey of
828832 Just {value: pk} -> case bytesFromHex signature of
829− Just {value: sigBytes} -> verify pk (textToBytes challenge) sigBytes
833+ Just {value: sigBytes} -> verify pk (textToBytes signedText) sigBytes
830834 _ -> False {}
831835 _ -> False {}
832836 result <- atomic (do