on master help sign in
server.knot: fix 4 bugs + 22 simplifications from audit

Bug fixes: - BUG-1: Add maxPresence (100k) and maxPresencePerServer (5k) caps to handleRecvGossip. Local rows are held out of both caps so a gossip flood cannot evict online users. Prevents unbounded *presence growth from unauthenticated gossip (DoS on every O(n) hot path). - BUG-2: Stop treating 404 no_presence as a peer failure in retryForwardsTo and forkForwardsTo. A 404 means the recipient isn't online at the peer yet (expected, retryable per PROTOCOL.md §7), not that the peer is down. Previously it poisoned the shared per-peer backoff (also gates gossip), suppressing all forwards AND gossip to that peer for 30s-8min from one stale-presence 404. - BUG-3: forwardMatches now includes toKey. Previously matched only on (blob, server), so two forwards with the same blob to different recipients at the same peer would race — completing one dropped the other without delivering it. - BUG-4: Challenge pool now LRU-evicts (capList) instead of returning 503 when maxChallenges is reached. Mirrors the session pool's approach. Prevents a distributed Sybil from blocking all logins. Simplifications: - S1: Absorb dedupe guard into appendMessage (both callers duplicated it) - S2: Extract markDelivered helper (duplicated in retryForwardsTo + forkForwardsTo) - S3: Extract freshFor predicate in doPoll (STM wait + collect shared it) - S4: Collapse 21 string prefixes to isReservedOctetPrefix (6-line numeric check) - S5-S7: Consolidate 5 copies of server-name rationale, 3 copies of federation threat model, delete isBadServerName prose enumeration (~50 lines saved) - S8: Inline isValidBoundedHex (single caller) - S9: Bind beforeColon once in isBadServerName - S10-S14: Move Sybil comment to maxSessions, trim key-sharing dup, fix stale main comment (beforeColon -> authHost), delete orphan comment, consolidate rate-limit rationale - S15-S22: Bind lastForwardedHop once, use afterComma, countWhere consistently, PollEvent type in doPoll, Timestamp not Int for cursor, bind presence filter once in pruneExpired, trim changelog prose

