@@ -18,6 +18,13 @@ serverHost = "localhost:8080"
1818listenPort = 8080
1919maintenanceSecret = "" -- set to enable /maintenance/* and /stats endpoints
2020federationScheme = "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 = ""
2128-- NOTE: listenHost is not configurable — Knot's listen primitive only accepts a port.
2229-- To restrict the bind address (e.g., 127.0.0.1 behind a reverse proxy), use
2330-- 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"
191198-- Peers log warnings when they receive requests from older versions.
192199-- Backward compatible: older peers ignore unknown fields (Knot's JSON parser
193200-- drops unknown keys). 0 = pre-versioning peer (field missing or unset).
194−federationProtocolVersion = 12
201+federationProtocolVersion = 13
195202
196203-- Max seenBy list length in gossip events (bounds memory and bandwidth per event)
197204maxSeenByLen = 20
@@ -239,6 +246,12 @@ federationUrl = \server -> federationScheme ++ server
239246maxPendingVerifications = 10000
240247verificationTokenExpiry : Int<Ms>
241248verificationTokenExpiry = 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
242255-- IP-based rate limiting (reverse proxy sets ClientIp header)
243256maxIpRatePerIp = 10 -- max auth requests per IP per window
244257ipRateWindowMs : Int<Ms>
@@ -833,6 +846,11 @@ maxPeerLatencyEntries = 100
833846-- Cached successful verify callbacks per-server (short-lived, reduces outbound HTTP calls)
834847*verificationCache : [{server: Text, scope: Text, verifiedAt: Timestamp}]
835848
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+
836854-- Per-IP unique key tracking: detects when a single IP authenticates many distinct pubkeys
837855-- (key-rotation abuse bypassing per-key rate limits). Pruned with other rate limiters.
838856*uniqueKeyIpRates : [UniqueKeyIpEntry]
@@ -1058,6 +1076,11 @@ route FederationApi where
10581076route FederationVerifyApi where
10591077 POST {token: Text, scope: Text} /federation/verify headers {clientIp: Text} -> {ok: Bool} = VerifyToken
10601078
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+
10611084-- Triggers pruning of expired state and retry of pending federation ops
10621085route MaintenanceApi where
10631086 POST {} /maintenance/prune headers {authorization: Text}
@@ -1192,7 +1215,7 @@ route StatsApi where
11921215route ReadyApi where
11931216 GET /ready -> {ready: Bool} = Ready
11941217
1195−route Api = AuthApi | ClientApi | FederationApi | FederationVerifyApi | MaintenanceApi | HealthApi | StatsApi | ReadyApi
1218+route Api = AuthApi | ClientApi | FederationApi | FederationVerifyApi | FederationPubkeyApi | MaintenanceApi | HealthApi | StatsApi | ReadyApi
11961219
11971220-- ============================================
11981221-- Helpers
@@ -1651,17 +1674,23 @@ isAllowedByAllowlist = \server allowed -> count allowed == 0 || any allowed (\a
16511674-- Generate a single-use token for outbound federation auth.
16521675-- scope binds the token to a request type ("gossip", "forward", "ack") so a token
16531676-- issued for one operation cannot authorize a different one.
1654−generateFederationAuth : Text -> Text -> IO {network} Text
1655−generateFederationAuth = \scope server -> do
1656− token <- genToken
1657− t <- now
1658− atomic do
1659− pending <- *pendingVerifications
1660− let updated = union pending [{token: token, createdAt: t, scope: scope}]
1661− set *pendingVerifications = if count updated > maxPendingVerifications
1662− then evictOneOldest updated (\p -> p.createdAt)
1663− else updated
1664− yield ("Bearer " ++ token)
1677+generateFederationAuth : Text -> Timestamp -> IO {network} Text
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
1685+ token <- genToken
1686+ t <- now
1687+ atomic do
1688+ pending <- *pendingVerifications
1689+ let updated = union pending [{token: token, createdAt: t, scope: scope}]
1690+ set *pendingVerifications = if count updated > maxPendingVerifications
1691+ then evictOneOldest updated (\p -> p.createdAt)
1692+ else updated
1693+ yield ("Bearer " ++ token)
16651694
16661695-- Verify an inbound federation token. Checks a short-lived per-server cache first
16671696-- to reduce outbound HTTP calls (a peer sending N requests/second would otherwise
@@ -3600,6 +3629,9 @@ pruneExpired = \t -> do
36003629 -- Prune expired federation verification tokens
36013630 pending <- *pendingVerifications
36023631 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
36033635 -- Prune expired forward nonces (round-robin: 4 of 16 buckets per cycle)
36043636 pruneNonceBucketsRoundRobin t
36053637 -- Prune gossip event rate entries
@@ -3657,7 +3689,7 @@ sendBatchedGossipToServer = \server allEvents hops ->
36573689 in if count unseen == 0 then yield {}
36583690 else forEachBatch unseen maxGossipEvents (\batch -> do
36593691 t <- now
3660− auth <- generateFederationAuth "gossip" server
3692+ auth <- generateFederationAuth "gossip" t
36613693 corrId <- genTokenN 1
36623694 result <- fetchFederation server (RecvGossip {
36633695 authorization: auth,
@@ -3889,6 +3921,87 @@ checkPreAuth = \clientIp t -> do
38893921 if ipLimited then yield "ip_rate_limited"
38903922 else yield ""
38913923
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+
38924005-- Federation auth wrapper. Intentionally does NOT call checkPreAuth because:
38934006-- (a) Soft drain (phase 1) blocks new client auth but must not block federation —
38944007-- peers need to deliver messages and acks to existing recipients during drain.
@@ -3897,7 +4010,7 @@ checkPreAuth = \clientIp t -> do
38974010-- (c) Federation routes don't carry a ClientIp header (peer-to-peer, not client-facing),
38984011-- so IP-based rate limiting doesn't apply.
38994012withFederationAuth = \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
39014014 blocked <- *blockedPeers
39024015 if isBlockedPeer fromServer blocked
39034016 then do
@@ -3910,7 +4023,14 @@ withFederationAuth = \authorization t respond fromServer scope requestTimestamp
39104023 logWarn ("federation: peer " ++ fromServer ++ " not in allowlist, rejected")
39114024 yield (respond {ok: False {}, error: "not_allowed"})
39124025 else do
3913− verified <- verifyFederationCallback fromServer authorization scope
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
4033+ else verifyFederationCallback fromServer authorization scope
39144034 if not verified
39154035 then yield (respond {ok: False {}, error: "unauthorized"})
39164036 -- Replay protection: reject requests with timestamps too far from local clock.
@@ -4153,7 +4273,7 @@ retryForwardsForServer = \entries remaining ->
41534273 let server = case headOf liveEntries of Just {value: f} -> f.toServer; Nothing {} -> ""
41544274 forEachBatch liveEntries maxBatchSize (\batch -> do
41554275 t <- now
4156− auth <- generateFederationAuth "forward-batch" server
4276+ auth <- generateFederationAuth "forward-batch" t
41574277 corrId <- genTokenN 1
41584278 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}
41594279 result <- fetchFederation server (FederationBatchForward {
@@ -4198,7 +4318,7 @@ retryForwardsSingle = \batch server ->
41984318 Just {value: f} -> do
41994319 let rest = do x <- batch; where x.messageId != f.messageId; yield x
42004320 t <- now
4201− auth <- generateFederationAuth ("forward:" ++ f.messageId) server
4321+ auth <- generateFederationAuth ("forward:" ++ f.messageId) t
42024322 corrId <- genTokenN 1
42034323 result <- fetchFederation server (ForwardMessage {
42044324 toKey: f.toKey, encryptedBlob: f.encryptedBlob,
@@ -4265,7 +4385,7 @@ circuitBreakerProbe = \failures t -> do
42654385 Just {value: candidate} -> do
42664386 logInfo ("federation: circuit breaker half-open probe to " ++ candidate.server)
42674387 probeStart <- now
4268− auth <- generateFederationAuth "gossip" candidate.server
4388+ auth <- generateFederationAuth "gossip" probeStart
42694389 result <- fetchFederation candidate.server (RecvGossip {
42704390 authorization: auth,
42714391 events: [], hopsRemaining: 1, fromServer: serverHostN,
@@ -4375,7 +4495,7 @@ forwardQueuedMessages = \targets t -> do
43754495
43764496fetchFederationAck = \origin messageIds -> do
43774497 t <- now
4378− auth <- generateFederationAuth "ack" origin
4498+ auth <- generateFederationAuth "ack" t
43794499 corrId <- genTokenN 1
43804500 fetchFederation origin (FederationAck {
43814501 authorization: auth,
@@ -4582,7 +4702,7 @@ retryPendingCancels = do
45824702 let serverCancels = do c <- pending; where c.toServer == server; yield c
45834703 forEachBatch serverCancels maxBatchSize (\batch -> do
45844704 cancelT <- now
4585− auth <- generateFederationAuth "cancel-batch" server
4705+ auth <- generateFederationAuth "cancel-batch" cancelT
45864706 corrId <- genTokenN 1
45874707 let payload = do c <- batch; yield {messageId: c.messageId, toKey: c.toKey}
45884708 result <- fetchFederation server (FederationBatchCancel {
@@ -5569,7 +5689,7 @@ forkSendForwards = \effectiveMsgId to encryptedBlob clampedTs remoteServers ->
55695689 Just {value: fe} -> yield {pending: True {}, fwdExpiresAt: fe.expiresAt, fwdPriority: fe.priority}
55705690 when fwdInfo.pending (do
55715691 fwdT <- now
5572− fwdAuth <- generateFederationAuth ("forward:" ++ effectiveMsgId) server
5692+ fwdAuth <- generateFederationAuth ("forward:" ++ effectiveMsgId) fwdT
55735693 fwdCorrId <- genTokenN 1
55745694 fwdResult <- fetchFederation server (ForwardMessage {
55755695 toKey: to, encryptedBlob: encryptedBlob,
@@ -5603,7 +5723,7 @@ sendBatchForwardsSequential = \msgs server ->
56035723 if count pending == 0 then yield {}
56045724 else do
56055725 t <- now
5606− auth <- generateFederationAuth "forward-batch" server
5726+ auth <- generateFederationAuth "forward-batch" t
56075727 corrId <- genTokenN 1
56085728 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}
56095729 result <- fetchFederation server (FederationBatchForward {
@@ -5657,7 +5777,7 @@ sendBatchForwardsFallback = \msgs server ->
56575777 Just {value: r} -> do
56585778 let rest = do m <- msgs; where m.messageId != r.messageId; yield m
56595779 fwdT <- now
5660− fwdAuth <- generateFederationAuth ("forward:" ++ r.messageId) server
5780+ fwdAuth <- generateFederationAuth ("forward:" ++ r.messageId) fwdT
56615781 fwdCorrId <- genTokenN 1
56625782 fwdResult <- fetchFederation server (ForwardMessage {
56635783 toKey: r.toKey, encryptedBlob: r.blob,
@@ -6601,7 +6721,7 @@ handleCancelBatch = \cancels authorization clientIp respond -> do
66016721 let serverCancels = do c <- results.fedCancels; where any c.servers (\s -> s == server); yield {messageId: c.messageId, toKey: c.toKey}
66026722 when (count serverCancels > 0) (do
66036723 cancelT <- now
6604− cancelAuth <- generateFederationAuth "cancel-batch" server
6724+ cancelAuth <- generateFederationAuth "cancel-batch" cancelT
66056725 cancelCorrId <- genTokenN 1
66066726 cancelResult <- fetchFederation server (FederationBatchCancel {
66076727 cancels: serverCancels, fromServer: serverHostN,
@@ -6768,6 +6888,7 @@ handleUndrain = \authorization respond -> do
67686888 -- Clear stale verification cache: entries cached during drain may reference
67696889 -- peers whose trust state changed during the drain/undrain cycle.
67706890 set *verificationCache = []
6891+ set *peerPublicKeys = []
67716892 yield (d > 0)
67726893 when wasDraining (logInfo "undrain: server resumed, accepting new requests")
67736894 when (not wasDraining) (logInfo "undrain: server was not draining")
@@ -7468,7 +7589,7 @@ handleCancelMessage = \messageId to authorization clientIp respond -> do
74687589 fork (when (federationEnabled && cancelResult.cancelled && count cancelResult.cancelServers > 0)
74697590 (sendToEach cancelResult.cancelServers (\server -> do
74707591 cancelT <- now
7471− cancelAuth <- generateFederationAuth "cancel-batch" server
7592+ cancelAuth <- generateFederationAuth "cancel-batch" cancelT
74727593 cancelCorrId <- genTokenN 1
74737594 cancelFedResult <- fetchFederation server (FederationBatchCancel {
74747595 cancels: [{messageId: messageId, toKey: to}], fromServer: serverHostN,
@@ -7692,7 +7813,7 @@ handlePingPeer = \server authorization respond -> do
76927813 if not authorized then yield (respond {ok: False {}, latencyMs: 0, error: "unauthorized"})
76937814 else do
76947815 startTime <- now
7695− auth <- generateFederationAuth "gossip" server
7816+ auth <- generateFederationAuth "gossip" startTime
76967817 pingCorrId <- genTokenN 1
76977818 result <- fetchFederation server (RecvGossip {
76987819 authorization: auth,
@@ -7992,6 +8113,7 @@ requestPath = \req -> case req of
79928113 FederationBatchCancel {} -> "POST /federation/cancel-batch"
79938114 FederationBatchForward {} -> "POST /federation/forward-batch"
79948115 VerifyToken {} -> "POST /federation/verify"
8116+ FederationPubkey {} -> "GET /federation/pubkey"
79958117 Prune {} -> "POST /maintenance/prune"
79968118 Drain {} -> "POST /maintenance/drain"
79978119 Shutdown {} -> "POST /maintenance/shutdown"
@@ -8077,6 +8199,7 @@ serveInner = \req -> case req of
80778199 FederationBatchForward {forwards, fromServer, requestTimestamp, correlationId, protocolVersion, authorization, respond} -> do
80788200 warnOldProtocol fromServer protocolVersion; handleFederationBatchForward forwards fromServer requestTimestamp correlationId authorization respond
80798201 VerifyToken {token, scope, clientIp, respond} -> handleVerifyToken token scope clientIp respond
8202+ FederationPubkey {respond} -> handleFederationPubkey respond
80808203 Prune {authorization, respond} -> handlePrune authorization respond
80818204 Drain {phase, authorization, respond} -> handleDrain phase authorization respond
80828205 Shutdown {authorization, respond} -> handleShutdown authorization respond
@@ -8155,6 +8278,7 @@ serveOverloaded = \req -> case req of
81558278 FederationBatchCancel {respond} -> respond {ok: False {}, error: "overloaded:1000ms", results: []}
81568279 FederationBatchForward {respond} -> respond {ok: False {}, error: "overloaded:1000ms", results: []}
81578280 VerifyToken {respond} -> respond {ok: False {}}
8281+ FederationPubkey {respond} -> respond {pubkey: "", ok: False {}}
81588282 -- Stats/admin routes (exempt routes handled by isOverloadExempt; these are fallbacks)
81598283 TopRecipients {respond} -> respond {recipients: [], ok: False {}, total: 0}
81608284 StatsForwards {respond} -> respond {servers: [], ok: False {}, total: 0}
@@ -8488,7 +8612,7 @@ startupProbeNext = \peers probed ->
84888612 -- /federation/verify on this server to confirm the token, testing the full
84898613 -- federation auth path in both directions.
84908614 authStart <- now
8491− auth <- generateFederationAuth "gossip" server
8615+ auth <- generateFederationAuth "gossip" authStart
84928616 authResult <- fetchFederation server (RecvGossip {
84938617 authorization: auth,
84948618 events: [], hopsRemaining: 1, fromServer: serverHostN,
@@ -8574,6 +8698,14 @@ main = do
85748698 (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.")
85758699 logInfo ("Skrepka relay server starting on port " ++ show listenPort ++ "...")
85768700 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")
85778709 logInfo (" prune interval: " ++ show backgroundPruneInterval ++ "ms")
85788710 logInfo (" poll timeout: " ++ show pollTimeoutMs ++ "ms")
85798711 logInfo (" max messages: " ++ show maxMessages ++ " (per recipient: " ++ show maxMessagesPerRecipient ++ ")")