Skip to content

Commit d4d5f42

Browse files
committed
Preserve nonce/replay state across duplicate key_exchange
Bug pinned by TestHandleKeyExchangeDuplicatePreservesCryptoState. When a peer sent a SECOND key_exchange with the SAME X25519 ephemeral pubkey (e.g. their reply crossed our retransmit on the wire), handleAuthKeyExchange and handleKeyExchange unconditionally replaced tm.crypto[N] with a freshly-derived peerCrypto. The freshly-derived pc has the same shared secret (pubkeys unchanged → same ECDH output) but RESET nonce counter, RESET maxRecvNonce, and EMPTY replay bitmap. Net effect: subsequent encrypted sends from us used counter 1,2,3... on the new pc, while peer's pc[us] still had a high maxRecvNonce from before. Peer dropped our packets as "outside replay window" or, if a counter happened to land on a clear bit before the reset became visible, "replay detected." Data plane silently broke under the harmless event of receiving a duplicate handshake. The fix: replace tm.crypto[N] only when there is no existing entry OR the peer's pubkey actually changed. The shared secret is deterministic from the pubkey pair; preserving the existing pc keeps the nonce monotonic and the replay window valid. This was the same fix I tried earlier this session (and reverted) because it broke other tests. Those failures were caused by the salvage cap of 32 — large replay batches were running over the receiver's nonce window. After 5de736f reduced the cap to 4, the salvage replay batch is small enough that the duplicate-key_exchange preservation fix becomes safe. Tests: - 2 new unit tests in tunnel_dup_keyexchange_test.go cover the preserve-on-duplicate and replace-on-real-rekey paths. - All 10 packages green on go test ./pkg/... ./tests/. - 10 targeted integration tests pass: tunnel_desync_recovery, midrekey_{send_file,send_message,task_results,task_submit}, peer_restarted_{send_file,send_message}, sender_clean_restart_midflight, chaos_packet_loss, force_relay_task.
1 parent c843c4a commit d4d5f42

2 files changed

Lines changed: 172 additions & 2 deletions

File tree