commite4eeb27f4971270a8ace285baa4e2b986d0f6527 authoriko <mail@iko.soy> dateSun 12 Jul 2026, 06:21 (3 months ago) parent8d304b5
1 file changed+305−277
server.knot+305−277
server.knot+305−277
@@ -99,7 +99,25 @@ maxServerNameLen = 128
9999-- Ceiling on outstanding auth challenges held in memory.
100100maxChallenges : Int
101101maxChallenges = 10000
102−-- Ceiling on concurrent active sessions.
102+-- Ceiling on concurrent active sessions. Enforced by `capList` in
103+-- `handleVerifyAuth`, which LRU-evicts the soonest-to-expire sessions once the
104+-- pool is full.
105+--
106+-- LIMITATION — that global LRU eviction is Sybil-floodable. `capList` does not
107+-- care whose sessions it drops: an attacker minting fresh keypairs (free) from
108+-- enough IPs to stay under `authRateLimit` can push `maxSessions` worth of
109+-- throwaway sessions through and evict *live* users' sessions, which shows up
110+-- client-side as a 401 and a forced re-handshake.
111+--
112+-- `maxSessionsPerKey` bounds any single key's share, and the per-IP
113+-- `authRateLimit` bounds the rate from any single source, so the flood needs real
114+-- distributed resources. Deliberately not fixed with a per-IP session cap:
115+-- `clientIp` is empty for every session whenever there is no trusted proxy
116+-- (`clientIpOf`), so such a cap would collapse to a single global bucket in
117+-- exactly the deployments that have no XFF — and even with a proxy it would
118+-- punish carrier-NAT users, where thousands of legitimate clients genuinely do
119+-- share one address. A real fix prices session creation (proof of work, or a
120+-- per-key deposit) rather than counting IPs.
103121maxSessions : Int
104122maxSessions = 10000
105123-- Per-pubkey ceiling on outstanding challenges (prevents one key from exhausting the pool).
@@ -120,6 +138,30 @@ maxChallengesPerKey = 5
120138-- instead of destructive acks — out of scope for v0.1.
121139maxSessionsPerKey : Int
122140maxSessionsPerKey = 10
141+-- Ceiling on gossip-learned (remote) presence rows.
142+--
143+-- `/federation/gossip` is unauthenticated and pubkeys are free to invent, so
144+-- without a cap one peer can grow `*presence` without bound — and every hot path
145+-- that reads it is an O(n) scan (`hasLocalPresence` on every inbound federated
146+-- forward, `refreshLocalPresence` on every /poll). `handleRecvGossip` LRU-evicts
147+-- (`capList`) down to this ceiling.
148+--
149+-- Local rows (`server == serverHost`) are deliberately *exempt* from this cap and
150+-- from `maxPresencePerServer`. They are already bounded by `maxSessions` — one row
151+-- per authenticated session — and a shared ceiling would let a gossip flood evict
152+-- them: gossiped rows expire at `onlineGossipTtl` (90 min) while local rows expire
153+-- at `sessionExpiry` (60 min), so `capList`'s soonest-to-expire eviction would drop
154+-- *our own online users* first, and every inbound federated forward addressed to
155+-- them would then answer `no_presence` (404). Capping only remote rows bounds the
156+-- table without handing an attacker a way to knock local users off the mesh.
157+maxPresence : Int
158+maxPresence = 100000
159+-- Per-peer-server ceiling on gossip-learned presence rows, so a single peer cannot
160+-- consume the whole `maxPresence` pool and evict every other peer's routing
161+-- information. Filling the global pool then requires many distinct hostnames that
162+-- each pass `isBadServerName`.
163+maxPresencePerServer : Int
164+maxPresencePerServer = 5000
123165-- Validity window for an auth challenge before it must be re-issued (1 min).
124166challengeExpiry : Int<Ms>
125167challengeExpiry = 60000
@@ -158,22 +200,13 @@ pollTimeoutMs = 25000
158200rateLimitWindow : Int<Ms>
159201rateLimitWindow = 60000
160202-- Per-client-IP budget for /auth/challenge and /auth/verify per `rateLimitWindow`.
161−-- The normal handshake is one challenge + one verify, so a healthy flow uses 2;
162−-- the budget leaves room for many clients behind one NAT. (Keyed on IP, not on
163−-- the submitted pubkey: keypairs are free to generate, so a pubkey-keyed bucket
164−-- hands an attacker a fresh budget with every rotation — a Sybil flood could
165−-- then mint sessions until `maxSessions` is hit and evict live ones. A pubkey
166−-- key is also abusable in the other direction: flooding a *victim's* pubkey
167−-- would exhaust that victim's login budget. Per-pubkey exhaustion of the
168−-- challenge/session pools is still bounded, by `maxChallengesPerKey` and
169−-- `maxSessionsPerKey` in the handlers.)
203+-- A healthy handshake is one challenge + one verify, so the budget leaves ample
204+-- room for many clients behind one NAT.
170205authRateLimit : Int
171206authRateLimit = 60
172−-- Per-client-IP budget for /poll per `rateLimitWindow`. Clients reconnect
173−-- after `pollTimeoutMs`, so steady state is ~3/min per client; the budget
174−-- leaves room for several clients behind one NAT. (Keyed on IP, not bearer
175−-- token — token-keyed buckets let an attacker mint a fresh bucket per
176−-- invented bearer value.)
207+-- Per-client-IP budget for /poll per `rateLimitWindow`. Clients reconnect after
208+-- `pollTimeoutMs`, so steady state is ~3/min per client; the budget leaves room
209+-- for several clients behind one NAT.
177210pollRateLimit : Int
178211pollRateLimit = 60
179212-- Per-client-IP budget for /messages per `rateLimitWindow`. Each call may
@@ -259,25 +292,13 @@ type PollEvent = {encryptedBlob: BlobHex}
259292-- fallback (the old `GossipUnknown` variant).
260293type GossipEvent = {eventType: Text, pubkey: Maybe Text}
261294
262−-- /messages is all-or-nothing per call: either every message in the batch is
263−-- enqueued (delivered, federated, or buffered for federation), or none are
264−-- and the call returns a non-2xx HTTP status. The per-message rejection code
265−-- "self_send" surfaces in the HTTP error body. Per-client-IP rate limiting is
266−-- enforced by the route's `rateLimit` clause, which short-circuits with 429
267−-- before the handler runs.
268−-- The sender's UI treats every success the same and learns "they got it"
269−-- only via the recipient's E2E ack.
270−
271−-- True when `s` is non-empty lowercase hex of even length, with `maxLen` ceiling.
272−isValidBoundedHex : Int -> Text -> Bool
273−isValidBoundedHex = \maxLen s -> length s <= maxLen && isValidHex s
274295-- True when `s` is lowercase hex of exactly `exactLen` characters (even by definition).
275296isValidExactHex : Int -> Text -> Bool
276297isValidExactHex = \exactLen s -> length s == exactLen && isValidHex s
277298-- True when `s` is lowercase hex within [`minBlobLen`, `maxBlobLen`]. The floor
278299-- rejects blobs too short to be a real envelope; see `minBlobLen`.
279300isValidBlobHex : Text -> Bool
280−isValidBlobHex = \s -> length s >= minBlobLen && isValidBoundedHex maxBlobLen s
301+isValidBlobHex = \s -> length s >= minBlobLen && length s <= maxBlobLen && isValidHex s
281302-- Hex-encoded Ed25519 public key (32 bytes → 64 chars exact).
282303type PubkeyHex = Text where isValidExactHex 64
283304-- Hex-encoded ciphertext or opaque blob; bounded to [`minBlobLen`, `maxBlobLen`]
@@ -290,6 +311,19 @@ type SignatureHex = Text where isValidExactHex 128
290311-- (`handleRecvGossip`); keeping it out of the type predicate lets the default
291312-- `serverHost = "localhost"` flow through presence rows in a non-federated
292313-- single-node deployment.
314+--
315+-- WHY SERVER NAMES STAY `Text` IN STATE (the canonical note; other sites point
316+-- here). Every server name held in `*presence`, `*forwards`, `*forwardFailures`
317+-- and `Message.deliveredTo` is plain `Text`, never `ServerName`. They all
318+-- originate in `serverHost` — a startup-overridable scalar, so a refined type
319+-- cannot be given a bare literal default and the predicate can only be checked at
320+-- runtime (`localServerName`). `ServerName` is therefore enforced exactly where it
321+-- can be: at the `/federation/gossip` route boundary, where the name is
322+-- peer-supplied (`fromServer: ServerName`). Handlers that receive the refined type
323+-- widen it once, up front (`let server = (serverName : Text)`), because Knot does
324+-- not implicitly coerce between a refined type and its base inside a comparison —
325+-- `f.toServer == server` would otherwise fail to typecheck. Widening is always
326+-- sound (the predicate only adds constraints); narrowing is what needs `refine`.
293327isValidServerName : Text -> Bool
294328isValidServerName = \s -> length s <= maxServerNameLen && s == toLower (stripTrailingDot s)
295329type ServerName = Text where isValidServerName
@@ -309,10 +343,6 @@ type Session = {token: Text, pubkey: PubkeyHex, expiresAt: Timestamp, ip: Text}
309343-- presence re-announce of `toKey` doesn't trigger a duplicate delivery.
310344-- (Single-field record rows, not bare names: the Knot runtime cannot persist
311345-- a nested relation of scalars.)
312−-- Server names are plain `Text`, not `ServerName`: they are sourced from
313−-- `serverHost`, which is a startup-overridable scalar the predicate cannot be
314−-- checked on at declaration time (see `localServerName`). Pubkeys are refined —
315−-- every write site can prove the predicate.
316346type Message = {toKey: PubkeyHex, encryptedBlob: BlobHex, receivedAt: Timestamp, deliveredTo: [{server: Text}]}
317347-- Last-known location of a pubkey, either local (`server == serverHost`) or
318348-- learned from a peer via gossip. Multiple entries per pubkey are allowed.
@@ -347,6 +377,8 @@ type ForwardFailure = {server: Text, failedAt: Timestamp, failures: Int}
347377-- Mailbox: ciphertexts addressed to local recipients, awaiting poll or federation forward.
348378*messages : [Message]
349379-- Last-known location of each pubkey (local sessions and gossiped remote presence).
380+-- Remote (gossiped) rows are capped by `maxPresence` / `maxPresencePerServer` in
381+-- `handleRecvGossip`; local rows are exempt and bounded by `maxSessions` instead.
350382*presence : [PresenceEntry]
351383-- Cross-server outbox: messages queued for delivery to a peer, with retry counts.
352384*forwards : [ForwardEntry]
@@ -372,17 +404,22 @@ type ForwardFailure = {server: Text, failedAt: Timestamp, failures: Int}
372404-- runs before the handler, so it cannot tell a valid session from an invented
373405-- one, and an attacker varying a self-chosen value would get a fresh bucket per
374406-- value. That rules out the bearer token (any string) and the submitted pubkey
375−-- (any keypair) alike — see `authRateLimit`. Each route has an independent
376−-- bucket store, so endpoints using the same key strategy don't share budget
377−-- across routes. Buckets persist in the runtime's `_knot_rate_limits` SQLite
378−-- table; rejection responds 429 before the handler runs.
407+-- (any keypair) alike. A pubkey key is abusable in both directions: an attacker
408+-- rotating keypairs draws a fresh budget with every rotation (a Sybil flood could
409+-- then push sessions through until `maxSessions` starts evicting live ones), and
410+-- flooding a *victim's* pubkey would exhaust that victim's own login budget.
411+-- Per-pubkey exhaustion of the challenge/session pools is bounded separately, by
412+-- `maxChallengesPerKey` and `maxSessionsPerKey` in the handlers. Each route has an
413+-- independent bucket store, so endpoints using the same key strategy don't share
414+-- budget across routes. Buckets persist in the runtime's `_knot_rate_limits`
415+-- SQLite table; rejection responds 429 before the handler runs.
379416
380417-- Suffix of `s` after its last comma, trimmed. X-Forwarded-For is a
381418-- comma-separated chain that each proxy appends to; only the final entry —
382419-- the one written by the nearest trusted proxy — is reliable for bucketing.
383420lastForwardedHop : Text -> Text
384421lastForwardedHop = \s -> if contains "," s
385−then lastForwardedHop (drop 1 s)
422+then lastForwardedHop (afterComma s)
386423else trim s
387424
388425-- The client address for rate-limit bucketing: the last X-Forwarded-For hop
@@ -390,8 +427,8 @@ else trim s
390427-- otherwise the TCP connection address.
391428pickClientIp : Maybe Text -> Text -> Text
392429pickClientIp = \h connIp -> case h of
393− Just {value: ips} -> if trustForwardedFor && lastForwardedHop ips != ""
394− then lastForwardedHop ips
430+ Just {value: ips} -> if trustForwardedFor
431+ then let hop = lastForwardedHop ips in if hop != "" then hop else connIp
395432 else connIp
396433 Nothing {} -> connIp
397434
@@ -448,6 +485,14 @@ capList = \items maxN -> drop (max 0 (count items - maxN)) (sortBy (\x -> x.expi
448485capPerKey : [{pubkey: PubkeyHex, expiresAt: Timestamp | r}] -> PubkeyHex -> Int -> [{pubkey: PubkeyHex, expiresAt: Timestamp | r}]
449486capPerKey = \items pubkey maxN -> union (filter (\x -> not (x.pubkey == pubkey)) items) (capList (filter (\x -> x.pubkey == pubkey) items) (maxN - 1))
450487
488+-- Cap entries matching `server` to maxN, leaving every other server's entries
489+-- untouched. Same shape as `capPerKey` on a different field, but caps to `maxN`
490+-- rather than `maxN - 1`: the sole caller (`handleRecvGossip`) applies this
491+-- *after* merging the peer's new rows in, not before adding one more. Keeps one
492+-- peer from crowding every other peer out of the `maxPresence` pool.
493+capPerServer : [{server: Text, expiresAt: Timestamp | r}] -> Text -> Int -> [{server: Text, expiresAt: Timestamp | r}]
494+capPerServer = \items server maxN -> union (filter (\x -> not (x.server == server)) items) (capList (filter (\x -> x.server == server) items) maxN)
495+
451496-- Random hex token: one random hex char per recursive call. `n` is the
452497-- desired length in characters (so n=48 yields 24 random bytes of entropy).
453498-- Session bearers, /auth challenges, and federation auth nonces all use n=48
@@ -523,14 +568,12 @@ authHost = \s -> if take 1 s == "["
523568then throughBracket s
524569else beforeColon s
525570
526−-- True when the host part is a bare integer (no dots) — a decimal or octal
527−-- "dword" IP like `2130706433` or `017700000001` (both = 127.0.0.1). These
528−-- carry no dotted octets, so the prefix list below never matches them, yet
529−-- they resolve to the same internal addresses.
571+-- True when the host is a bare integer (no dots) — a decimal or octal "dword" IP
572+-- like `2130706433` or `017700000001` (both = 127.0.0.1). These carry no dotted
573+-- octets, so the octet checks below never match them, yet they resolve to the same
574+-- internal addresses. Takes the already-port-stripped host (`beforeColon sn`).
530575isNumericHost : Text -> Bool
531−isNumericHost = \sn ->
532− let host = beforeColon sn in
533− host != "" && all (\c -> contains c "0123456789") (chars host)
576+isNumericHost = \host -> host != "" && all (\c -> contains c "0123456789") (chars host)
534577
535578-- Decimal value of a single hex/ascii digit character.
536579digitValue : Text -> Int
@@ -636,6 +679,21 @@ anyOctetLeadingZero = \host ->
636679 let rest = afterDot host in
637680 (length octet > 1 && take 1 octet == "0") || (rest != "" && anyOctetLeadingZero rest)
638681
682+-- True when the host's first two labels name a reserved IPv4 range: the zero net
683+-- (0/8), loopback (127/8), and the RFC1918 blocks 10/8, 172.16/12, 192.168/16,
684+-- plus link-local 169.254/16.
685+--
686+-- Deliberately *not* gated on `isDottedNumeric`, unlike `isReservedIPv4` below:
687+-- these run as leading string labels too, matching the literal `"10."` / `"172.16."`
688+-- prefix tests this replaces. So "10.example.com" stays blocked. A non-numeric
689+-- first label parses to -1 via `decimalValue` and matches nothing, so an ordinary
690+-- hostname is never caught.
691+isReservedOctetPrefix : Text -> Bool
692+isReservedOctetPrefix = \host ->
693+ let o1 = decimalValue (beforeDot host) in
694+ let o2 = decimalValue (beforeDot (afterDot host)) in
695+ o1 == 0 || o1 == 10 || o1 == 127 || (o1 == 172 && o2 >= 16 && o2 <= 31) || (o1 == 192 && o2 == 168) || (o1 == 169 && o2 == 254)
696+
639697-- Block private/reserved/internal hostnames (SSRF defense).
640698--
641699-- LIMITATION — this check is purely lexical: it inspects the *string* a peer
@@ -647,29 +705,19 @@ anyOctetLeadingZero = \host ->
647705-- free to answer differently the second time — a DNS-rebinding TOCTOU). Closing
648706-- it needs resolution and connection to be fused: resolve the name, reject the
649707-- resulting IP against the same reserved ranges, and connect to *that* IP —
650−-- which the `fetch` in use does not expose. Treat the list below as a guard
708+-- which the `fetch` in use does not expose. Treat the checks below as a guard
651709-- against accidental and naive-literal SSRF, not against a motivated attacker
652−-- with a nameserver. Same class of gap as the percent-encoding one noted next.
710+-- with a nameserver. Percent-encoding tricks are likewise not covered.
711+--
712+-- Enforced on inbound requests (`handleRecvGossip`, so a peer can't claim a bogus
713+-- `fromServer`) and again at every federation egress site (`sendBatchedGossip`,
714+-- `retryPendingForwards`, `forkForwardsTo`) as defense-in-depth — state persisted
715+-- before a denylist tightening, or any future ingress gap, must not steer a
716+-- `fetch` at an internal host.
653717--
654−-- Skips percent-encoding
655−-- tricks but covers the high-value cases: `localhost`, RFC1918 ranges (10/8,
656−-- 172.16/12, 192.168/16) plus link-local
657−-- 169.254/16, the IPv4 zero net, CGNAT 100.64/10, benchmarking 198.18/15,
658−-- the TEST-NET ranges 192.0.2/24 and 203.0.113/24, and multicast/reserved
659−-- 224/4+240/4 (any first octet >= 224, via `isReservedIPv4`), bare IPv6
660−-- literals (any string with more than one `:` — covers loopback
661−-- `::`/`::1`, link-local `fe80::/10`, ULA `fc00::/7`, globals like `2001:db8::1`),
662−-- bracketed IPv6 literals, integer/octal "dword" IPv4 (`isNumericHost`), the
663−-- `0x`-prefixed hex form, octal dotted-octet IPv4 (any leading-zero octet, via
664−-- `hasLeadingZeroOctet` — the resolver reads "0177.0.0.1" as 127.0.0.1), and the
665−-- `.local` / `.internal` / `.arpa` / `.onion`
666−-- suffixes. Enforced on inbound requests (`handleRecvGossip`, so a peer can't
667−-- claim a bogus `fromServer`) and again at every federation egress site
668−-- (`sendBatchedGossip`, `retryPendingForwards`, `forkForwardsTo`) as
669−-- defense-in-depth — state persisted before a denylist tightening, or any
670−-- future ingress gap, must not steer a `fetch` at an internal host.
671718-- A legitimate `host:port` server name has exactly one `:`, so the multi-colon
672−-- check leaves that case intact.
719+-- check rejects bare IPv6 literals (loopback `::`/`::1`, link-local `fe80::/10`,
720+-- ULA `fc00::/7`, globals like `2001:db8::1`) while leaving `host:port` intact.
673721--
674722-- The name is trailing-dot-stripped (`stripTrailingDot`) before every check.
675723-- The system resolver treats `localhost.` / `foo.internal.` (DNS root form) the
@@ -680,34 +728,10 @@ anyOctetLeadingZero = \host ->
680728-- keeps the deny-list sound independently of that upstream check — the same
681729-- "don't trust prior validation" posture the rest of this note describes.
682730isBadServerName : Text -> Bool
683−isBadServerName = \raw -> let sn = stripTrailingDot raw in sn == "" || sn == "localhost" || hasPrefix "localhost:" sn || isNumericHost sn || isReservedIPv4 (beforeColon sn) || hasLeadingZeroOctet (beforeColon sn) || hasPrefix "0x" sn || any (\c -> not contains c "0123456789abcdefghijklmnopqrstuvwxyz-.:") (chars sn) || countChar ":" sn > 1 || any (\p -> hasPrefix p sn) [
684− ".",
685− ":",
686− "[",
687− "127.",
688− "10.",
689− "192.168.",
690− "169.254.",
691− "192.0.2.",
692− "203.0.113.",
693− "0.",
694− "172.16.",
695− "172.17.",
696− "172.18.",
697− "172.19.",
698− "172.20.",
699− "172.21.",
700− "172.22.",
701− "172.23.",
702− "172.24.",
703− "172.25.",
704− "172.26.",
705− "172.27.",
706− "172.28.",
707− "172.29.",
708− "172.30.",
709− "172.31."
710−] || any (\s -> hasSuffix s sn) [".localhost", ".local", ".internal", ".arpa", ".onion"]
731+isBadServerName = \raw ->
732+ let sn = stripTrailingDot raw in
733+ let host = beforeColon sn in
734+ sn == "" || sn == "localhost" || hasPrefix "localhost:" sn || isNumericHost host || isReservedOctetPrefix host || isReservedIPv4 host || hasLeadingZeroOctet host || hasPrefix "0x" sn || any (\c -> not contains c "0123456789abcdefghijklmnopqrstuvwxyz-.:") (chars sn) || countChar ":" sn > 1 || any (\p -> hasPrefix p sn) [".", ":", "[", "192.0.2.", "203.0.113."] || any (\s -> hasSuffix s sn) [".localhost", ".local", ".internal", ".arpa", ".onion"]
711735
712736-- ============================================
713737-- Auth helpers
@@ -839,12 +863,8 @@ hasLocalPresence = \pres pk t -> any (\p -> p.pubkey == pk && isLocalServer p.se
839863-- Project items through `getServer`, drop our own hostname, and dedupe.
840864-- Used to derive peer recipient lists from forwards/presence/etc.
841865--
842−-- Returns `[Text]`, the base type of `ServerName`. Server names stay unrefined
843−-- internally: they originate in `serverHost`, a startup-overridable scalar whose
844−-- predicate can only be checked at runtime (`localServerName`). The refined
845−-- `ServerName` is enforced where it can be — at the `/federation/gossip` route
846−-- boundary (`fromServer: ServerName`) and at federation egress, where
847−-- `isBadServerName` applies the SSRF deny-list the predicate deliberately omits.
866+-- Returns `[Text]`, the base type of `ServerName` — see `type ServerName` for why
867+-- server names stay unrefined everywhere in state.
848868collectPeers : [a] -> (a -> Text) -> [Text]
849869collectPeers = \items getServer -> filter (\s -> not isLocalServer s) (map getServer items)
850870
@@ -858,18 +878,41 @@ currentSeq = \rows -> fold (\acc r -> max acc r.value) (0 : Timestamp) rows
858878-- Append one ciphertext to the local mailbox under a fresh monotonic
859879-- sequence. Row-level STM retry on `*messages` wakes any parked /poll
860880-- transaction whose filter matches the new row. Caller wraps in `atomic`.
881+--
882+-- Idempotent on (toKey, encryptedBlob): an identical ciphertext already in the
883+-- mailbox is left alone rather than appended again. Both ingress paths need this
884+-- — a client retry after a lost response (`handleSendMessages`) and a peer's
885+-- re-forward after a lost 200 (`handleForwardMessage`) carry the identical blob,
886+-- and the fresh `seqTs` below would otherwise make `union` keep both rows and
887+-- double-deliver. Re-reading `*messages` per call also collapses duplicate blobs
888+-- appearing twice within one batch.
861889appendMessage : PubkeyHex -> BlobHex -> Int<Ms> -> IO {rw *messages, rw *seqCounter} {}
862890appendMessage = \toKey encryptedBlob t -> do
863891 msgs <- *messages
864− seqRows <- *seqCounter
865− let seqTs = max t (currentSeq seqRows + 1)
866− *seqCounter = [{value: seqTs}]
867− *messages = union msgs [{toKey, encryptedBlob, receivedAt: seqTs, deliveredTo: []}]
868−
869−-- True when `f` is the queued forward of `blob` to `server`. Used to dedupe
870−-- per-(blob, server) entries on the forwards outbox.
871−forwardMatches : BlobHex -> Text -> ForwardEntry -> Bool
872−forwardMatches = \blob server f -> f.encryptedBlob == blob && f.toServer == server
892+ when (not any (\m -> m.toKey == toKey && m.encryptedBlob == encryptedBlob) msgs) (do
893+ seqRows <- *seqCounter
894+ let seqTs = max t (currentSeq seqRows + 1)
895+ *seqCounter = [{value: seqTs}]
896+ *messages = union msgs [{toKey, encryptedBlob, receivedAt: seqTs, deliveredTo: []}])
897+
898+-- Record that `server` has accepted a federated forward of this message, so a
899+-- later presence re-announce of `toKey` doesn't re-forward the same blob to a peer
900+-- that already has it. No-op when the peer is already listed. Caller wraps in
901+-- `atomic`.
902+markDelivered : PubkeyHex -> BlobHex -> Text -> IO {rw *messages} {}
903+markDelivered = \toKey blob server -> do
904+ msgs <- *messages
905+ *messages = map (\m -> if m.toKey == toKey && m.encryptedBlob == blob && not any (\d -> d.server == server) m.deliveredTo
906+ then {m.toKey, m.encryptedBlob, m.receivedAt, deliveredTo: union m.deliveredTo [{server}]}
907+ else m) msgs
908+
909+-- True when `f` is the queued forward of `blob` to `toKey` at `server`. Used to
910+-- dedupe entries on the forwards outbox. All three fields matter: the same blob
911+-- can legitimately be queued to the same peer for two *different* recipients (a
912+-- sender fanning one ciphertext out to several keys), and matching on
913+-- (blob, server) alone would let completing one of them drop the others.
914+forwardMatches : PubkeyHex -> BlobHex -> Text -> ForwardEntry -> Bool
915+forwardMatches = \toKey blob server f -> f.toKey == toKey && f.encryptedBlob == blob && f.toServer == server
873916
874917-- Replace any local presence rows for `pubkey` with one fresh row at
875918-- `expiry`, and return the post-update peer broadcast set. Caller wraps in
@@ -901,8 +944,9 @@ pruneExpired = \t -> atomic do
901944 *forwardFailures = filter (\f -> t - f.failedAt < forwardFailureMaxAge) ff
902945 -- presence: remember locally-expired keys for offline gossip
903946 pres <- *presence
904− *presence = filter (\p -> p.expiresAt > t) pres
905− let liveLocalPubkeys = map (\p -> p.pubkey) (filter (\p -> isLocalServer p.server && p.expiresAt > t) pres)
947+ let live = filter (\p -> p.expiresAt > t) pres
948+ *presence = live
949+ let liveLocalPubkeys = map (\p -> p.pubkey) (filter (\p -> isLocalServer p.server) live)
906950 yield (map (\p -> p.pubkey) (filter (\p -> p.expiresAt <= t && isLocalServer p.server && not elem p.pubkey liveLocalPubkeys) pres))
907951
908952-- ============================================
@@ -957,17 +1001,25 @@ sendBatchedGossip = \peers events -> case localServerName of
9571001-- as forwards to the newly-known server.
9581002
9591003-- Attempt every forward in `batch` to one peer, in order, and report whether any
960−-- attempt failed. Drops successful entries from `*forwards` and records the peer
961−-- on the message's `deliveredTo`; on failure bumps the retry count and gives up
962−-- after `maxForwardRetries`.
1004+-- attempt was a genuine peer failure. Drops successful entries from `*forwards` and
1005+-- records the peer on the message's `deliveredTo`; on failure bumps the retry count
1006+-- and gives up after `maxForwardRetries`.
9631007--
964−-- Deliberately does *not* touch `*forwardFailures` — the caller records at most
965−-- one failure per peer per sweep from the Bool this returns. Recording per failing
966−-- *message* (as this loop used to) let a peer holding N buffered messages take N
967−-- backoff steps in a single sweep: one unreachable peer with a full outbox jumped
968−-- straight to `retryBackoffMax` and `forwardFailureMaxAge` then pinned it there for
969−-- 24 h, so the peer stayed un-retried long after it came back. The counter is meant
970−-- to count consecutive failed *sweeps*, and now does.
1008+-- INVARIANT — this must not touch `*forwardFailures`. The caller records at most one
1009+-- failure per peer per sweep, from the Bool returned here; recording per failing
1010+-- *message* would let one unreachable peer with N buffered messages take N backoff
1011+-- steps in a single sweep and pin itself at `retryBackoffMax` for `forwardFailureMaxAge`.
1012+-- The counter counts consecutive failed *sweeps*.
1013+--
1014+-- A 404 is NOT a peer failure. `handleForwardMessage` answers 404 `no_presence`
1015+-- whenever the recipient is not *currently* online at that peer, which is an
1016+-- expected, retryable outcome (PROTOCOL.md §7) — the peer is healthy and answering.
1017+-- But `fetch` returns `Err` for every status >= 400, so treating any `Err` as a
1018+-- failure would feed a routine 404 into `recordFailure`, and `*forwardFailures` is
1019+-- shared with gossip: one stale-presence 404 would suppress all forwards *and* all
1020+-- gossip to that peer for a full backoff step (30 s–8 min). So a 404 leaves the peer
1021+-- unpenalized and the forward row untouched — no retry bump either, since the row is
1022+-- waiting on the recipient's presence, not on a delivery that went wrong.
9711023--
9721024-- Recursive rather than `forEach` because the result has to be accumulated across
9731025-- iterations; `sortBy` fixes the iteration order that `head`/`drop 1` then walk.
@@ -976,23 +1028,23 @@ retryForwardsTo = \server batch -> case head batch of
9761028 Nothing {} -> yield (False {})
9771029 Just {value: f} -> do
9781030 result <- fetch (federationUrl server) (ForwardMessage {f.toKey, f.encryptedBlob})
979− let success = case result of
980− Err _ -> False {}
981− Ok _ -> True {}
982− let isMatch = forwardMatches f.encryptedBlob server
1031+ let status = case result of
1032+ Err {error: e} -> e.status
1033+ Ok _ -> 200
1034+ let success = status < 400
1035+ -- Recipient simply isn't online at the peer yet: hold the row, blame no one.
1036+ let deferred = status == 404
1037+ let isMatch = forwardMatches f.toKey f.encryptedBlob server
9831038 let isDone = \x -> isMatch x && (success || x.retries + 1 >= maxForwardRetries)
9841039 atomic (do
985− cur <- *forwards
986− *forwards = map (\x -> if isMatch x
987− then {x.toServer, x.toKey, x.encryptedBlob, retries: x.retries + 1}
988− else x) (filter (\x -> not isDone x) cur)
989− when success (do
990− msgs <- *messages
991− *messages = map (\m -> if m.toKey == f.toKey && m.encryptedBlob == f.encryptedBlob && not any (\d -> d.server == server) m.deliveredTo
992− then {m.toKey, m.encryptedBlob, m.receivedAt, deliveredTo: union m.deliveredTo [{server}]}
993− else m) msgs))
1040+ when (not deferred) (do
1041+ cur <- *forwards
1042+ *forwards = map (\x -> if isMatch x
1043+ then {x.toServer, x.toKey, x.encryptedBlob, retries: x.retries + 1}
1044+ else x) (filter (\x -> not isDone x) cur))
1045+ when success (markDelivered f.toKey f.encryptedBlob server))
9941046 restFailed <- retryForwardsTo server (drop 1 batch)
995− yield (not success || restFailed)
1047+ yield ((not success && not deferred) || restFailed)
9961048
9971049-- Walk pending forwards grouped by destination and retry each peer's backlog (up
9981050-- to maxBatchSize per peer) via `/federation/forward`. One failure record per peer
@@ -1012,13 +1064,12 @@ retryPendingForwards = do
10121064 cur <- *forwards
10131065 *forwards = filter (\x -> x.toServer != server) cur)
10141066 else when (shouldRetryServer server t failures) (do
1015− -- Order the per-peer backlog deterministically before `take maxBatchSize`:
1016− -- `[a]` is an unordered set, so an unsorted `take` returns an
1017− -- implementation-defined subset that could keep retrying the same entries
1018− -- while starving others past the cap. A stable key drains a fixed prefix as
1019− -- entries succeed (removed) or hit `maxForwardRetries` (dropped). (Same class
1020− -- of fix as `forwardQueuedToServer`'s `sortBy receivedAt`; `ForwardEntry`
1021− -- carries no timestamp, so key on the unique blob.)
1067+ -- INVARIANT — `[a]` is an unordered set, so the backlog must be sorted before
1068+ -- `take maxBatchSize` or the batch is an implementation-defined subset that
1069+ -- could keep retrying the same entries while starving others past the cap. A
1070+ -- stable key drains a fixed prefix as entries succeed (removed) or hit
1071+ -- `maxForwardRetries` (dropped). `ForwardEntry` carries no timestamp, so key on
1072+ -- the unique blob.
10221073 let batch = take maxBatchSize (sortBy (\f -> f.encryptedBlob) (filter (\f -> f.toServer == server) fwds))
10231074 anyFailed <- retryForwardsTo server batch
10241075 -- `count batch > 0` guards the clear: `maxBatchSize` is startup-overridable,
@@ -1032,28 +1083,18 @@ retryPendingForwards = do
10321083-- Skips messages already delivered to `server` (tracked via Message.deliveredTo)
10331084-- so a presence re-announce after a TTL lapse doesn't duplicate delivery.
10341085--
1035−-- SECURITY: this function trusts `*presence`, and `*presence` is writable by any
1036−-- unauthenticated host via `/federation/gossip`. A forged `online` announcement
1037−-- therefore redirects a victim's queued ciphertext to the attacker's server —
1038−-- see the gossip-redirect note on `handleRecvGossip` for the full threat model
1039−-- and why it is an accepted v0.1 limitation. Do not add a caller that widens
1040−-- what this forwards (e.g. dropping the `deliveredTo` filter) without revisiting
1041−-- that note.
1086+-- SECURITY: this is the function the gossip-redirect attack in the section header
1087+-- above actually cashes out through — it routes on `*presence`, which any
1088+-- unauthenticated host can write. Do not add a caller that widens what this
1089+-- forwards (e.g. dropping the `deliveredTo` filter) without revisiting that note.
10421090forwardQueuedToServer : PubkeyHex -> ServerName -> IO {rw *forwards, r *messages} {}
10431091forwardQueuedToServer = \pubkey serverName -> atomic (do
1044− -- Widen the refined `ServerName` to its base `Text` once, up front. Server names
1045− -- are plain `Text` everywhere in state (`ForwardEntry.toServer`,
1046− -- `Message.deliveredTo`, `PresenceEntry.server` — see `collectPeers` for why),
1047− -- and Knot does not implicitly coerce between a refined type and its base inside
1048− -- a comparison: `f.toServer == server` would try to use the `Text` field where a
1049− -- `ServerName` is required and fail to typecheck. Widening is always sound (the
1050− -- predicate only ever adds constraints); narrowing is what needs `refine`.
10511092 let server = (serverName : Text)
10521093 msgs <- *messages
10531094 fwds <- *forwards
1054− let existing = count (filter (\f -> f.toServer == server) fwds)
1095+ let existing = countWhere (\f -> f.toServer == server) fwds
10551096 let budget = max 0 (maxForwardsPerServer - existing)
1056− let candidates = filter (\m -> m.toKey == pubkey && not any (forwardMatches m.encryptedBlob server) fwds && not any (\d -> d.server == server) m.deliveredTo) msgs
1097+ let candidates = filter (\m -> m.toKey == pubkey && not any (forwardMatches m.toKey m.encryptedBlob server) fwds && not any (\d -> d.server == server) m.deliveredTo) msgs
10571098 let newFwds = map (\m -> {toServer: server, m.toKey, m.encryptedBlob, retries: 0}) (take budget (sortBy (\m -> m.receivedAt) candidates))
10581099 *forwards = union fwds newFwds)
10591100
@@ -1074,6 +1115,15 @@ forwardQueuedToServer = \pubkey serverName -> atomic (do
10741115-- Capped per-key (maxChallengesPerKey); per-client-IP rate limiting is enforced
10751116-- by the route's `rateLimit` clause. The IP is recorded so /auth/verify can
10761117-- require the same client.
1118+--
1119+-- A full pool LRU-evicts rather than rejecting, exactly as `handleVerifyAuth` does
1120+-- for `maxSessions`. Answering 503 once `maxChallenges` is reached would hand any
1121+-- attacker who can fill the pool a total login outage — nobody can start a
1122+-- handshake, including clients whose challenges are already in flight. Evicting the
1123+-- soonest-to-expire challenge instead degrades to "an abandoned challenge may be
1124+-- dropped before its 60 s window elapses", which costs the affected client one
1125+-- retry. The freshly-issued row always survives its own `capList`: it carries the
1126+-- latest `expiresAt` of any live challenge by construction.
10771127handleChallenge : PubkeyHex -> Text -> IO {rw *challenges, clock, random} (Result HttpError {challenge: Text, expiresAt: Timestamp})
10781128handleChallenge = \pubkey clientIp -> do
10791129 t <- now
@@ -1082,11 +1132,8 @@ handleChallenge = \pubkey clientIp -> do
10821132 atomic (do
10831133 ch <- *challenges
10841134 let cappedActive = capPerKey (filter (\c -> c.expiresAt > t) ch) pubkey maxChallengesPerKey
1085− if count cappedActive >= maxChallenges
1086− then yield (Err {error: {status: 503, message: "capacity"}})
1087− else do
1088− *challenges = union cappedActive [{pubkey, challenge, expiresAt, ip: clientIp}]
1089− yield (Ok {value: {challenge, expiresAt}}))
1135+ *challenges = capList (union cappedActive [{pubkey, challenge, expiresAt, ip: clientIp}]) maxChallenges
1136+ yield (Ok {value: {challenge, expiresAt}}))
10901137
10911138-- POST /auth/verify: validate `signature` over `challenge` for `pubkey`.
10921139-- On success, mints a session token, records local presence (`pubkey` is now
@@ -1119,22 +1166,8 @@ handleVerifyAuth = \pubkey challenge signature revokeOthers clientIp -> do
11191166 then filter (\s -> s.pubkey != pubkey) ses
11201167 else capPerKey ses pubkey maxSessionsPerKey
11211168 let newSession = {token, pubkey, expiresAt: expiry, ip: clientIp}
1122− -- LIMITATION — global LRU eviction is Sybil-floodable. `capList` evicts the
1123− -- soonest-to-expire sessions once the global `maxSessions` pool is full, and
1124− -- it does not care whose they are: an attacker minting fresh keypairs (free)
1125− -- from enough IPs to stay under `authRateLimit` can push `maxSessions` worth
1126− -- of throwaway sessions through and evict *live* users' sessions, which
1127− -- shows up client-side as a 401 and a forced re-handshake.
1128− --
1129− -- `maxSessionsPerKey` bounds any single key's share, and the per-IP
1130− -- `authRateLimit` bounds the rate from any single source, so the flood needs
1131− -- real distributed resources. Deliberately not fixed with a per-IP session
1132− -- cap: `clientIp` is empty for every session whenever there is no trusted
1133− -- proxy (`clientIpOf`), so such a cap would collapse to a single global
1134− -- bucket in exactly the deployments that have no XFF — and even with a proxy
1135− -- it would punish carrier-NAT users, where thousands of legitimate clients
1136− -- genuinely do share one address. A real fix prices session creation (proof
1137− -- of work, or a per-key deposit) rather than counting IPs.
1169+ -- LRU-evicts the soonest-to-expire session when the pool is full; this global
1170+ -- eviction is Sybil-floodable, see the LIMITATION note on `maxSessions`.
11381171 *sessions = capList (union pruned [newSession]) maxSessions
11391172 peers <- refreshLocalPresence pubkey expiry
11401173 yield (Just {value: peers}))
@@ -1157,7 +1190,7 @@ handleVerifyAuth = \pubkey challenge signature revokeOthers clientIp -> do
11571190-- don't time us out mid-session, then runs `doPoll` to collect events (or
11581191-- block until a matching message arrives or the timeout elapses) and
11591192-- assembles the response.
1160−handlePoll : Int -> Text -> Text -> IO {r *seqCounter, r *sessions, rw *forwardFailures, rw *forwards, rw *messages, rw *presence, network, clock} (Result HttpError {cursor: Timestamp, events: [PollEvent]})
1193+handlePoll : Timestamp -> Text -> Text -> IO {r *seqCounter, r *sessions, rw *forwardFailures, rw *forwards, rw *messages, rw *presence, network, clock} (Result HttpError {cursor: Timestamp, events: [PollEvent]})
11611194handlePoll = \cursor authorization clientIp -> do
11621195 t <- now
11631196 setup <- atomic (do
@@ -1196,12 +1229,20 @@ handlePoll = \cursor authorization clientIp -> do
11961229-- the wait transaction reads `*messages` and `retry`s until a row matching
11971230-- this pubkey/cursor is committed; the timer wins on idle. Either way we
11981231-- re-collect at the new wall time, so a timeout returns an empty page.
1199−doPoll : {clampedCursor: Timestamp, pubkey: PubkeyHex | a} -> Timestamp -> IO {r *messages, clock} {cursor: Timestamp, events: [{encryptedBlob: BlobHex}]}
1232+
1233+-- Messages addressed to `pk`, unexpired at `tnow`, and newer than `cursor`. Shared
1234+-- by `doPoll`'s collect and STM-wait arms so the two predicates cannot drift: a
1235+-- wait that accepted a row the collect then filtered out (say, an expired row still
1236+-- above the cursor) would wake the poll, return an empty page with an unchanged
1237+-- cursor, and spin the client through an immediate re-poll into the same wakeup.
1238+freshFor : PubkeyHex -> Timestamp -> Timestamp -> [Message] -> [Message]
1239+freshFor = \pk cursor tnow msgs -> filter (\m -> m.toKey == pk && tnow - m.receivedAt < messageMaxAge && m.receivedAt > cursor) msgs
1240+
1241+doPoll : {clampedCursor: Timestamp, pubkey: PubkeyHex | a} -> Timestamp -> IO {r *messages, clock} {cursor: Timestamp, events: [PollEvent]}
12001242doPoll = \s t -> do
12011243 let collectAt = \tnow -> atomic (do
12021244 msgs <- *messages
1203− let fresh = filter (\m -> m.toKey == s.pubkey && tnow - m.receivedAt < messageMaxAge && m.receivedAt > s.clampedCursor) msgs
1204− let limited = take maxPollEvents (sortBy (\m -> m.receivedAt) fresh)
1245+ let limited = take maxPollEvents (sortBy (\m -> m.receivedAt) (freshFor s.pubkey s.clampedCursor tnow msgs))
12051246 yield {
12061247 events: map (\m -> {m.encryptedBlob}) limited,
12071248 cursor: fold (\acc m -> max acc m.receivedAt) s.clampedCursor limited
@@ -1213,19 +1254,12 @@ doPoll = \s t -> do
12131254 -- `race` requires both arms to share a single effect row, so each arm
12141255 -- carries the union {r *messages, clock} — the STM wait gets a `now`,
12151256 -- the timer gets a no-op `*messages` read.
1216− -- The wait predicate must match `collectAt`'s exactly, age guard included:
1217− -- an expired row that still sits above the cursor (not yet swept by
1218− -- `pruneExpired`) would otherwise satisfy the wait, wake the poll, then be
1219− -- filtered out by `collectAt` — returning an empty page whose cursor is
1220− -- unchanged, so the client immediately re-polls into the same wakeup. That
1221− -- is a tight spin for as long as the row survives.
12221257 race
12231258 (do
12241259 tw <- now
12251260 atomic (do
12261261 msgs <- *messages
1227− let fresh = filter (\m -> m.toKey == s.pubkey && tw - m.receivedAt < messageMaxAge && m.receivedAt > s.clampedCursor) msgs
1228− if count fresh > 0 then yield {} else retry))
1262+ if count (freshFor s.pubkey s.clampedCursor tw msgs) > 0 then yield {} else retry))
12291263 (do
12301264 atomic (do _ <- *messages; yield {})
12311265 sleep pollTimeoutMs)
@@ -1236,11 +1270,9 @@ doPoll = \s t -> do
12361270-- receivedAt <= cursor, plus their pending forwards.
12371271--
12381272-- The ack is destructive and keyed on `pubkey` alone — no session identity enters
1239−-- into it. Two concurrent sessions for the same keypair therefore share one
1240−-- mailbox and race: whichever polls first acks the rows out from under the other,
1241−-- which never sees them. That is an accepted limitation, not a bug to fix here —
1242−-- one keypair per device is the model. See `maxSessionsPerKey`.
1243−ackPolledMessages : PubkeyHex -> Int -> IO {rw *forwards, rw *messages} {}
1273+-- into it, so two sessions sharing one keypair race over one mailbox. Accepted
1274+-- limitation; see `maxSessionsPerKey`.
1275+ackPolledMessages : PubkeyHex -> Timestamp -> IO {rw *forwards, rw *messages} {}
12441276ackPolledMessages = \pubkey cursor -> do
12451277 msgs <- *messages
12461278 let matches = \m -> m.toKey == pubkey && m.receivedAt <= cursor
@@ -1260,6 +1292,8 @@ ackPolledMessages = \pubkey cursor -> do
12601292-- Duplicates are silently treated as success so retries are idempotent.
12611293-- Per-client-IP send rate is enforced by the route's `rateLimit` clause.
12621294-- Federated forwards are kicked off in background forks after the atomic.
1295+-- The sender's UI treats every success the same and learns "they got it" only via
1296+-- the recipient's E2E ack.
12631297
12641298-- POST /messages: send a list of ciphertexts atomically. Self-send is
12651299-- rejected up-front; the remaining batch is committed inside one atomic
@@ -1294,14 +1328,11 @@ handleSendMessages = \messages authorization clientIp -> do
12941328 remoteServers: collectPeers (filter (\p -> p.pubkey == m.to && p.expiresAt > t) pres) (\p -> p.server)
12951329 }) messages
12961330 forEach staged (\c -> do
1297− -- Dedupe like the federation ingress path (`handleForwardMessage`):
1298− -- a client retry after a lost response carries the identical
1299− -- ciphertext, and `appendMessage`'s fresh `seqTs` would otherwise
1300− -- make `union` keep both rows and double-deliver. Re-reading
1301− -- `*messages` each iteration also collapses duplicate blobs that
1302− -- appear twice within one batch.
1331+ -- Read `*messages` *before* the append: `delivered` below must see the
1332+ -- pre-existing row, and re-reading each iteration is also what lets
1333+ -- `appendMessage`'s own dedupe collapse a blob repeated within one batch.
13031334 existingMsgs <- *messages
1304− when (not any (\m -> m.toKey == c.to && m.encryptedBlob == c.encryptedBlob) existingMsgs) (appendMessage c.to c.encryptedBlob t)
1335+ appendMessage c.to c.encryptedBlob t
13051336 -- Peers that already hold this blob — from an earlier send of the
13061337 -- identical ciphertext that was forwarded then pruned from
13071338 -- `*forwards`. A brand-new message has empty `deliveredTo`, so this
@@ -1315,7 +1346,7 @@ handleSendMessages = \messages authorization clientIp -> do
13151346 -- is skipped.
13161347 when federationEnabled (do
13171348 fwds <- *forwards
1318− let newFwds = map (\s -> {toServer: s, toKey: c.to, c.encryptedBlob, retries: 0}) (filter (\s -> not (any (forwardMatches c.encryptedBlob s) fwds) && not elem s delivered && countWhere (\f -> f.toServer == s) fwds < maxForwardsPerServer) c.remoteServers)
1349+ let newFwds = map (\s -> {toServer: s, toKey: c.to, c.encryptedBlob, retries: 0}) (filter (\s -> not (any (forwardMatches c.to c.encryptedBlob s) fwds) && not elem s delivered && countWhere (\f -> f.toServer == s) fwds < maxForwardsPerServer) c.remoteServers)
13191350 *forwards = union fwds newFwds))
13201351 yield staged)
13211352 when federationEnabled (forEach committed (\c -> fork (forkForwardsTo c.to c.encryptedBlob c.remoteServers)))
@@ -1331,22 +1362,23 @@ handleSendMessages = \messages authorization clientIp -> do
13311362-- the message), and `deliveredTo` prevents re-forwards to peers that already
13321363-- have the message. On failure the forwards row stays so
13331364-- `retryPendingForwards` can re-attempt with backoff.
1365+--
1366+-- A 404 is not a peer failure — see `retryForwardsTo` for why `no_presence` must
1367+-- not reach `recordFailure`. Here the row is left queued for the retry sweep and
1368+-- the peer's backoff (shared with gossip) is not touched.
13341369forkForwardsTo : PubkeyHex -> BlobHex -> [Text] -> IO {rw *forwardFailures, rw *forwards, rw *messages, network, clock} {}
13351370forkForwardsTo = \to encryptedBlob servers -> forEach servers (\server -> when (not isBadServerName server) (do
13361371 t <- now
13371372 result <- fetch (federationUrl server) (ForwardMessage {toKey: to, encryptedBlob})
1338− let same = forwardMatches encryptedBlob server
1373+ let same = forwardMatches to encryptedBlob server
13391374 case result of
1340− Err _ -> recordFailure server t
1375+ Err {error: e} -> when (e.status != 404) (recordFailure server t)
13411376 Ok _ -> do
13421377 clearFailure server t
13431378 atomic (do
13441379 fwds <- *forwards
13451380 *forwards = filter (\x -> not same x) fwds
1346− msgs <- *messages
1347− *messages = map (\m -> if m.toKey == to && m.encryptedBlob == encryptedBlob && not any (\d -> d.server == server) m.deliveredTo
1348− then {m.toKey, m.encryptedBlob, m.receivedAt, deliveredTo: union m.deliveredTo [{server}]}
1349− else m) msgs)))
1381+ markDelivered to encryptedBlob server)))
13501382
13511383-- ============================================
13521384-- Handler: federation receive
@@ -1356,6 +1388,40 @@ forkForwardsTo = \to encryptedBlob servers -> forEach servers (\server -> when (
13561388-- currently-online local keys. Both handlers refuse with 403 when
13571389-- `federationEnabled` is off, matching the outbound paths — a single-node
13581390-- deployment accepts no peer writes to `*presence`/`*messages`.
1391+--
1392+-- SECURITY — THE OPEN-FEDERATION THREAT MODEL (accepted v0.1 limitations; the
1393+-- canonical statement, referenced by the handlers below and by
1394+-- `forwardQueuedToServer`). Neither endpoint authenticates its caller and nothing
1395+-- ties an event to the key it speaks for, so any host that can reach us may:
1396+--
1397+-- * announce an arbitrary victim pubkey as `online` at itself. We write that
1398+-- presence row, and the next `forwardQueuedToServer` hands the victim's queued
1399+-- ciphertext to the attacker's host — a *gossip redirect*.
1400+-- * append an arbitrary blob to any currently-online local key's mailbox, which
1401+-- the recipient then polls down — *unauthenticated mailbox injection*.
1402+--
1403+-- Neither breaks confidentiality or allows forgery. Payloads are sealed to the
1404+-- recipient's key and the sender's identity and signature live *inside* the AEAD
1405+-- (PROTOCOL.md §5), so a redirected blob cannot be read and an injected blob that
1406+-- does not open — or opens without a valid inner signature — is dropped by the
1407+-- client and can never impersonate a contact. What the attacker gets is metadata a
1408+-- relay is otherwise never supposed to concentrate (which keys receive traffic, how
1409+-- much, when), unsolicited storage and bandwidth, and — because our copy is pruned
1410+-- once marked `deliveredTo` — the ability to *withhold* delivery by steering a
1411+-- ciphertext at a host that will never serve it. Confidentiality-preserving;
1412+-- availability- and metadata-affecting.
1413+--
1414+-- Bounded, not prevented, by: the SSRF deny-list (`isBadServerName`), the
1415+-- `no_presence` gate, per-IP rate limits, the presence caps (`maxPresence` /
1416+-- `maxPresencePerServer`), the gossip TTL (`onlineGossipTtl`) capping how long one
1417+-- forged row survives, `maxBlobLen`, the `appendMessage` dedupe, and
1418+-- `messageMaxAge` pruning.
1419+--
1420+-- The fix for both is peer authentication: require a gossip event to carry a
1421+-- signature from the pubkey it announces, and/or authenticate the peer relay itself
1422+-- so an unknown host cannot write to `*presence` / `*messages` at all. Deferred past
1423+-- v0.1 because it needs a key-to-home-server binding the protocol does not yet
1424+-- define. PROTOCOL.md §10 enumerates what open federation does and does not protect.
13591425
13601426-- POST /federation/gossip: ingest peer presence events. The transport-level
13611427-- `fromServer` is the home server for every event. Drops every (pubkey,
@@ -1365,40 +1431,15 @@ forkForwardsTo = \to encryptedBlob servers -> forEach servers (\server -> when (
13651431-- `forwardQueuedToServer`. Events with an unrecognized `eventType` or no
13661432-- `pubkey` are skipped (forward-compat) rather than rejecting the batch.
13671433--
1368−-- SECURITY (accepted v0.1 limitation — gossip redirect / ciphertext theft):
1369−-- this endpoint is unauthenticated, and nothing ties a gossip event to the key
1370−-- it claims to speak for. Any host that can reach us may announce an arbitrary
1371−-- victim pubkey as `online` at itself; we write that presence row, and the very
1372−-- next `forwardQueuedToServer` hands the victim's queued ciphertext to the
1373−-- attacker's host. The attacker cannot *read* those messages — the payload is
1374−-- sealed to the victim's key, and the sender's identity and signature live
1375−-- inside the AEAD (PROTOCOL.md §5) — but it does harvest metadata a relay is
1376−-- otherwise never supposed to concentrate: which keys are receiving traffic,
1377−-- how much, and when. Worse, because our copy is pruned once it is marked
1378−-- `deliveredTo` the attacker's host, a forged announcement can also *withhold*
1379−-- delivery: the ciphertext goes to a host that will never serve it to the
1380−-- victim's client. So this is a confidentiality-preserving but availability-
1381−-- and metadata-affecting attack.
1382−--
1383−-- Bounded, not prevented, by: the SSRF deny-list (`isBadServerName`), the
1384−-- `no_presence` gate on the inbound side, per-IP rate limits, and the gossip
1385−-- TTL (`onlineGossipTtl`) capping how long one forged row survives.
1386−--
1387−-- The fix is peer authentication: require a gossip event to carry a signature
1388−-- from the pubkey it announces (proving the key really did authenticate at
1389−-- `fromServer`), and/or authenticate the peer relay itself so an unknown host
1390−-- cannot inject presence at all. Deferred past v0.1 because it needs a
1391−-- key-to-home-server binding the protocol does not yet define.
1392−-- PROTOCOL.md §10 enumerates what open federation does and does not protect.
1434+-- SECURITY: this is the gossip-redirect ingress described in the section header
1435+-- above — a forged `online` row written here is what steals a victim's routing.
1436+-- The presence caps applied below are the only thing bounding how much of
1437+-- `*presence` an unauthenticated peer can own.
13931438handleRecvGossip : [GossipEvent] -> ServerName -> IO {r *messages, rw *forwards, rw *presence, clock} (Result HttpError {})
13941439handleRecvGossip = \events fromServer -> do
13951440 t <- now
1396− -- `fromServer` arrives refined from the route boundary — that is exactly where
1397− -- the `ServerName` predicate is worth enforcing, since the name is peer-supplied.
1398− -- Internally, though, every server name in state is plain `Text` (see
1399− -- `collectPeers`), so widen once here: a `PresenceEntry.server` field cannot be
1400− -- built from, or compared against, the refined type. `forwardQueuedToServer`
1401− -- below still takes the refined `fromServer` — it wants the checked name.
1441+ -- Widen the peer-supplied `ServerName` once (see `type ServerName`);
1442+ -- `forwardQueuedToServer` below still takes the refined name.
14021443 let from = (fromServer : Text)
14031444 if not federationEnabled
14041445 then yield (Err {error: {status: 403, message: "federation_disabled"}})
@@ -1428,7 +1469,16 @@ handleRecvGossip = \events fromServer -> do
14281469 else acc
14291470 Nothing {} -> acc) {touched: ([] : [PubkeyHex]), added: ([] : [PresenceEntry])} events
14301471 let kept = filter (\p -> p.server != from || not elem p.pubkey collected.touched) pres
1431− *presence = union kept collected.added
1472+ let merged = union kept collected.added
1473+ -- Cap the gossip-learned rows: per-peer first, so one peer cannot crowd every
1474+ -- other peer's routing information out of the pool, then globally. Local rows
1475+ -- are held out of both caps and re-unioned afterwards — folding them into the
1476+ -- same LRU would let a gossip flood evict our own online users, because their
1477+ -- `sessionExpiry` (60 min) is *sooner* than a gossiped row's `onlineGossipTtl`
1478+ -- (90 min) and `capList` drops soonest-to-expire first. See `maxPresence`.
1479+ let localRows = filter (\p -> isLocalServer p.server) merged
1480+ let remoteRows = filter (\p -> not isLocalServer p.server) merged
1481+ *presence = union localRows (capList (capPerServer remoteRows from maxPresencePerServer) maxPresence)
14321482 yield (filter (\pk -> not any (\p -> p.pubkey == pk && p.server == from && p.expiresAt > t) pres) (map (\p -> p.pubkey) collected.added)))
14331483 fork (forEach newlyOnline (\pk -> forwardQueuedToServer pk fromServer))
14341484 yield (Ok {value: {}})
@@ -1440,29 +1490,13 @@ handleRecvGossip = \events fromServer -> do
14401490-- actively-polling client and get stuck. If B refuses, A retries via
14411491-- `retryPendingForwards` against the next presence target.
14421492-- Re-delivery of an already-stored (toKey, blob) pair is treated as success
1443−-- without a second append, so a sender whose success response was lost can
1444−-- retry without duplicating the message in the recipient's mailbox.
1445−--
1446−-- SECURITY (accepted v0.1 limitation — unauthenticated mailbox injection):
1447−-- this endpoint authenticates nobody. Any host that can reach us may append an
1448−-- arbitrary blob to any currently-online local key's mailbox, and the recipient
1449−-- will poll it down like any other message. This is a spam/flood surface, not a
1450−-- forgery one: the sender's identity and signature live *inside* the AEAD
1451−-- (PROTOCOL.md §5), so an injected blob that the recipient's key cannot open —
1452−-- or that opens but carries no valid inner signature — is dropped by the client
1453−-- and can never impersonate a contact. What an attacker gets is unsolicited
1454−-- rows in the recipient's mailbox and in `*messages` (storage + bandwidth), not
1455−-- a message the recipient will attribute to someone else.
1456−--
1457−-- Bounded, not prevented, by: the `no_presence` gate (only currently-online
1458−-- local keys are injectable, so an attacker must first observe or force
1459−-- presence), per-IP rate limits on the route, `maxBlobLen`, the dedupe below
1460−-- (the same blob cannot be appended twice), and `messageMaxAge` pruning.
1493+-- without a second append (`appendMessage` is idempotent), so a sender whose
1494+-- success response was lost can retry without duplicating the message.
14611495--
1462−-- The fix is the same one the gossip-redirect note calls for: authenticate the
1463−-- peer relay, so an unknown host cannot write to `*messages` at all. Deferred
1464−-- past v0.1. PROTOCOL.md §10 enumerates what open federation does and does not
1465−-- protect.
1496+-- SECURITY: this is the unauthenticated-mailbox-injection ingress described in the
1497+-- section header above — an unsolicited row lands here. The `no_presence` gate is
1498+-- what keeps the surface to keys that are already online, so an attacker must first
1499+-- observe or force presence.
14661500handleForwardMessage : PubkeyHex -> BlobHex -> IO {rw *messages, r *presence, rw *seqCounter, clock} (Result HttpError {})
14671501handleForwardMessage = \toKey encryptedBlob -> do
14681502 t <- now
@@ -1473,8 +1507,7 @@ handleForwardMessage = \toKey encryptedBlob -> do
14731507 if not hasLocalPresence pres toKey t
14741508 then yield (Err {error: {status: 404, message: "no_presence"}})
14751509 else do
1476− msgs <- *messages
1477− when (not any (\m -> m.toKey == toKey && m.encryptedBlob == encryptedBlob) msgs) (appendMessage toKey encryptedBlob t)
1510+ appendMessage toKey encryptedBlob t
14781511 yield (Ok {value: {}}))
14791512
14801513-- ============================================
@@ -1554,17 +1587,12 @@ main = do
15541587 case localServerName of
15551588 Nothing {} -> logWarn ("serverHost '" ++ serverHost ++ "' is not a valid ServerName (must be lowercase, no trailing dot, at most " ++ show maxServerNameLen ++ " chars); federation egress disabled")
15561589 _ -> yield {}
1557− -- serverHost is still the compiled-in default. The auth signature is bound to
1558− -- the hostname the client dialed (`skrepka-auth-v1:<host>:<challenge>`,
1559− -- PROTOCOL.md §6), and /auth/verify checks it against `beforeColon serverHost`
1560− -- — so any client that reaches this relay under a name other than "localhost"
1561− -- fails verification with 401, and no session is ever issued. That is exactly
1562− -- the install.sh deployment (Caddy fronts a public DOMAIN, clients dial it),
1563− -- which passes `--serverHost=<domain>` — but the current Knot runtime accepts
1564− -- and then IGNORES `--<name>=<value>` constant overrides at startup, so the
1565− -- flag is inert and serverHost stays "localhost". A public relay must bake the
1566− -- real name at build time (`knot build server.knot --serverHost=<host>`).
1567− -- Warn loudly rather than let every login fail silently.
1590+ -- /auth/verify checks the client's host-bound signature against
1591+ -- `authHost serverHost`, so a relay still on the compiled-in default 401s every
1592+ -- login the moment a client dials it under any other name — which is exactly the
1593+ -- install.sh deployment (Caddy fronts a public DOMAIN). `--serverHost=<domain>` is
1594+ -- accepted and then IGNORED by the current Knot runtime, so it must be baked in at
1595+ -- build time instead. Warn loudly rather than let every login fail silently.
15681596 when (serverHost == "localhost") (logWarn "serverHost is the default 'localhost': every client that dials this relay under any other hostname will fail /auth/verify (the auth signature is host-bound, PROTOCOL.md §6), so no session is ever issued. A public relay must set serverHost to its real hostname; note the Knot runtime currently ignores --serverHost at startup, so bake it at build time: knot build server.knot --serverHost=<host>")
15691597 when (federationEnabled && count seedPeerList > 0) (logInfo (" seed peers: " ++ show seedPeerList))
15701598 fork backgroundPrune