on master help sign in
Add Ed25519 federation signing: eliminate verify callback round-trips with signature-based auth and cached peer keys
57b23f5iko
server.knot+160−28
@@ -18,6 +18,13 @@ serverHost = "localhost:8080"
18listenPort = 8080
18listenPort = 8080
19maintenanceSecret = "" -- set to enable /maintenance/* and /stats endpoints
19maintenanceSecret = "" -- set to enable /maintenance/* and /stats endpoints
20federationScheme = "https://" -- scheme for federation HTTP calls
20federationScheme = "https://" -- scheme for federation HTTP calls
21+-- Ed25519 federation signing keys (hex-encoded). When both are set, outbound federation
22+-- requests are signed instead of using verify callbacks (eliminates the round-trip).
23+-- Inbound requests with "Signed" auth are verified locally; "Bearer" auth falls back
24+-- to the existing verify callback for backward compatibility with older peers.
25+-- Empty = signing disabled, use verify callbacks only.
26+serverFederationPrivateKeyHex = ""
27+serverFederationPublicKeyHex = ""
21-- NOTE: listenHost is not configurable — Knot's listen primitive only accepts a port.
28-- NOTE: listenHost is not configurable — Knot's listen primitive only accepts a port.
22-- To restrict the bind address (e.g., 127.0.0.1 behind a reverse proxy), use
29-- To restrict the bind address (e.g., 127.0.0.1 behind a reverse proxy), use
23-- firewall rules (e.g., iptables -A INPUT -p tcp --dport 8080 -s 127.0.0.1 -j ACCEPT)
30-- firewall rules (e.g., iptables -A INPUT -p tcp --dport 8080 -s 127.0.0.1 -j ACCEPT)
@@ -191,7 +198,7 @@ serverBuildVersion = "2026-04-26e"
191-- Peers log warnings when they receive requests from older versions.
198-- Peers log warnings when they receive requests from older versions.
192-- Backward compatible: older peers ignore unknown fields (Knot's JSON parser
199-- Backward compatible: older peers ignore unknown fields (Knot's JSON parser
193-- drops unknown keys). 0 = pre-versioning peer (field missing or unset).
200-- drops unknown keys). 0 = pre-versioning peer (field missing or unset).
194−federationProtocolVersion = 12
201+federationProtocolVersion = 13
195
202
196-- Max seenBy list length in gossip events (bounds memory and bandwidth per event)
203-- Max seenBy list length in gossip events (bounds memory and bandwidth per event)
197maxSeenByLen = 20
204maxSeenByLen = 20
@@ -239,6 +246,12 @@ federationUrl = \server -> federationScheme ++ server
239maxPendingVerifications = 10000
246maxPendingVerifications = 10000
240verificationTokenExpiry : Int<Ms>
247verificationTokenExpiry : Int<Ms>
241verificationTokenExpiry = 30000 -- 30 seconds
248verificationTokenExpiry = 30000 -- 30 seconds
249+-- Peer public key cache (for Ed25519 federation signing)
250+maxPeerPublicKeys = 100
251+peerKeyTtlMs : Int<Ms>
252+peerKeyTtlMs = 86400000 -- 24 hours; peer keys change rarely (server restart or rotation)
253+peerKeyRefetchCooldownMs : Int<Ms>
254+peerKeyRefetchCooldownMs = 60000 -- 1 minute; minimum interval between re-fetch attempts per server
242-- IP-based rate limiting (reverse proxy sets ClientIp header)
255-- IP-based rate limiting (reverse proxy sets ClientIp header)
243maxIpRatePerIp = 10 -- max auth requests per IP per window
256maxIpRatePerIp = 10 -- max auth requests per IP per window
244ipRateWindowMs : Int<Ms>
257ipRateWindowMs : Int<Ms>
@@ -833,6 +846,11 @@ maxPeerLatencyEntries = 100
833-- Cached successful verify callbacks per-server (short-lived, reduces outbound HTTP calls)
846-- Cached successful verify callbacks per-server (short-lived, reduces outbound HTTP calls)
834*verificationCache : [{server: Text, scope: Text, verifiedAt: Timestamp}]
847*verificationCache : [{server: Text, scope: Text, verifiedAt: Timestamp}]
835
848
849+-- Cached peer Ed25519 public keys for signature-based federation auth.
850+-- Fetched from GET /federation/pubkey on first contact, cached with peerKeyTtlMs TTL.
851+-- failedAt tracks last failed fetch to enforce peerKeyRefetchCooldownMs.
852+*peerPublicKeys : [{server: Text, pubkey: Text, fetchedAt: Timestamp, failedAt: Timestamp}]
853+
836-- Per-IP unique key tracking: detects when a single IP authenticates many distinct pubkeys
854-- Per-IP unique key tracking: detects when a single IP authenticates many distinct pubkeys
837-- (key-rotation abuse bypassing per-key rate limits). Pruned with other rate limiters.
855-- (key-rotation abuse bypassing per-key rate limits). Pruned with other rate limiters.
838*uniqueKeyIpRates : [UniqueKeyIpEntry]
856*uniqueKeyIpRates : [UniqueKeyIpEntry]
@@ -1058,6 +1076,11 @@ route FederationApi where
1058route FederationVerifyApi where
1076route FederationVerifyApi where
1059 POST {token: Text, scope: Text} /federation/verify headers {clientIp: Text} -> {ok: Bool} = VerifyToken
1077 POST {token: Text, scope: Text} /federation/verify headers {clientIp: Text} -> {ok: Bool} = VerifyToken
1060
1078
1079+-- Ed25519 federation public key endpoint: peers fetch this to verify signed requests.
1080+-- Unauthenticated (like /health and /ready). Rate-limited via health rate limiter.
1081+route FederationPubkeyApi where
1082+ GET /federation/pubkey -> {pubkey: Text, ok: Bool} = FederationPubkey
1083+
1061-- Triggers pruning of expired state and retry of pending federation ops
1084-- Triggers pruning of expired state and retry of pending federation ops
1062route MaintenanceApi where
1085route MaintenanceApi where
1063 POST {} /maintenance/prune headers {authorization: Text}
1086 POST {} /maintenance/prune headers {authorization: Text}
@@ -1192,7 +1215,7 @@ route StatsApi where
1192route ReadyApi where
1215route ReadyApi where
1193 GET /ready -> {ready: Bool} = Ready
1216 GET /ready -> {ready: Bool} = Ready
1194
1217
1195−route Api = AuthApi | ClientApi | FederationApi | FederationVerifyApi | MaintenanceApi | HealthApi | StatsApi | ReadyApi
1218+route Api = AuthApi | ClientApi | FederationApi | FederationVerifyApi | FederationPubkeyApi | MaintenanceApi | HealthApi | StatsApi | ReadyApi
1196
1219
1197-- ============================================
1220-- ============================================
1198-- Helpers
1221-- Helpers
@@ -1651,17 +1674,23 @@ isAllowedByAllowlist = \server allowed -> count allowed == 0 || any allowed (\a
1651-- Generate a single-use token for outbound federation auth.
1674-- Generate a single-use token for outbound federation auth.
1652-- scope binds the token to a request type ("gossip", "forward", "ack") so a token
1675-- scope binds the token to a request type ("gossip", "forward", "ack") so a token
1653-- issued for one operation cannot authorize a different one.
1676-- issued for one operation cannot authorize a different one.
1654−generateFederationAuth : Text -> Text -> IO {network} Text
1677+generateFederationAuth : Text -> Timestamp -> IO {network} Text
1655−generateFederationAuth = \scope server -> do
1678+generateFederationAuth = \scope requestTimestamp ->
1679+ if serverFederationPrivateKeyHex != ""
1680+ then do
1681+ let message = textToBytes (scope ++ "|" ++ show requestTimestamp ++ "|" ++ serverHostN)
1682+ let sig = sign (bytesFromHex serverFederationPrivateKeyHex) message
1683+ yield ("Signed " ++ bytesToHex sig)
1684+ else do
1656− token <- genToken
1685+ token <- genToken
1657− t <- now
1686+ t <- now
1658− atomic do
1687+ atomic do
1659− pending <- *pendingVerifications
1688+ pending <- *pendingVerifications
1660− let updated = union pending [{token: token, createdAt: t, scope: scope}]
1689+ let updated = union pending [{token: token, createdAt: t, scope: scope}]
1661− set *pendingVerifications = if count updated > maxPendingVerifications
1690+ set *pendingVerifications = if count updated > maxPendingVerifications
1662− then evictOneOldest updated (\p -> p.createdAt)
1691+ then evictOneOldest updated (\p -> p.createdAt)
1663− else updated
1692+ else updated
1664− yield ("Bearer " ++ token)
1693+ yield ("Bearer " ++ token)
1665
1694
1666-- Verify an inbound federation token. Checks a short-lived per-server cache first
1695-- Verify an inbound federation token. Checks a short-lived per-server cache first
1667-- to reduce outbound HTTP calls (a peer sending N requests/second would otherwise
1696-- to reduce outbound HTTP calls (a peer sending N requests/second would otherwise
@@ -3600,6 +3629,9 @@ pruneExpired = \t -> do
3600 -- Prune expired federation verification tokens
3629 -- Prune expired federation verification tokens
3601 pending <- *pendingVerifications
3630 pending <- *pendingVerifications
3602 set *pendingVerifications = do p <- pending; where t - p.createdAt < verificationTokenExpiry; yield p
3631 set *pendingVerifications = do p <- pending; where t - p.createdAt < verificationTokenExpiry; yield p
3632+ -- Prune expired peer public key cache entries
3633+ peerKeys <- *peerPublicKeys
3634+ set *peerPublicKeys = do k <- peerKeys; where t - k.fetchedAt < peerKeyTtlMs; yield k
3603 -- Prune expired forward nonces (round-robin: 4 of 16 buckets per cycle)
3635 -- Prune expired forward nonces (round-robin: 4 of 16 buckets per cycle)
3604 pruneNonceBucketsRoundRobin t
3636 pruneNonceBucketsRoundRobin t
3605 -- Prune gossip event rate entries
3637 -- Prune gossip event rate entries
@@ -3657,7 +3689,7 @@ sendBatchedGossipToServer = \server allEvents hops ->
3657 in if count unseen == 0 then yield {}
3689 in if count unseen == 0 then yield {}
3658 else forEachBatch unseen maxGossipEvents (\batch -> do
3690 else forEachBatch unseen maxGossipEvents (\batch -> do
3659 t <- now
3691 t <- now
3660− auth <- generateFederationAuth "gossip" server
3692+ auth <- generateFederationAuth "gossip" t
3661 corrId <- genTokenN 1
3693 corrId <- genTokenN 1
3662 result <- fetchFederation server (RecvGossip {
3694 result <- fetchFederation server (RecvGossip {
3663 authorization: auth,
3695 authorization: auth,
@@ -3889,6 +3921,87 @@ checkPreAuth = \clientIp t -> do
3889 if ipLimited then yield "ip_rate_limited"
3921 if ipLimited then yield "ip_rate_limited"
3890 else yield ""
3922 else yield ""
3891
3923
3924+-- ============================================
3925+-- Ed25519 Federation Signing
3926+-- ============================================
3927+-- When serverFederationPrivateKeyHex is configured, outbound federation requests are
3928+-- signed with Ed25519 instead of using random tokens + verify callbacks. Inbound
3929+-- requests with "Signed" auth are verified locally using the sender's cached public key.
3930+-- "Bearer" auth falls back to the existing verify callback for backward compatibility.
3931+
3932+-- GET /federation/pubkey: Return this server's Ed25519 federation public key.
3933+-- Unauthenticated. Rate-limited via health rate limiter (same as /ready).
3934+handleFederationPubkey = \respond -> do
3935+ t <- now
3936+ healthLimited <- checkHealthRate t
3937+ if healthLimited then yield (respond {pubkey: "", ok: False {}})
3938+ else yield (respond {pubkey: serverFederationPublicKeyHex, ok: serverFederationPublicKeyHex != ""})
3939+
3940+-- Fetch and cache a peer's Ed25519 public key for signature verification.
3941+-- Returns the hex-encoded public key, or "" if unavailable.
3942+-- Checks cache first (peerKeyTtlMs TTL), fetches from /federation/pubkey on miss.
3943+-- Enforces peerKeyRefetchCooldownMs between re-fetch attempts to prevent DoS.
3944+fetchPeerPublicKey : Text -> Timestamp -> IO {network} Text
3945+fetchPeerPublicKey = \server t -> do
3946+ cached <- atomic do
3947+ keys <- *peerPublicKeys
3948+ yield (headOf (do k <- keys; where k.server == server; yield k))
3949+ case cached of
3950+ Just {value: entry} ->
3951+ if t - entry.fetchedAt < peerKeyTtlMs && entry.pubkey != ""
3952+ then yield entry.pubkey
3953+ else if entry.failedAt > 0 && t - entry.failedAt < peerKeyRefetchCooldownMs
3954+ then yield ""
3955+ else fetchAndCachePeerKey server t
3956+ Nothing {} -> fetchAndCachePeerKey server t
3957+
3958+-- Fetch a peer's public key from /federation/pubkey and cache the result.
3959+-- On failure, caches the failure timestamp to enforce cooldown.
3960+fetchAndCachePeerKey : Text -> Timestamp -> IO {network} Text
3961+fetchAndCachePeerKey = \server t -> do
3962+ result <- fetchFederation server (FederationPubkey {})
3963+ case result of
3964+ Err e -> do
3965+ logWarn ("federation: pubkey fetch from " ++ server ++ " failed: " ++ show e)
3966+ atomic do
3967+ keys <- *peerPublicKeys
3968+ let updated = union (do k <- keys; where k.server != server; yield k) [{server: server, pubkey: "", fetchedAt: t, failedAt: t}]
3969+ set *peerPublicKeys = if count updated > maxPeerPublicKeys then evictOneOldest updated (\k -> k.fetchedAt) else updated
3970+ yield ""
3971+ Ok r ->
3972+ if r.ok && r.pubkey != "" then do
3973+ atomic do
3974+ keys <- *peerPublicKeys
3975+ let updated = union (do k <- keys; where k.server != server; yield k) [{server: server, pubkey: r.pubkey, fetchedAt: t, failedAt: 0}]
3976+ set *peerPublicKeys = if count updated > maxPeerPublicKeys then evictOneOldest updated (\k -> k.fetchedAt) else updated
3977+ yield r.pubkey
3978+ else do
3979+ atomic do
3980+ keys <- *peerPublicKeys
3981+ let updated = union (do k <- keys; where k.server != server; yield k) [{server: server, pubkey: "", fetchedAt: t, failedAt: t}]
3982+ set *peerPublicKeys = if count updated > maxPeerPublicKeys then evictOneOldest updated (\k -> k.fetchedAt) else updated
3983+ yield ""
3984+
3985+-- Verify an inbound Ed25519 signed federation request.
3986+-- Reconstructs the signed message from (scope, requestTimestamp, fromServer),
3987+-- fetches the peer's public key (cached), and verifies the signature.
3988+-- On verification failure, re-fetches the key once (handles peer key rotation
3989+-- or restart with new key) and retries before rejecting.
3990+verifyFederationSignature : Text -> Text -> Text -> Timestamp -> IO {network} Bool
3991+verifyFederationSignature = \fromServer signatureHex scope requestTimestamp -> do
3992+ t <- now
3993+ pubkeyHex <- fetchPeerPublicKey fromServer t
3994+ if pubkeyHex == "" then yield (False {})
3995+ else do
3996+ let message = textToBytes (scope ++ "|" ++ show requestTimestamp ++ "|" ++ fromServer)
3997+ let sigValid = verify (bytesFromHex pubkeyHex) message (bytesFromHex signatureHex)
3998+ if sigValid then yield (True {})
3999+ else do
4000+ -- Key may be stale (peer restarted with new key). Re-fetch once and retry.
4001+ refetched <- fetchAndCachePeerKey fromServer t
4002+ if refetched == "" || refetched == pubkeyHex then yield (False {})
4003+ else yield (verify (bytesFromHex refetched) message (bytesFromHex signatureHex))
4004+
3892-- Federation auth wrapper. Intentionally does NOT call checkPreAuth because:
4005-- Federation auth wrapper. Intentionally does NOT call checkPreAuth because:
3893-- (a) Soft drain (phase 1) blocks new client auth but must not block federation —
4006-- (a) Soft drain (phase 1) blocks new client auth but must not block federation —
3894-- peers need to deliver messages and acks to existing recipients during drain.
4007-- peers need to deliver messages and acks to existing recipients during drain.
@@ -3897,7 +4010,7 @@ checkPreAuth = \clientIp t -> do
3897-- (c) Federation routes don't carry a ClientIp header (peer-to-peer, not client-facing),
4010-- (c) Federation routes don't carry a ClientIp header (peer-to-peer, not client-facing),
3898-- so IP-based rate limiting doesn't apply.
4011-- so IP-based rate limiting doesn't apply.
3899withFederationAuth = \authorization t respond fromServer scope requestTimestamp precondition clearOnAuth action -> do
4012withFederationAuth = \authorization t respond fromServer scope requestTimestamp precondition clearOnAuth action -> do
3900− -- Check blocklist and allowlist before spending resources on verification callback
4013+ -- Check blocklist and allowlist before spending resources on verification
3901 blocked <- *blockedPeers
4014 blocked <- *blockedPeers
3902 if isBlockedPeer fromServer blocked
4015 if isBlockedPeer fromServer blocked
3903 then do
4016 then do
@@ -3910,7 +4023,14 @@ withFederationAuth = \authorization t respond fromServer scope requestTimestamp
3910 logWarn ("federation: peer " ++ fromServer ++ " not in allowlist, rejected")
4023 logWarn ("federation: peer " ++ fromServer ++ " not in allowlist, rejected")
3911 yield (respond {ok: False {}, error: "not_allowed"})
4024 yield (respond {ok: False {}, error: "not_allowed"})
3912 else do
4025 else do
4026+ -- Dispatch on auth scheme: "Signed " = Ed25519 signature, "Bearer " = verify callback
4027+ verified <- if take 7 authorization == "Signed "
4028+ then do
4029+ let signatureHex = drop 7 authorization
4030+ if signatureHex == "" || tooLong signatureHex maxSignatureLen
4031+ then yield (False {})
4032+ else verifyFederationSignature fromServer signatureHex scope requestTimestamp
3913− verified <- verifyFederationCallback fromServer authorization scope
4033+ else verifyFederationCallback fromServer authorization scope
3914 if not verified
4034 if not verified
3915 then yield (respond {ok: False {}, error: "unauthorized"})
4035 then yield (respond {ok: False {}, error: "unauthorized"})
3916 -- Replay protection: reject requests with timestamps too far from local clock.
4036 -- Replay protection: reject requests with timestamps too far from local clock.
@@ -4153,7 +4273,7 @@ retryForwardsForServer = \entries remaining ->
4153 let server = case headOf liveEntries of Just {value: f} -> f.toServer; Nothing {} -> ""
4273 let server = case headOf liveEntries of Just {value: f} -> f.toServer; Nothing {} -> ""
4154 forEachBatch liveEntries maxBatchSize (\batch -> do
4274 forEachBatch liveEntries maxBatchSize (\batch -> do
4155 t <- now
4275 t <- now
4156− auth <- generateFederationAuth "forward-batch" server
4276+ auth <- generateFederationAuth "forward-batch" t
4157 corrId <- genTokenN 1
4277 corrId <- genTokenN 1
4158 let batchPayload = do f <- batch; yield {toKey: f.toKey, encryptedBlob: f.encryptedBlob, timestamp: f.timestamp, originServer: f.originServer, messageId: f.messageId, expiresAt: f.expiresAt, priority: f.priority}
4278 let batchPayload = do f <- batch; yield {toKey: f.toKey, encryptedBlob: f.encryptedBlob, timestamp: f.timestamp, originServer: f.originServer, messageId: f.messageId, expiresAt: f.expiresAt, priority: f.priority}
4159 result <- fetchFederation server (FederationBatchForward {
4279 result <- fetchFederation server (FederationBatchForward {
@@ -4198,7 +4318,7 @@ retryForwardsSingle = \batch server ->
4198 Just {value: f} -> do
4318 Just {value: f} -> do
4199 let rest = do x <- batch; where x.messageId != f.messageId; yield x
4319 let rest = do x <- batch; where x.messageId != f.messageId; yield x
4200 t <- now
4320 t <- now
4201− auth <- generateFederationAuth ("forward:" ++ f.messageId) server
4321+ auth <- generateFederationAuth ("forward:" ++ f.messageId) t
4202 corrId <- genTokenN 1
4322 corrId <- genTokenN 1
4203 result <- fetchFederation server (ForwardMessage {
4323 result <- fetchFederation server (ForwardMessage {
4204 toKey: f.toKey, encryptedBlob: f.encryptedBlob,
4324 toKey: f.toKey, encryptedBlob: f.encryptedBlob,
@@ -4265,7 +4385,7 @@ circuitBreakerProbe = \failures t -> do
4265 Just {value: candidate} -> do
4385 Just {value: candidate} -> do
4266 logInfo ("federation: circuit breaker half-open probe to " ++ candidate.server)
4386 logInfo ("federation: circuit breaker half-open probe to " ++ candidate.server)
4267 probeStart <- now
4387 probeStart <- now
4268− auth <- generateFederationAuth "gossip" candidate.server
4388+ auth <- generateFederationAuth "gossip" probeStart
4269 result <- fetchFederation candidate.server (RecvGossip {
4389 result <- fetchFederation candidate.server (RecvGossip {
4270 authorization: auth,
4390 authorization: auth,
4271 events: [], hopsRemaining: 1, fromServer: serverHostN,
4391 events: [], hopsRemaining: 1, fromServer: serverHostN,
@@ -4375,7 +4495,7 @@ forwardQueuedMessages = \targets t -> do
4375
4495
4376fetchFederationAck = \origin messageIds -> do
4496fetchFederationAck = \origin messageIds -> do
4377 t <- now
4497 t <- now
4378− auth <- generateFederationAuth "ack" origin
4498+ auth <- generateFederationAuth "ack" t
4379 corrId <- genTokenN 1
4499 corrId <- genTokenN 1
4380 fetchFederation origin (FederationAck {
4500 fetchFederation origin (FederationAck {
4381 authorization: auth,
4501 authorization: auth,
@@ -4582,7 +4702,7 @@ retryPendingCancels = do
4582 let serverCancels = do c <- pending; where c.toServer == server; yield c
4702 let serverCancels = do c <- pending; where c.toServer == server; yield c
4583 forEachBatch serverCancels maxBatchSize (\batch -> do
4703 forEachBatch serverCancels maxBatchSize (\batch -> do
4584 cancelT <- now
4704 cancelT <- now
4585− auth <- generateFederationAuth "cancel-batch" server
4705+ auth <- generateFederationAuth "cancel-batch" cancelT
4586 corrId <- genTokenN 1
4706 corrId <- genTokenN 1
4587 let payload = do c <- batch; yield {messageId: c.messageId, toKey: c.toKey}
4707 let payload = do c <- batch; yield {messageId: c.messageId, toKey: c.toKey}
4588 result <- fetchFederation server (FederationBatchCancel {
4708 result <- fetchFederation server (FederationBatchCancel {
@@ -5569,7 +5689,7 @@ forkSendForwards = \effectiveMsgId to encryptedBlob clampedTs remoteServers ->
5569 Just {value: fe} -> yield {pending: True {}, fwdExpiresAt: fe.expiresAt, fwdPriority: fe.priority}
5689 Just {value: fe} -> yield {pending: True {}, fwdExpiresAt: fe.expiresAt, fwdPriority: fe.priority}
5570 when fwdInfo.pending (do
5690 when fwdInfo.pending (do
5571 fwdT <- now
5691 fwdT <- now
5572− fwdAuth <- generateFederationAuth ("forward:" ++ effectiveMsgId) server
5692+ fwdAuth <- generateFederationAuth ("forward:" ++ effectiveMsgId) fwdT
5573 fwdCorrId <- genTokenN 1
5693 fwdCorrId <- genTokenN 1
5574 fwdResult <- fetchFederation server (ForwardMessage {
5694 fwdResult <- fetchFederation server (ForwardMessage {
5575 toKey: to, encryptedBlob: encryptedBlob,
5695 toKey: to, encryptedBlob: encryptedBlob,
@@ -5603,7 +5723,7 @@ sendBatchForwardsSequential = \msgs server ->
5603 if count pending == 0 then yield {}
5723 if count pending == 0 then yield {}
5604 else do
5724 else do
5605 t <- now
5725 t <- now
5606− auth <- generateFederationAuth "forward-batch" server
5726+ auth <- generateFederationAuth "forward-batch" t
5607 corrId <- genTokenN 1
5727 corrId <- genTokenN 1
5608 let batchPayload = do r <- pending; yield {toKey: r.toKey, encryptedBlob: r.blob, timestamp: r.ts, originServer: serverHostN, messageId: r.messageId, expiresAt: r.fwdExpiresAt, priority: r.fwdPriority}
5728 let batchPayload = do r <- pending; yield {toKey: r.toKey, encryptedBlob: r.blob, timestamp: r.ts, originServer: serverHostN, messageId: r.messageId, expiresAt: r.fwdExpiresAt, priority: r.fwdPriority}
5609 result <- fetchFederation server (FederationBatchForward {
5729 result <- fetchFederation server (FederationBatchForward {
@@ -5657,7 +5777,7 @@ sendBatchForwardsFallback = \msgs server ->
5657 Just {value: r} -> do
5777 Just {value: r} -> do
5658 let rest = do m <- msgs; where m.messageId != r.messageId; yield m
5778 let rest = do m <- msgs; where m.messageId != r.messageId; yield m
5659 fwdT <- now
5779 fwdT <- now
5660− fwdAuth <- generateFederationAuth ("forward:" ++ r.messageId) server
5780+ fwdAuth <- generateFederationAuth ("forward:" ++ r.messageId) fwdT
5661 fwdCorrId <- genTokenN 1
5781 fwdCorrId <- genTokenN 1
5662 fwdResult <- fetchFederation server (ForwardMessage {
5782 fwdResult <- fetchFederation server (ForwardMessage {
5663 toKey: r.toKey, encryptedBlob: r.blob,
5783 toKey: r.toKey, encryptedBlob: r.blob,
@@ -6601,7 +6721,7 @@ handleCancelBatch = \cancels authorization clientIp respond -> do
6601 let serverCancels = do c <- results.fedCancels; where any c.servers (\s -> s == server); yield {messageId: c.messageId, toKey: c.toKey}
6721 let serverCancels = do c <- results.fedCancels; where any c.servers (\s -> s == server); yield {messageId: c.messageId, toKey: c.toKey}
6602 when (count serverCancels > 0) (do
6722 when (count serverCancels > 0) (do
6603 cancelT <- now
6723 cancelT <- now
6604− cancelAuth <- generateFederationAuth "cancel-batch" server
6724+ cancelAuth <- generateFederationAuth "cancel-batch" cancelT
6605 cancelCorrId <- genTokenN 1
6725 cancelCorrId <- genTokenN 1
6606 cancelResult <- fetchFederation server (FederationBatchCancel {
6726 cancelResult <- fetchFederation server (FederationBatchCancel {
6607 cancels: serverCancels, fromServer: serverHostN,
6727 cancels: serverCancels, fromServer: serverHostN,
@@ -6768,6 +6888,7 @@ handleUndrain = \authorization respond -> do
6768 -- Clear stale verification cache: entries cached during drain may reference
6888 -- Clear stale verification cache: entries cached during drain may reference
6769 -- peers whose trust state changed during the drain/undrain cycle.
6889 -- peers whose trust state changed during the drain/undrain cycle.
6770 set *verificationCache = []
6890 set *verificationCache = []
6891+ set *peerPublicKeys = []
6771 yield (d > 0)
6892 yield (d > 0)
6772 when wasDraining (logInfo "undrain: server resumed, accepting new requests")
6893 when wasDraining (logInfo "undrain: server resumed, accepting new requests")
6773 when (not wasDraining) (logInfo "undrain: server was not draining")
6894 when (not wasDraining) (logInfo "undrain: server was not draining")
@@ -7468,7 +7589,7 @@ handleCancelMessage = \messageId to authorization clientIp respond -> do
7468 fork (when (federationEnabled && cancelResult.cancelled && count cancelResult.cancelServers > 0)
7589 fork (when (federationEnabled && cancelResult.cancelled && count cancelResult.cancelServers > 0)
7469 (sendToEach cancelResult.cancelServers (\server -> do
7590 (sendToEach cancelResult.cancelServers (\server -> do
7470 cancelT <- now
7591 cancelT <- now
7471− cancelAuth <- generateFederationAuth "cancel-batch" server
7592+ cancelAuth <- generateFederationAuth "cancel-batch" cancelT
7472 cancelCorrId <- genTokenN 1
7593 cancelCorrId <- genTokenN 1
7473 cancelFedResult <- fetchFederation server (FederationBatchCancel {
7594 cancelFedResult <- fetchFederation server (FederationBatchCancel {
7474 cancels: [{messageId: messageId, toKey: to}], fromServer: serverHostN,
7595 cancels: [{messageId: messageId, toKey: to}], fromServer: serverHostN,
@@ -7692,7 +7813,7 @@ handlePingPeer = \server authorization respond -> do
7692 if not authorized then yield (respond {ok: False {}, latencyMs: 0, error: "unauthorized"})
7813 if not authorized then yield (respond {ok: False {}, latencyMs: 0, error: "unauthorized"})
7693 else do
7814 else do
7694 startTime <- now
7815 startTime <- now
7695− auth <- generateFederationAuth "gossip" server
7816+ auth <- generateFederationAuth "gossip" startTime
7696 pingCorrId <- genTokenN 1
7817 pingCorrId <- genTokenN 1
7697 result <- fetchFederation server (RecvGossip {
7818 result <- fetchFederation server (RecvGossip {
7698 authorization: auth,
7819 authorization: auth,
@@ -7992,6 +8113,7 @@ requestPath = \req -> case req of
7992 FederationBatchCancel {} -> "POST /federation/cancel-batch"
8113 FederationBatchCancel {} -> "POST /federation/cancel-batch"
7993 FederationBatchForward {} -> "POST /federation/forward-batch"
8114 FederationBatchForward {} -> "POST /federation/forward-batch"
7994 VerifyToken {} -> "POST /federation/verify"
8115 VerifyToken {} -> "POST /federation/verify"
8116+ FederationPubkey {} -> "GET /federation/pubkey"
7995 Prune {} -> "POST /maintenance/prune"
8117 Prune {} -> "POST /maintenance/prune"
7996 Drain {} -> "POST /maintenance/drain"
8118 Drain {} -> "POST /maintenance/drain"
7997 Shutdown {} -> "POST /maintenance/shutdown"
8119 Shutdown {} -> "POST /maintenance/shutdown"
@@ -8077,6 +8199,7 @@ serveInner = \req -> case req of
8077 FederationBatchForward {forwards, fromServer, requestTimestamp, correlationId, protocolVersion, authorization, respond} -> do
8199 FederationBatchForward {forwards, fromServer, requestTimestamp, correlationId, protocolVersion, authorization, respond} -> do
8078 warnOldProtocol fromServer protocolVersion; handleFederationBatchForward forwards fromServer requestTimestamp correlationId authorization respond
8200 warnOldProtocol fromServer protocolVersion; handleFederationBatchForward forwards fromServer requestTimestamp correlationId authorization respond
8079 VerifyToken {token, scope, clientIp, respond} -> handleVerifyToken token scope clientIp respond
8201 VerifyToken {token, scope, clientIp, respond} -> handleVerifyToken token scope clientIp respond
8202+ FederationPubkey {respond} -> handleFederationPubkey respond
8080 Prune {authorization, respond} -> handlePrune authorization respond
8203 Prune {authorization, respond} -> handlePrune authorization respond
8081 Drain {phase, authorization, respond} -> handleDrain phase authorization respond
8204 Drain {phase, authorization, respond} -> handleDrain phase authorization respond
8082 Shutdown {authorization, respond} -> handleShutdown authorization respond
8205 Shutdown {authorization, respond} -> handleShutdown authorization respond
@@ -8155,6 +8278,7 @@ serveOverloaded = \req -> case req of
8155 FederationBatchCancel {respond} -> respond {ok: False {}, error: "overloaded:1000ms", results: []}
8278 FederationBatchCancel {respond} -> respond {ok: False {}, error: "overloaded:1000ms", results: []}
8156 FederationBatchForward {respond} -> respond {ok: False {}, error: "overloaded:1000ms", results: []}
8279 FederationBatchForward {respond} -> respond {ok: False {}, error: "overloaded:1000ms", results: []}
8157 VerifyToken {respond} -> respond {ok: False {}}
8280 VerifyToken {respond} -> respond {ok: False {}}
8281+ FederationPubkey {respond} -> respond {pubkey: "", ok: False {}}
8158 -- Stats/admin routes (exempt routes handled by isOverloadExempt; these are fallbacks)
8282 -- Stats/admin routes (exempt routes handled by isOverloadExempt; these are fallbacks)
8159 TopRecipients {respond} -> respond {recipients: [], ok: False {}, total: 0}
8283 TopRecipients {respond} -> respond {recipients: [], ok: False {}, total: 0}
8160 StatsForwards {respond} -> respond {servers: [], ok: False {}, total: 0}
8284 StatsForwards {respond} -> respond {servers: [], ok: False {}, total: 0}
@@ -8488,7 +8612,7 @@ startupProbeNext = \peers probed ->
8488 -- /federation/verify on this server to confirm the token, testing the full
8612 -- /federation/verify on this server to confirm the token, testing the full
8489 -- federation auth path in both directions.
8613 -- federation auth path in both directions.
8490 authStart <- now
8614 authStart <- now
8491− auth <- generateFederationAuth "gossip" server
8615+ auth <- generateFederationAuth "gossip" authStart
8492 authResult <- fetchFederation server (RecvGossip {
8616 authResult <- fetchFederation server (RecvGossip {
8493 authorization: auth,
8617 authorization: auth,
8494 events: [], hopsRemaining: 1, fromServer: serverHostN,
8618 events: [], hopsRemaining: 1, fromServer: serverHostN,
@@ -8574,6 +8698,14 @@ main = do
8574 (logWarn "SECURITY: DNS rebinding risk — federation URLs resolve DNS at fetch time, not at validation time. A peer hostname passing isBadServerName could rebind to a private IP between validation and fetch. Mitigate by blocking outbound connections to RFC1918/link-local/CGNAT ranges at the firewall level.")
8698 (logWarn "SECURITY: DNS rebinding risk — federation URLs resolve DNS at fetch time, not at validation time. A peer hostname passing isBadServerName could rebind to a private IP between validation and fetch. Mitigate by blocking outbound connections to RFC1918/link-local/CGNAT ranges at the firewall level.")
8575 logInfo ("Skrepka relay server starting on port " ++ show listenPort ++ "...")
8699 logInfo ("Skrepka relay server starting on port " ++ show listenPort ++ "...")
8576 logInfo (" federation: " ++ if federationEnabled then "enabled (protocol v" ++ show federationProtocolVersion ++ ")" else "disabled")
8700 logInfo (" federation: " ++ if federationEnabled then "enabled (protocol v" ++ show federationProtocolVersion ++ ")" else "disabled")
8701+ when (serverFederationPrivateKeyHex != "" && serverFederationPublicKeyHex != "")
8702+ (logInfo " federation auth: Ed25519 signing enabled")
8703+ when (serverFederationPrivateKeyHex == "" && federationEnabled)
8704+ (logInfo " federation auth: verify callback (no signing key configured)")
8705+ when (serverFederationPrivateKeyHex != "" && serverFederationPublicKeyHex == "")
8706+ (logError "FATAL: serverFederationPrivateKeyHex is set but serverFederationPublicKeyHex is empty — both must be configured together")
8707+ when (serverFederationPrivateKeyHex == "" && serverFederationPublicKeyHex != "")
8708+ (logError "FATAL: serverFederationPublicKeyHex is set but serverFederationPrivateKeyHex is empty — both must be configured together")
8577 logInfo (" prune interval: " ++ show backgroundPruneInterval ++ "ms")
8709 logInfo (" prune interval: " ++ show backgroundPruneInterval ++ "ms")
8578 logInfo (" poll timeout: " ++ show pollTimeoutMs ++ "ms")
8710 logInfo (" poll timeout: " ++ show pollTimeoutMs ++ "ms")
8579 logInfo (" max messages: " ++ show maxMessages ++ " (per recipient: " ++ show maxMessagesPerRecipient ++ ")")
8711 logInfo (" max messages: " ++ show maxMessages ++ " (per recipient: " ++ show maxMessagesPerRecipient ++ ")")