pkg/daemon/tunnel.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -728,7 +728,18 @@ func (tm *TunnelManager) handleAuthKeyExchange(data []byte, from *net.UDPAddr, f
728728
oldPC := tm.crypto[peerNodeID]
729729
hadCrypto := oldPC != nil
730730
keyChanged := hadCrypto && oldPC.peerX25519Key != pc.peerX25519Key
731-
tm.crypto[peerNodeID] = pc
731+
// Only replace tm.crypto when there is no existing entry OR the
732+
// peer's X25519 ephemeral key actually changed. Replacing on a
733+
// duplicate key_exchange (same pubkey — common under our retransmit
734+
// loop or a peer's reply crossing on the wire) would reset the
735+
// nonce counter and replay-window bitmap, causing our subsequent
736+
// encrypted sends and any in-flight peer packets to look wrong on
737+
// the wire. The shared secret derived from the same pubkey is
738+
// identical; the existing peerCrypto is the correct one.
739+
// Pinned by TestHandleKeyExchangeDuplicatePreservesCryptoState.
740+
if !hadCrypto || keyChanged {
741+
tm.crypto[peerNodeID] = pc
742+
}
732743
if !fromRelay {
733744
tm.peers[peerNodeID] = from
734745
} else if _, ok := tm.peers[peerNodeID]; !ok && tm.beaconAddr != nil {
@@ -831,7 +842,12 @@ func (tm *TunnelManager) handleKeyExchange(data []byte, from *net.UDPAddr, fromR
831842
hadCrypto := oldPC != nil
832843
// Detect rekeying: peer restarted with a new keypair
833844
keyChanged := hadCrypto && oldPC.peerX25519Key != pc.peerX25519Key
834-
tm.crypto[peerNodeID] = pc
845+
// Same rationale as handleAuthKeyExchange: don't replace on a
846+
// duplicate key_exchange (same pubkey) — the nonce counter +
847+
// replay-window reset would invalidate in-flight ciphertexts.
848+
if !hadCrypto || keyChanged {
849+
tm.crypto[peerNodeID] = pc
850+
}
835851
if !fromRelay {
836852
tm.peers[peerNodeID] = from
837853
} else if _, ok := tm.peers[peerNodeID]; !ok && tm.beaconAddr != nil {
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+
package daemon
4+
5+
import (
6+
"crypto/ecdh"
7+
"crypto/rand"
8+
"encoding/binary"
9+
"testing"
10+
)
11+
12+
// TestHandleKeyExchangeDuplicatePreservesCryptoState pins the latent
13+
// bug we identified during the P1-010 work: when a peer sends a SECOND
14+
// key_exchange with the SAME X25519 ephemeral pubkey (e.g. their reply
15+
// crossed our retransmit on the wire), our handleKeyExchange used to
16+
// unconditionally replace tm.crypto[N] with a fresh peerCrypto. That
17+
// reset the nonce counter and replay-window bitmap, which then caused
18+
// our subsequent encrypted sends (using the now-zero counter) to fall
19+
// outside the receiver's now-stale replay window — the receiver flagged
20+
// them as "outside replay window" or "replay detected" and dropped them.
21+
//
22+
// Net effect: a duplicate handshake silently broke the data plane.
23+
//
24+
// The fix: skip replacement when peer's pubkey is unchanged. The
25+
// shared secret is identical; preserving the existing peerCrypto keeps
26+
// the nonce counter monotonic and the replay window valid.
27+
func TestHandleKeyExchangeDuplicatePreservesCryptoState(t *testing.T) {
28+
tm := NewTunnelManager()
29+
if err := tm.EnableEncryption(); err != nil {
30+
t.Fatalf("EnableEncryption: %v", err)
31+
}
32+
if err := tm.Listen("127.0.0.1:0"); err != nil {
33+
t.Fatalf("Listen: %v", err)
34+
}
35+
defer tm.Close()
36+
37+
const peerID uint32 = 100
38+
tm.AddPeer(peerID, mustUDPAddr(t, "127.0.0.1:9999"))
39+
40+
// Generate the peer's X25519 ephemeral keypair. Same pubkey will be
41+
// reused for the duplicate.
42+
curve := ecdh.X25519()
43+
peerPriv, err := curve.GenerateKey(rand.Reader)
44+
if err != nil {
45+
t.Fatalf("peer keygen: %v", err)
46+
}
47+
peerPub := peerPriv.PublicKey().Bytes()
48+
49+
frame := make([]byte, 36)
50+
binary.BigEndian.PutUint32(frame[0:4], peerID)
51+
copy(frame[4:36], peerPub)
52+
53+
from := mustUDPAddr(t, "127.0.0.1:1111")
54+
tm.handleKeyExchange(frame, from, false)
55+
56+
tm.mu.RLock()
57+
pc1 := tm.crypto[peerID]
58+
tm.mu.RUnlock()
59+
if pc1 == nil {
60+
t.Fatal("first handleKeyExchange did not install crypto")
61+
}
62+
63+
// Simulate steady-state activity: advance the send counter to 100,
64+
// set the recv max to 100, mark bit 100 in the replay bitmap. A
65+
// well-behaved fix preserves all three across the duplicate.
66+
pc1.nonce = 100
67+
pc1.replayMu.Lock()
68+
pc1.maxRecvNonce = 100
69+
pc1.setReplayBit(100)
70+
pc1.replayMu.Unlock()
71+
72+
// Same peer sends key_exchange AGAIN with the SAME pubkey. This is
73+
// what happens under the rekey retransmit loop — our reply crossed
74+
// peer's retransmit on the wire, so peer sends another to be safe.
75+
tm.handleKeyExchange(frame, from, false)
76+
77+
tm.mu.RLock()
78+
pc2 := tm.crypto[peerID]
79+
tm.mu.RUnlock()
80+
if pc2 == nil {
81+
t.Fatal("crypto disappeared after duplicate handleKeyExchange")
82+
}
83+
84+
// Pointer-identity is the strongest proof of "didn't replace."
85+
if pc2 != pc1 {
86+
t.Fatalf("duplicate key_exchange replaced peerCrypto pointer (was %p, now %p)", pc1, pc2)
87+
}
88+
89+
// Even if the implementation chose to install a new pc with the
90+
// same fields, the counters MUST be preserved.
91+
if pc2.nonce != 100 {
92+
t.Errorf("send counter reset: nonce = %d, want 100", pc2.nonce)
93+
}
94+
pc2.replayMu.Lock()
95+
max := pc2.maxRecvNonce
96+
bitSet := pc2.replayBitmap[100/64]&(1<<(100%64)) != 0
97+
pc2.replayMu.Unlock()
98+
if max != 100 {
99+
t.Errorf("recv max reset: maxRecvNonce = %d, want 100", max)
100+
}
101+
if !bitSet {
102+
t.Error("replay bitmap reset: bit 100 not set after duplicate key_exchange")
103+
}
104+
}
105+
106+
// TestHandleKeyExchangeNewPubkeyDoesReplace is the complement: when
107+
// peer's pubkey actually CHANGES (peer restarted, new ephemeral keys),
108+
// we MUST replace — the shared secret is different, so reusing the old
109+
// peerCrypto would decrypt nothing.
110+
func TestHandleKeyExchangeNewPubkeyDoesReplace(t *testing.T) {
111+
tm := NewTunnelManager()
112+
if err := tm.EnableEncryption(); err != nil {
113+
t.Fatalf("EnableEncryption: %v", err)
114+
}
115+
if err := tm.Listen("127.0.0.1:0"); err != nil {
116+
t.Fatalf("Listen: %v", err)
117+
}
118+
defer tm.Close()
119+
120+
const peerID uint32 = 100
121+
tm.AddPeer(peerID, mustUDPAddr(t, "127.0.0.1:9999"))
122+
123+
curve := ecdh.X25519()
124+
priv1, _ := curve.GenerateKey(rand.Reader)
125+
priv2, _ := curve.GenerateKey(rand.Reader)
126+
127+
mkFrame := func(pub []byte) []byte {
128+
f := make([]byte, 36)
129+
binary.BigEndian.PutUint32(f[0:4], peerID)
130+
copy(f[4:36], pub)
131+
return f
132+
}
133+
134+
from := mustUDPAddr(t, "127.0.0.1:1111")
135+
tm.handleKeyExchange(mkFrame(priv1.PublicKey().Bytes()), from, false)
136+
pc1 := tm.crypto[peerID]
137+
if pc1 == nil {
138+
t.Fatal("first install missing")
139+
}
140+
141+
// Peer "restarts" with new ephemeral keys.
142+
tm.handleKeyExchange(mkFrame(priv2.PublicKey().Bytes()), from, false)
143+
pc2 := tm.crypto[peerID]
144+
if pc2 == nil {
145+
t.Fatal("second install missing")
146+
}
147+
if pc2 == pc1 {
148+
t.Fatal("crypto should have been replaced when pubkey changed")
149+
}
150+
// Different shared secret — peerX25519Key field reflects new pubkey.
151+
if pc2.peerX25519Key == pc1.peerX25519Key {
152+
t.Fatal("peerX25519Key did not update on rekey")
153+
}
154+
}

0 commit comments

Comments
 (0)