Skip to content

Commit 87960d3

Browse files
TeoSlayerteovlclaude
authored
fix(envelope): authenticate before committing replay state (remote-DoS) (#374)
DecryptFrame advanced the per-peer replay window (MaxRecvNonce) BEFORE running AEAD.Open. A forged PILS frame that failed authentication but carried a maximal nonce counter pinned MaxRecvNonce at ~2^63; the failing Open rolled back only the replay bit, never the high-water-mark. Every subsequent genuine frame from the real peer then fell >= ReplayWindowSize behind the pinned max and was dropped as ErrOutsideWindow, tripping the aged-session fast-drop into permanent packet loss + rekey storms. The attack needs only a victim node ID + a peer it has a session with (both public in the registry) and is reachable through the beacon relay (handleRelayDeliver -> handleEncrypted), so NAT'd victims are hit with no spoofing and no rate limit in the way. Fix (WireGuard-style ordering): the replay counter is only ever advanced by an authenticated frame. 1. read-only pre-check (Crypto.WouldAcceptNonce) rejects obvious replays / out-of-window frames WITHOUT mutating state, so AEAD work is still skipped for junk; 2. AEAD.Open runs on frames the pre-check would accept; 3. only a successful Open commits the nonce (CheckAndRecordNonce). A forged high-counter frame now passes the pre-check, fails AEAD, and returns ErrAEAD without touching MaxRecvNonce. On the success path a post-Open re-check via CheckAndRecordNonce handles the benign case where a concurrent authenticated frame advanced the window meanwhile. All legitimate behaviour is preserved byte-for-byte: in-window reordering, genuine replay rejection, outside-window divergence counting, the salvage path, and aged-session handling. Adds WouldAcceptNonce (read-only verdict mirror of CheckAndRecordNonce) and a regression test that fails on the old ordering (forged frame advances MaxRecvNonce to 2^63; next genuine frame -> ErrOutsideWindow) and passes after (forged frame -> ErrAEAD, window untouched, genuine frame decrypts; replay + reordering guarantees still hold). Co-authored-by: Teodor Calin <teodor@vulturelabs.io> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent f0c35a1 commit 87960d3

3 files changed

Lines changed: 271 additions & 44 deletions

File tree

pkg/daemon/envelope/envelope.go

Lines changed: 93 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -122,12 +122,31 @@ func EncryptWith(store *keyexchange.Store, c *keyexchange.Crypto, plaintext []by
122122
// Returns a DecryptResult describing the outcome. The caller (L5/L7)
123123
// consults Result.Err to decide rekey requests, drop policy, etc.
124124
//
125-
// On AEAD-Open failure DecryptFrame:
126-
// - rolls back the speculative replay-bit (UndoReplayBit) so future
127-
// legitimate frames at the same counter can still be decoded;
128-
// - increments c.DecryptFailCount under c.ReplayMu (kept on the
129-
// Crypto rather than under Store.mu — c.ReplayMu is leaf-level so
130-
// this preserves the lock graph invariant).
125+
// Ordering (authenticate-before-commit — the remote-DoS fix):
126+
// 1. A read-only pre-check (WouldAcceptNonce) rejects obvious replays /
127+
// out-of-window frames WITHOUT mutating any replay state, so the
128+
// expensive AEAD work is skipped for junk.
129+
// 2. AEAD.Open runs on frames the pre-check would accept.
130+
// 3. ONLY on a successful Open is the nonce committed to the replay
131+
// window (CheckAndRecordNonce advances MaxRecvNonce / sets the bit).
132+
//
133+
// This is deliberately the WireGuard-style ordering: the replay counter is
134+
// only ever advanced by an authenticated frame. The previous
135+
// check-record-then-open ordering let an attacker who knew a victim's node
136+
// ID + a peer it had a session with (both public) inject one PILS frame
137+
// with a maximal counter: CheckAndRecordNonce pinned MaxRecvNonce at that
138+
// value, AEAD.Open then failed, and every subsequent genuine frame from
139+
// the real peer fell >= ReplayWindowSize behind the pinned max — dropped as
140+
// ErrOutsideWindow, tripping the aged-session fast-drop into a permanent
141+
// packet-loss + rekey storm. Reachable through the beacon relay
142+
// (handleRelayDeliver → handleEncrypted), so NAT'd victims were hit with no
143+
// spoofing. Advancing the window only after authentication closes it.
144+
//
145+
// On AEAD-Open failure DecryptFrame increments c.DecryptFailCount under
146+
// c.ReplayMu (kept on the Crypto rather than under Store.mu — c.ReplayMu is
147+
// leaf-level so this preserves the lock graph invariant). No replay-window
148+
// mutation happens on the failure path (nothing was speculatively
149+
// recorded), so MaxRecvNonce is untouched by a forged frame.
131150
//
132151
// The grace-gated drop decision (ShouldDropOnDecryptFail) lives on
133152
// keyexchange.Store; this function only signals via DecryptResult.Err.
@@ -146,56 +165,30 @@ func DecryptFrame(store *keyexchange.Store, data []byte) DecryptResult {
146165

147166
recvCounter := binary.BigEndian.Uint64(nonce[len(nonce)-8:])
148167

168+
// Step 1: read-only pre-check. Reject obvious replays / out-of-window
169+
// frames before spending AEAD, but WITHOUT mutating replay state. A
170+
// forged high-counter frame passes this pre-check (a new maximum is not
171+
// a replay) — it is only committed in step 3, after authentication.
149172
c.ReplayMu.Lock()
150-
ok := c.CheckAndRecordNonce(recvCounter)
173+
accept := c.WouldAcceptNonce(recvCounter)
151174
maxN := c.MaxRecvNonce
152175
c.ReplayMu.Unlock()
153176

154-
if !ok {
155-
err := ErrReplay
156-
if recvCounter < maxN && maxN-recvCounter >= keyexchange.ReplayWindowSize {
157-
err = ErrOutsideWindow
158-
// Track consecutive outside-window rejections. A sustained
159-
// burst means the peer's send counter has diverged from our
160-
// window's high-water-mark too far for in-band recovery —
161-
// only a fresh key exchange resets both sides. The caller
162-
// (L7) consults ShouldDropOnOutsideWindow to enforce the
163-
// threshold + grace gate. Mirrors DecryptFailCount.
164-
c.ReplayMu.Lock()
165-
c.OutsideWindowCount++
166-
c.ReplayMu.Unlock()
167-
} else {
168-
// In-window replay collision. Symmetric counterpart to
169-
// OutsideWindowCount on the *other* side of the window:
170-
// peer's counter is INSIDE [max-window, max] but at a
171-
// position we've already marked. Sustained collisions mean
172-
// the peer's send counter reset (peer restarted with a
173-
// persistent X25519 identity, so no PILA was negotiated)
174-
// and every frame they now produce lands on a bit we
175-
// already set. Recovery is structurally identical to
176-
// outside-window: gate via ShouldDropOnReplay (threshold +
177-
// grace), then drop the Crypto and trigger a fresh exchange.
178-
c.ReplayMu.Lock()
179-
c.ReplayCount++
180-
c.ReplayMu.Unlock()
181-
}
182-
return DecryptResult{
183-
PeerNodeID: peerNodeID,
184-
Counter: recvCounter,
185-
MaxRecvNonce: maxN,
186-
Err: err,
187-
}
177+
if !accept {
178+
return rejectResult(c, peerNodeID, recvCounter, maxN)
188179
}
189180

190-
// H3 fix: verify sender's nodeID as AAD
181+
// Step 2: authenticate. H3 fixverify sender's nodeID as AAD.
191182
aad := make([]byte, 4)
192183
binary.BigEndian.PutUint32(aad, peerNodeID)
193184
plaintext, err := c.AEAD.Open(nil, nonce, ciphertext, aad)
194185
if err != nil {
195186
store.EncryptFail.Add(1)
196-
// Undo the speculative nonce record on decrypt failure.
187+
// AEAD failed: the frame is unauthenticated. The pre-check above
188+
// recorded NOTHING in the replay window, so there is no speculative
189+
// bit to roll back and — critically — MaxRecvNonce is untouched. A
190+
// forged frame therefore cannot wedge the window.
197191
c.ReplayMu.Lock()
198-
c.UndoReplayBit(recvCounter)
199192
c.DecryptFailCount++
200193
c.ReplayMu.Unlock()
201194
return DecryptResult{
@@ -206,6 +199,21 @@ func DecryptFrame(store *keyexchange.Store, data []byte) DecryptResult {
206199
}
207200
}
208201

202+
// Step 3: authenticated — commit the nonce to the replay window. Use
203+
// CheckAndRecordNonce (a full re-check, not a blind record) because a
204+
// concurrent authenticated frame may have advanced the window between
205+
// the read-only pre-check and here. If this frame lost that benign
206+
// reorder/duplicate race it is rejected as replay/outside-window,
207+
// exactly as the old check-then-open ordering would have rejected it.
208+
c.ReplayMu.Lock()
209+
committed := c.CheckAndRecordNonce(recvCounter)
210+
maxN = c.MaxRecvNonce
211+
c.ReplayMu.Unlock()
212+
213+
if !committed {
214+
return rejectResult(c, peerNodeID, recvCounter, maxN)
215+
}
216+
209217
// Successful decrypt — reset all three fault counters under one lock.
210218
c.ReplayMu.Lock()
211219
if c.DecryptFailCount != 0 {
@@ -226,3 +234,44 @@ func DecryptFrame(store *keyexchange.Store, data []byte) DecryptResult {
226234
MaxRecvNonce: maxN,
227235
}
228236
}
237+
238+
// rejectResult builds the DecryptResult for a nonce the replay window
239+
// rejects, and bumps the matching consecutive-fault counter. Shared by the
240+
// read-only pre-check (step 1) and the post-AEAD commit re-check (step 3).
241+
// maxN is the MaxRecvNonce observed under ReplayMu at the point of
242+
// rejection, used both for the caller's logging and to classify the
243+
// rejection as in-window replay vs. outside-window divergence.
244+
func rejectResult(c *keyexchange.Crypto, peerNodeID uint32, recvCounter, maxN uint64) DecryptResult {
245+
err := ErrReplay
246+
if recvCounter < maxN && maxN-recvCounter >= keyexchange.ReplayWindowSize {
247+
err = ErrOutsideWindow
248+
// Track consecutive outside-window rejections. A sustained burst
249+
// means the peer's send counter has diverged from our window's
250+
// high-water-mark too far for in-band recovery — only a fresh key
251+
// exchange resets both sides. The caller (L7) consults
252+
// ShouldDropOnOutsideWindow to enforce the threshold + grace gate.
253+
// Mirrors DecryptFailCount.
254+
c.ReplayMu.Lock()
255+
c.OutsideWindowCount++
256+
c.ReplayMu.Unlock()
257+
} else {
258+
// In-window replay collision. Symmetric counterpart to
259+
// OutsideWindowCount on the *other* side of the window: peer's
260+
// counter is INSIDE [max-window, max] but at a position we've
261+
// already marked. Sustained collisions mean the peer's send counter
262+
// reset (peer restarted with a persistent X25519 identity, so no
263+
// PILA was negotiated) and every frame they now produce lands on a
264+
// bit we already set. Recovery is structurally identical to
265+
// outside-window: gate via ShouldDropOnReplay (threshold + grace),
266+
// then drop the Crypto and trigger a fresh exchange.
267+
c.ReplayMu.Lock()
268+
c.ReplayCount++
269+
c.ReplayMu.Unlock()
270+
}
271+
return DecryptResult{
272+
PeerNodeID: peerNodeID,
273+
Counter: recvCounter,
274+
MaxRecvNonce: maxN,
275+
Err: err,
276+
}
277+
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+
package envelope_test
4+
5+
import (
6+
"bytes"
7+
"encoding/binary"
8+
"errors"
9+
"testing"
10+
11+
"github.com/pilot-protocol/pilotprotocol/pkg/daemon/envelope"
12+
"github.com/pilot-protocol/pilotprotocol/pkg/daemon/keyexchange"
13+
)
14+
15+
// forgeFrame builds a structurally-valid PILS frame that claims to come
16+
// from senderID at the given nonce counter but whose ciphertext is random
17+
// junk — so AEAD.Open MUST fail. Returned bytes start at the senderID
18+
// (i.e. the `data` shape DecryptFrame consumes, PILS magic stripped).
19+
func forgeFrame(senderID uint32, counter uint64) []byte {
20+
// [senderID(4)][nonce(12) = prefix(4)+counter(8)][ciphertext+tag(>=16)]
21+
buf := make([]byte, 4+12+16)
22+
binary.BigEndian.PutUint32(buf[0:4], senderID)
23+
// nonce prefix (bytes 4..8) left zero; counter in the last 8 nonce bytes.
24+
binary.BigEndian.PutUint64(buf[4+4:4+12], counter)
25+
// ciphertext+tag stays all-zero — guaranteed to fail GCM authentication
26+
// against any real key.
27+
return buf
28+
}
29+
30+
// TestForgedHighCounterDoesNotWedgeWindow is the regression test for the
31+
// HIGH-severity remote-DoS: a forged PILS frame carrying a maximal nonce
32+
// counter that FAILS AEAD must NOT advance MaxRecvNonce. Before the
33+
// authenticate-before-commit fix, DecryptFrame recorded the nonce (pinning
34+
// MaxRecvNonce at ~2^63) BEFORE AEAD.Open ran; the failing Open rolled back
35+
// only the replay bit, leaving the high-water-mark stuck. Every subsequent
36+
// genuine frame then fell outside the replay window and was dropped.
37+
//
38+
// This test FAILS on the pre-fix code (the genuine follow-up frame comes
39+
// back ErrOutsideWindow) and PASSES after (it decrypts cleanly).
40+
func TestForgedHighCounterDoesNotWedgeWindow(t *testing.T) {
41+
t.Parallel()
42+
s := newPeerSetup(t)
43+
44+
// A couple of genuine frames flow first, establishing a normal, low
45+
// MaxRecvNonce (counters 1, 2) on the peer side.
46+
for i := 0; i < 2; i++ {
47+
f, err := envelope.EncryptFrame(s.localStore, s.peerID, []byte("legit-early"))
48+
if err != nil {
49+
t.Fatalf("encrypt legit #%d: %v", i, err)
50+
}
51+
if r := envelope.DecryptFrame(s.peerStore, f[4:]); r.Err != nil {
52+
t.Fatalf("decrypt legit #%d: %v", i, r.Err)
53+
}
54+
}
55+
56+
maxBefore := recvMax(s.peerStore, s.localID)
57+
if maxBefore == 0 {
58+
t.Fatalf("precondition: expected MaxRecvNonce advanced, got 0")
59+
}
60+
61+
// ATTACK: inject one forged frame with a maximal counter. It claims to
62+
// be from localID (a public node ID) but its ciphertext is junk, so
63+
// AEAD.Open fails.
64+
const forgedCounter = uint64(1) << 63
65+
res := envelope.DecryptFrame(s.peerStore, forgeFrame(s.localID, forgedCounter))
66+
if !errors.Is(res.Err, envelope.ErrAEAD) {
67+
t.Fatalf("forged frame: err = %v, want ErrAEAD", res.Err)
68+
}
69+
70+
// CORE ASSERTION: the forged frame must NOT have advanced the window.
71+
maxAfter := recvMax(s.peerStore, s.localID)
72+
if maxAfter != maxBefore {
73+
t.Fatalf("forged AEAD-fail advanced MaxRecvNonce: before=%d after=%d "+
74+
"(window wedged — the DoS bug)", maxBefore, maxAfter)
75+
}
76+
77+
// END-TO-END: the next genuine frame (real counter) must still decrypt.
78+
// On the buggy code MaxRecvNonce is pinned at 2^63 so this frame is
79+
// ReplayWindowSize behind the max and comes back ErrOutsideWindow.
80+
legit, err := envelope.EncryptFrame(s.localStore, s.peerID, []byte("post-attack"))
81+
if err != nil {
82+
t.Fatalf("encrypt post-attack: %v", err)
83+
}
84+
got := envelope.DecryptFrame(s.peerStore, legit[4:])
85+
if got.Err != nil {
86+
t.Fatalf("genuine frame after forged injection: err = %v, want success "+
87+
"(window wedged by forged high counter)", got.Err)
88+
}
89+
if !bytes.Equal(got.Plaintext, []byte("post-attack")) {
90+
t.Fatalf("plaintext mismatch: got %q", got.Plaintext)
91+
}
92+
}
93+
94+
// TestForgedInjectionPreservesReplayAndReordering asserts the fix does not
95+
// weaken the legitimate replay-window guarantees: genuine replays are still
96+
// rejected, and in-window out-of-order delivery is still accepted.
97+
func TestForgedInjectionPreservesReplayAndReordering(t *testing.T) {
98+
t.Parallel()
99+
s := newPeerSetup(t)
100+
101+
// Encrypt three frames up front (counters 1, 2, 3) but deliver them out
102+
// of order and with a forged frame interleaved.
103+
f1, _ := envelope.EncryptFrame(s.localStore, s.peerID, []byte("c1"))
104+
f2, _ := envelope.EncryptFrame(s.localStore, s.peerID, []byte("c2"))
105+
f3, _ := envelope.EncryptFrame(s.localStore, s.peerID, []byte("c3"))
106+
107+
// Deliver counter=2 first (advances max to 2).
108+
if r := envelope.DecryptFrame(s.peerStore, f2[4:]); r.Err != nil {
109+
t.Fatalf("deliver c2: %v", r.Err)
110+
}
111+
112+
// Forged high-counter injection between legit frames — must be ErrAEAD
113+
// and must NOT wedge the window.
114+
if r := envelope.DecryptFrame(s.peerStore, forgeFrame(s.localID, uint64(1)<<62)); !errors.Is(r.Err, envelope.ErrAEAD) {
115+
t.Fatalf("forged interleave: err = %v, want ErrAEAD", r.Err)
116+
}
117+
118+
// In-window REORDERING: counter=1 arrives after 2 — still accepted.
119+
if r := envelope.DecryptFrame(s.peerStore, f1[4:]); r.Err != nil {
120+
t.Fatalf("in-window reorder (c1 after c2): err = %v, want success", r.Err)
121+
}
122+
123+
// In-order continuation: counter=3 accepted.
124+
if r := envelope.DecryptFrame(s.peerStore, f3[4:]); r.Err != nil {
125+
t.Fatalf("deliver c3: %v", r.Err)
126+
}
127+
128+
// GENUINE REPLAY: re-deliver counter=2 — must be rejected as a replay.
129+
if r := envelope.DecryptFrame(s.peerStore, f2[4:]); !errors.Is(r.Err, envelope.ErrReplay) {
130+
t.Fatalf("genuine replay of c2: err = %v, want ErrReplay", r.Err)
131+
}
132+
// And counter=1 replay too.
133+
if r := envelope.DecryptFrame(s.peerStore, f1[4:]); !errors.Is(r.Err, envelope.ErrReplay) {
134+
t.Fatalf("genuine replay of c1: err = %v, want ErrReplay", r.Err)
135+
}
136+
}
137+
138+
// recvMax reads the peer-side MaxRecvNonce for the Crypto keyed under
139+
// senderID, under the same lock DecryptFrame uses.
140+
func recvMax(store *keyexchange.Store, senderID uint32) uint64 {
141+
c := store.Get(senderID)
142+
if c == nil {
143+
return 0
144+
}
145+
c.ReplayMu.Lock()
146+
defer c.ReplayMu.Unlock()
147+
return c.MaxRecvNonce
148+
}

pkg/daemon/keyexchange/crypto.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,36 @@ func (c *Crypto) CheckAndRecordNonce(counter uint64) bool {
262262
return true
263263
}
264264

265+
// WouldAcceptNonce reports whether counter would be accepted by
266+
// CheckAndRecordNonce WITHOUT mutating any replay state. It is the
267+
// read-only pre-check L6 (envelope) runs before the (expensive) AEAD-Open:
268+
// obvious replays / out-of-window frames are rejected here so the crypto
269+
// work is skipped, but — crucially — nothing is recorded. The replay
270+
// window is only advanced by CheckAndRecordNonce, and L6 calls that ONLY
271+
// after AEAD.Open authenticates the frame. This ordering is what stops a
272+
// forged high-counter frame (which fails AEAD) from pinning MaxRecvNonce
273+
// and wedging every subsequent genuine frame out of the window.
274+
//
275+
// Must be called with c.ReplayMu held. The logic mirrors the accept/reject
276+
// verdicts of CheckAndRecordNonce exactly, minus the writes.
277+
func (c *Crypto) WouldAcceptNonce(counter uint64) bool {
278+
if c.MaxRecvNonce == 0 {
279+
return true // first packet ever
280+
}
281+
if counter > c.MaxRecvNonce {
282+
return true // new maximum
283+
}
284+
// counter <= MaxRecvNonce
285+
if c.MaxRecvNonce-counter >= ReplayWindowSize {
286+
return false // too old (outside window)
287+
}
288+
bit := counter % ReplayWindowSize
289+
if c.ReplayBitmap[bit/64]&(1<<(bit%64)) != 0 {
290+
return false // already seen (replay)
291+
}
292+
return true
293+
}
294+
265295
// SetReplayBit sets the replay-window bit corresponding to counter.
266296
// Must be called with c.ReplayMu held.
267297
func (c *Crypto) SetReplayBit(counter uint64) {

0 commit comments

Comments
 (0)