@@ -123,6 +123,13 @@ maxFederationInFlight = 100
123123federationCircuitBreakerThreshold = 3
124124-- Federation replay dedup: track recent (server, timestamp) pairs to reject exact replays
125125maxRecentFederationRequests = 1000
126+-- Forward nonce dedup: tracks (messageId, toKey) pairs to catch replays
127+-- beyond the lifetime of messages/ackedIds/deliveredIds
128+maxForwardNonces = 100000
129+forwardNonceRetention : Int<Ms>
130+forwardNonceRetention = messageMaxAge * 2 -- 60 days; extends 30 days beyond message dedup
131+-- Counter reconciliation: every N prune cycles, recount from truth to self-heal drift
132+reconciliationFrequency = 10
126133
127134-- Federation latency metric window
128135federationLatencyWindowMs : Int<Ms>
@@ -475,6 +482,9 @@ type RecentFederationRequest = {server: Text, timestamp: Timestamp, seenAt: Time
475482-- scope binds the token to a specific request type (gossip/forward/ack) to prevent cross-use
476483*pendingVerifications : [{token: Text, createdAt: Timestamp, scope: Text}]
477484
485+-- Forward nonce dedup: (messageId, toKey, seenAt) for replay protection beyond message lifetime
486+*forwardNonces : [{messageId: MessageIdHex, toKey: PubkeyHex, seenAt: Timestamp}]
487+
478488-- ============================================
479489-- Routes
480490-- ============================================
@@ -538,7 +548,7 @@ route FederationApi where
538548-- Federation callback verification: the receiving server calls back to the sender
539549-- to confirm a request token is genuine. No shared secret needed.
540550route FederationVerifyApi where
541− POST {token: Text, scope: Text} /federation/verify -> {ok: Bool} = VerifyToken
551+ POST {token: Text, scope: Text} /federation/verify headers {clientIp: Text} -> {ok: Bool} = VerifyToken
542552
543553-- Triggers pruning of expired state and retry of pending federation ops
544554route MaintenanceApi where
@@ -1658,6 +1668,9 @@ pruneExpired = \t -> do
16581668 -- Prune expired federation verification tokens
16591669 pending <- *pendingVerifications
16601670 set *pendingVerifications = do p <- pending; where t - p.createdAt < verificationTokenExpiry; yield p
1671+ -- Prune expired forward nonces (extended replay protection)
1672+ forwardNonces <- *forwardNonces
1673+ set *forwardNonces = do n <- forwardNonces; where t - n.seenAt < forwardNonceRetention; yield n
16611674 yield presenceResult
16621675
16631676
@@ -2173,6 +2186,28 @@ waitForInFlightDrain = \deadline -> do
21732186pruneAndRetry = \t -> do
21742187 atomic do set *lastPrunedAt = t
21752188 pr <- pruneExpired t
2189+ -- Periodic counter reconciliation: recount from actual data every N prune cycles to self-heal drift
2190+ pc <- *pruneCount
2191+ when (pc > 0 && pc - (pc / reconciliationFrequency) * reconciliationFrequency == 0) (do
2192+ msgDrift <- atomic do
2193+ allMsgs <- readAllMessages
2194+ let actual = count allMsgs
2195+ stored <- *totalMessageCount
2196+ set *totalMessageCount = actual
2197+ set *recipientMsgCounts = countByRecipient allMsgs
2198+ yield (actual - stored)
2199+ presDrift <- atomic do
2200+ actual <- countAllPresence
2201+ stored <- *totalPresenceCount
2202+ set *totalPresenceCount = actual
2203+ yield (actual - stored)
2204+ sessDrift <- atomic do
2205+ actual <- countAllSessions
2206+ stored <- *totalSessionCount
2207+ set *totalSessionCount = actual
2208+ yield (actual - stored)
2209+ when (msgDrift != 0 || presDrift != 0 || sessDrift != 0)
2210+ (println ("reconciliation: corrected drift — messages: " ++ show msgDrift ++ ", presence: " ++ show presDrift ++ ", sessions: " ++ show sessDrift)))
21762211 pendingForwards <- atomic do f <- *forwards; yield f
21772212 ackState <- atomic do
21782213 ackedIds2 <- *ackedIds
@@ -2997,15 +3032,18 @@ handleLogout = \authorization clientIp respond -> do
29973032-- POST /federation/verify: Confirm a token was issued by this server.
29983033-- Called by the receiving server during federation callback verification.
29993034-- Tokens are single-use and expire after verificationTokenExpiry.
3000−handleVerifyToken = \token scope respond -> do
3035+handleVerifyToken = \token scope clientIp respond -> do
30013036 t <- now
30023037 globalRateLimited <- checkGlobalRate t
30033038 if globalRateLimited then yield (respond {ok: False {}})
30043039 else do
3040+ ipLimited <- checkIpRate clientIp t
3041+ if ipLimited then yield (respond {ok: False {}})
3042+ else do
30053043 found <- atomic do
30063044 pending <- *pendingVerifications
3007− -- Empty scope accepts any (backward compat with peers that don't send scope yet)
3008− let match = any pending (\p -> safeCompare p.token token && (scope == "" || p.scope == scope) && t - p.createdAt < verificationTokenExpiry)
3045+ -- Scope is required: callers must specify the operation type (gossip/forward/ack)
3046+ let match = any pending (\p -> safeCompare p.token token && p.scope == scope && t - p.createdAt < verificationTokenExpiry)
30093047 when match (set *pendingVerifications = do p <- pending; where not (safeCompare p.token token); yield p)
30103048 yield match
30113049 yield (respond {ok: found})
@@ -3080,7 +3118,8 @@ handleForwardMessage = \toKey encryptedBlob timestamp originServer messageId fro
30803118 bucket <- readMessageBucket msgBucket
30813119 ackedIds <- *ackedIds
30823120 deliveredIds <- *deliveredIds
3083− let isDup = checkMessageDup bucket messageId toKey || hasMessage messageId toKey ackedIds || hasMessage messageId toKey deliveredIds
3121+ forwardNonces <- *forwardNonces
3122+ let isDup = checkMessageDup bucket messageId toKey || hasMessage messageId toKey ackedIds || hasMessage messageId toKey deliveredIds || any forwardNonces (\n -> n.messageId == messageId && n.toKey == toKey)
30843123 if isDup
30853124 then yield {forward: ForwardDuplicate {}, globalCapReached: False {}, recvRateLimited: False {}}
30863125 else do
@@ -3120,6 +3159,10 @@ handleForwardMessage = \toKey encryptedBlob timestamp originServer messageId fro
31203159 set *totalMessageCount = totalMsgCount + 1
31213160 set *recipientMsgCounts = adjustRecipientCount recipientCounts toKey 1
31223161 signalMessageForKey toKey seqTs
3162+ -- Record forward nonce for replay protection beyond message/ack dedup lifetime
3163+ fwdNonces <- *forwardNonces
3164+ let updatedNonces = union fwdNonces [{messageId: messageId, toKey: toKey, seenAt: t}]
3165+ set *forwardNonces = if count updatedNonces > maxForwardNonces then evictOneOldest updatedNonces (\n -> n.seenAt) else updatedNonces
31233166 yield {forward: ForwardStored {}, globalCapReached: False {}, recvRateLimited: False {}}
31243167 case result.forward of
31253168 ForwardRejected {} ->
@@ -3440,7 +3483,7 @@ serve = \req -> case req of
34403483 RecvGossip {events, hopsRemaining, fromServer, requestTimestamp, authorization, respond} -> handleRecvGossip events hopsRemaining fromServer requestTimestamp authorization respond
34413484 ForwardMessage {toKey, encryptedBlob, timestamp, originServer, messageId, fromServer, requestTimestamp, authorization, respond} -> handleForwardMessage toKey encryptedBlob timestamp originServer messageId fromServer requestTimestamp authorization respond
34423485 FederationAck {messageIds, originServer, fromServer, requestTimestamp, authorization, respond} -> handleFederationAck messageIds originServer fromServer requestTimestamp authorization respond
3443− VerifyToken {token, scope, respond} -> handleVerifyToken token scope respond
3486+ VerifyToken {token, scope, clientIp, respond} -> handleVerifyToken token scope clientIp respond
34443487 Prune {authorization, respond} -> handlePrune authorization respond
34453488 Drain {authorization, respond} -> handleDrain authorization respond
34463489 Undrain {authorization, respond} -> handleUndrain authorization respond
@@ -3561,6 +3604,7 @@ main = do
35613604 && verificationTokenExpiry > 0
35623605 && maxAckRetries > 0 && maxPairRatePerPair > 0 && pairRateWindowMs > 0
35633606 && maxFederationPairRatePerPair > 0 && federationPairRateWindowMs > 0
3607+ && reconciliationFrequency > 0 && maxForwardNonces > 0
35643608 -- Relationship invariants: these misconfigurations cause subtle bugs
35653609 -- (e.g., messages pruned before their stated max age, backoff that never increases)
35663610 let configRelationsOk = unackedMessageMaxAge <= messageMaxAge
@@ -3579,15 +3623,20 @@ main = do
35793623 && maxAckOverflow <= maxAckedIds
35803624 && gossipOriginHops <= maxGossipHops
35813625 && verificationTokenExpiry <= federationFetchTimeoutMs
3626+ && forwardNonceRetention >= messageMaxAge
35823627 if not configOk
35833628 then println "FATAL: invalid configuration — capacity limits and intervals must be > 0, server not started"
35843629 else if not configRelationsOk
3585− then println "FATAL: invalid configuration — relationship invariants violated (check: unackedMessageMaxAge <= messageMaxAge, challengeExpiry < sessionExpiry, retryBackoffBase <= retryBackoffMax, backgroundPruneInterval < healthPruneMaxAge, pollTimeoutMs < sessionExpiry, gossipDedupWindow < presenceRefreshInterval, federationFetchTimeoutMs <= backgroundPruneInterval, maxAckOverflow <= maxAckedIds, gossipOriginHops <= maxGossipHops, verificationTokenExpiry <= federationFetchTimeoutMs, per-key limits <= global limits), server not started"
3630+ then println "FATAL: invalid configuration — relationship invariants violated (check: unackedMessageMaxAge <= messageMaxAge, challengeExpiry < sessionExpiry, retryBackoffBase <= retryBackoffMax, backgroundPruneInterval < healthPruneMaxAge, pollTimeoutMs < sessionExpiry, gossipDedupWindow < presenceRefreshInterval, federationFetchTimeoutMs <= backgroundPruneInterval, maxAckOverflow <= maxAckedIds, gossipOriginHops <= maxGossipHops, verificationTokenExpiry <= federationFetchTimeoutMs, forwardNonceRetention >= messageMaxAge, per-key limits <= global limits), server not started"
35863631 else do
35873632 t <- now
35883633 atomic do
35893634 set *startedAt = t
35903635 set *serverGeneration = t
3636+ when (maintenanceSecret == "")
3637+ (println "WARNING: maintenanceSecret is empty — /maintenance/* and /stats endpoints are disabled")
3638+ when (matchesHostnameOrSubdomain serverHostN "localhost")
3639+ (println ("WARNING: serverHost is " ++ serverHost ++ " — peers cannot reach this server for federation"))
35913640 println "Skrepka relay server starting on port 8080..."
35923641 println (" federation: " ++ if federationEnabled then "enabled" else "disabled")
35933642 println (" prune interval: " ++ show backgroundPruneInterval ++ "ms")