dig-nat 0.8.1

Abstract NAT traversal for DIG Node peer connections — one connect() API over direct, UPnP/IGD, NAT-PMP, PCP, relay-coordinated hole-punch, and relay.dig.net as last-resort fallback; establishes an mTLS peer connection with peer_id = SHA256(TLS SPKI DER).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
//! Socket-driven method tests over LOOPBACK UDP responders (in-process, no external network).
//!
//! These exercise the real `transact` / `query_reflexive_address` I/O paths of NAT-PMP, PCP, and
//! STUN by standing up a tiny UDP server on `127.0.0.1` that replies with a canned datagram — the
//! "mocked socket" the task calls for. Covers the success paths (a present gateway/STUN server) and
//! the timeout paths (nothing listening) that the pure encode/parse tests can't reach.

use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::time::Duration;

use dig_nat::method::natpmp::{
    encode_map_request, NatPmpMethod, OP_EXTERNAL_ADDRESS, OP_MAP_UDP, RESPONSE_FLAG,
};
use dig_nat::method::pcp::{MapNonce, PcpMethod, OP_MAP, PCP_VERSION, PROTO_UDP, RESPONSE_BIT};
use dig_nat::method::{TraversalKind, TraversalMethod};
use dig_nat::stun::{query_reflexive_address, StunError, BINDING_SUCCESS, MAGIC_COOKIE};
use dig_nat::{PeerId, PeerTarget};
use tokio::net::UdpSocket;

fn peer(addr: &str) -> PeerTarget {
    PeerTarget::with_addr(
        PeerId::from_bytes([1u8; 32]),
        addr.parse().unwrap(),
        "DIG_MAINNET",
    )
}

/// A loopback NAT-PMP gateway: answers the external-address request then the map request.
#[tokio::test]
async fn natpmp_attempt_succeeds_against_loopback_gateway() {
    let gw = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
    let gw_addr = gw.local_addr().unwrap();

    tokio::spawn(async move {
        let mut buf = [0u8; 32];
        // 1) external-address request → success response with an external IP.
        let (_, from) = gw.recv_from(&mut buf).await.unwrap();
        let mut ext = vec![0u8; 12];
        ext[1] = OP_EXTERNAL_ADDRESS + RESPONSE_FLAG;
        ext[8..12].copy_from_slice(&[203, 0, 113, 9]);
        gw.send_to(&ext, from).await.unwrap();
        // 2) map request → success response.
        let (n, from) = gw.recv_from(&mut buf).await.unwrap();
        assert_eq!(buf[1], OP_MAP_UDP, "second request is a map");
        let _ = n;
        let mut map = vec![0u8; 16];
        map[1] = OP_MAP_UDP + RESPONSE_FLAG;
        map[8..10].copy_from_slice(&4444u16.to_be_bytes());
        map[10..12].copy_from_slice(&4444u16.to_be_bytes());
        map[12..16].copy_from_slice(&7200u32.to_be_bytes());
        gw.send_to(&map, from).await.unwrap();
    });

    let mut m = NatPmpMethod::new(*ipv4(gw_addr), 4444);
    m.gateway = to_v4(gw_addr);
    m.timeout = Duration::from_secs(2);
    let out = m.attempt(&peer("198.51.100.7:4444")).await.unwrap();
    assert_eq!(out.kind, TraversalKind::NatPmp);
    assert_eq!(out.dial_addr(), Some("198.51.100.7:4444".parse().unwrap()));
}

/// No gateway listening → the NAT-PMP method times out (and reports a timeout MethodError).
#[tokio::test]
async fn natpmp_times_out_when_no_gateway() {
    let mut m = NatPmpMethod::new(Ipv4Addr::LOCALHOST, 4444);
    // Point at a port with nothing listening.
    m.gateway = "127.0.0.1:9".parse().unwrap();
    m.timeout = Duration::from_millis(150);
    let err = m.attempt(&peer("198.51.100.7:4444")).await.unwrap_err();
    assert_eq!(err.kind, TraversalKind::NatPmp);
    // No gateway → the method fails gracefully: a timeout (no reply) OR a socket error (an OS that
    // returns port-unreachable, e.g. Windows ICMP → WSAECONNRESET). Either way, never a panic.
    assert!(
        err.timeout || err.reason.contains("io"),
        "graceful failure, got: {}",
        err.reason
    );
}

/// A loopback PCP gateway: answers a MAP request with a MAP success echoing the nonce.
#[tokio::test]
async fn pcp_attempt_succeeds_against_loopback_gateway() {
    let gw = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
    let gw_addr = gw.local_addr().unwrap();

    tokio::spawn(async move {
        let mut buf = [0u8; 128];
        let (n, from) = gw.recv_from(&mut buf).await.unwrap();
        assert!(n >= 60, "a PCP MAP request is 60 bytes");
        // Echo the nonce from the request's MAP body (offset 24..36).
        let nonce: MapNonce = buf[24..36].try_into().unwrap();
        let mut resp = vec![0u8; 60];
        resp[0] = PCP_VERSION;
        resp[1] = OP_MAP | RESPONSE_BIT;
        resp[3] = 0; // success
        resp[4..8].copy_from_slice(&7200u32.to_be_bytes());
        resp[24..36].copy_from_slice(&nonce);
        resp[36] = PROTO_UDP;
        resp[42..44].copy_from_slice(&5555u16.to_be_bytes());
        let mapped = Ipv4Addr::new(203, 0, 113, 9).to_ipv6_mapped().octets();
        resp[44..60].copy_from_slice(&mapped);
        gw.send_to(&resp, from).await.unwrap();
    });

    let mut m = PcpMethod::new(
        *ipv4(gw_addr),
        4444,
        IpAddr::V4(Ipv4Addr::new(192, 168, 1, 50)),
    );
    m.gateway = to_v4(gw_addr);
    m.timeout = Duration::from_secs(2);
    let out = m.attempt(&peer("198.51.100.7:4444")).await.unwrap();
    assert_eq!(out.kind, TraversalKind::Pcp);
    assert_eq!(out.dial_addr(), Some("198.51.100.7:4444".parse().unwrap()));
}

/// #179 MEDIUM regression: PCP's `transact()` previously discarded the response datagram's source
/// (`Ok(Ok((n, _)))`) and accepted any reply echoing the right nonce, regardless of which host sent
/// it. Here an "attacker" socket (NOT the real gateway) sends a well-formed MAP success echoing the
/// (sniffed) nonce but assigning a poisoned external port/IP, racing ahead of the genuine gateway
/// reply. The genuine reply — from the actual gateway address — must be the one `attempt` returns.
#[tokio::test]
async fn pcp_ignores_response_from_non_gateway_source() {
    let gw = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
    let gw_addr = gw.local_addr().unwrap();
    let attacker = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();

    tokio::spawn(async move {
        let mut buf = [0u8; 128];
        let (n, from) = gw.recv_from(&mut buf).await.unwrap();
        assert!(n >= 60, "a PCP MAP request is 60 bytes");
        let nonce: MapNonce = buf[24..36].try_into().unwrap();

        // Attacker sends a spoofed MAP success first, from a different socket/address, echoing the
        // sniffed nonce and assigning a poisoned external port/IP.
        let mut spoofed = vec![0u8; 60];
        spoofed[0] = PCP_VERSION;
        spoofed[1] = OP_MAP | RESPONSE_BIT;
        spoofed[3] = 0;
        spoofed[4..8].copy_from_slice(&7200u32.to_be_bytes());
        spoofed[24..36].copy_from_slice(&nonce);
        spoofed[36] = PROTO_UDP;
        spoofed[42..44].copy_from_slice(&6666u16.to_be_bytes());
        let poisoned = Ipv4Addr::new(198, 51, 100, 66).to_ipv6_mapped().octets();
        spoofed[44..60].copy_from_slice(&poisoned);
        attacker.send_to(&spoofed, from).await.unwrap();

        tokio::time::sleep(Duration::from_millis(50)).await;

        // The genuine gateway reply follows.
        let mut resp = vec![0u8; 60];
        resp[0] = PCP_VERSION;
        resp[1] = OP_MAP | RESPONSE_BIT;
        resp[3] = 0;
        resp[4..8].copy_from_slice(&7200u32.to_be_bytes());
        resp[24..36].copy_from_slice(&nonce);
        resp[36] = PROTO_UDP;
        resp[42..44].copy_from_slice(&5555u16.to_be_bytes());
        let mapped = Ipv4Addr::new(203, 0, 113, 9).to_ipv6_mapped().octets();
        resp[44..60].copy_from_slice(&mapped);
        gw.send_to(&resp, from).await.unwrap();
    });

    let mut m = PcpMethod::new(
        *ipv4(gw_addr),
        4444,
        IpAddr::V4(Ipv4Addr::new(192, 168, 1, 50)),
    );
    m.gateway = to_v4(gw_addr);
    m.timeout = Duration::from_secs(2);
    let out = m
        .attempt(&peer("198.51.100.7:4444"))
        .await
        .expect("keeps waiting past the spoofed datagram and accepts the genuine reply");
    assert_eq!(out.kind, TraversalKind::Pcp);
    assert_eq!(out.dial_addr(), Some("198.51.100.7:4444".parse().unwrap()));
}

#[tokio::test]
async fn pcp_times_out_when_no_gateway() {
    let mut m = PcpMethod::new(Ipv4Addr::LOCALHOST, 4444, IpAddr::V4(Ipv4Addr::LOCALHOST));
    m.gateway = "127.0.0.1:9".parse().unwrap();
    m.timeout = Duration::from_millis(150);
    let err = m.attempt(&peer("198.51.100.7:4444")).await.unwrap_err();
    assert_eq!(err.kind, TraversalKind::Pcp);
    // Timeout (no reply) or a socket error (OS port-unreachable) — either is graceful, never a panic.
    assert!(
        err.timeout || err.reason.contains("io"),
        "graceful failure, got: {}",
        err.reason
    );
}

/// A loopback STUN server: replies to a Binding request with a Binding success carrying an
/// XOR-MAPPED-ADDRESS. Proves the real `query_reflexive_address` round-trip.
#[tokio::test]
async fn stun_query_reflexive_address_against_loopback_server() {
    let server = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
    let server_addr = server.local_addr().unwrap();

    tokio::spawn(async move {
        let mut buf = [0u8; 512];
        let (_, from) = server.recv_from(&mut buf).await.unwrap();
        // Echo the transaction id from the request (bytes 8..20).
        let txid: [u8; 12] = buf[8..20].try_into().unwrap();
        // A genuinely-global reflexive address (documentation ranges are rejected by #1387).
        let reflexive = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 41234);
        let resp = build_xor_mapped_response(&txid, reflexive);
        server.send_to(&resp, from).await.unwrap();
    });

    let client = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
    let got = query_reflexive_address(&client, server_addr, Duration::from_secs(2))
        .await
        .unwrap();
    assert_eq!(got.port(), 41234);
    assert_eq!(got.ip(), IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)));
}

/// #1387: a STUN server that returns a NON-GLOBAL/reserved reflexive address (here loopback) must
/// be rejected — `query_reflexive_address` surfaces `NoMappedAddress` rather than advertising a
/// bogus address. Guards against a malicious/compromised STUN server (the relay).
#[tokio::test]
async fn stun_rejects_reserved_reflexive_address() {
    let server = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
    let server_addr = server.local_addr().unwrap();

    tokio::spawn(async move {
        let mut buf = [0u8; 512];
        let (_, from) = server.recv_from(&mut buf).await.unwrap();
        let txid: [u8; 12] = buf[8..20].try_into().unwrap();
        // A reserved (loopback) reflexive — never a legitimate server-reflexive candidate.
        let bogus = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 41234);
        let resp = build_xor_mapped_response(&txid, bogus);
        server.send_to(&resp, from).await.unwrap();
    });

    let client = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
    let err = query_reflexive_address(&client, server_addr, Duration::from_secs(2))
        .await
        .unwrap_err();
    assert_eq!(
        err,
        StunError::NoMappedAddress,
        "a reserved/non-global reflexive must be rejected as NoMappedAddress"
    );
}

/// #179 MEDIUM regression: `query_reflexive_address` previously discarded the datagram source
/// (`let (n, _from) = ...`) and accepted ANY reply that passed the txid check, regardless of which
/// host sent it. Here an "attacker" socket (NOT the queried `server`) sends a well-formed
/// BINDING_SUCCESS with the correct (sniffed) transaction id and a poisoned reflexive address,
/// racing ahead of the real server's genuine reply. The client MUST ignore the off-server datagram
/// and keep waiting within the deadline for a reply that actually originates from `server`.
#[tokio::test]
async fn stun_ignores_response_from_non_queried_source() {
    let server = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
    let server_addr = server.local_addr().unwrap();
    let attacker = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();

    // Both are genuinely-global addresses (documentation ranges are rejected by #1387); the point
    // of this test is source-address selection, so both must pass the usability guard.
    let genuine_reflexive = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 41234);
    let poisoned_reflexive = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)), 6666);

    tokio::spawn(async move {
        let mut buf = [0u8; 512];
        let (_, from) = server.recv_from(&mut buf).await.unwrap();
        let txid: [u8; 12] = buf[8..20].try_into().unwrap();

        // The attacker (a different socket/address than `server`) sends a well-formed, correctly
        // txid'd BINDING_SUCCESS FIRST, carrying a poisoned reflexive address.
        let spoofed = build_xor_mapped_response(&txid, poisoned_reflexive);
        attacker.send_to(&spoofed, from).await.unwrap();

        // Give the spoofed datagram a moment to arrive and (if the bug is present) be accepted
        // before the genuine reply is sent.
        tokio::time::sleep(Duration::from_millis(50)).await;

        // The real server's genuine reply follows.
        let genuine = build_xor_mapped_response(&txid, genuine_reflexive);
        server.send_to(&genuine, from).await.unwrap();
    });

    let client = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
    let got = query_reflexive_address(&client, server_addr, Duration::from_secs(2))
        .await
        .expect("keeps waiting past the spoofed datagram and accepts the genuine reply");
    assert_eq!(
        got, genuine_reflexive,
        "must return the server's genuine reflexive address, not the attacker's poisoned one"
    );
}

/// #1387 Bug 1: a STUN server that returns an IPv4-mapped IPv6 reflexive smuggling a reserved
/// IPv4 range (here `::ffff:127.0.0.1`) must be rejected — the guard folds the mapped form to V4
/// before classifying, so it cannot bypass the V4 predicate. Proves the fix end-to-end through
/// `decode_mapped_address` (the attacker controls all 16 bytes), not just the predicate in isolation.
#[tokio::test]
async fn stun_rejects_ipv4_mapped_reserved_reflexive_address() {
    let server = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
    let server_addr = server.local_addr().unwrap();

    tokio::spawn(async move {
        let mut buf = [0u8; 512];
        let (_, from) = server.recv_from(&mut buf).await.unwrap();
        let txid: [u8; 12] = buf[8..20].try_into().unwrap();
        // ::ffff:127.0.0.1 — an IPv4-mapped loopback smuggled as a V6 XOR-MAPPED-ADDRESS.
        let bogus = SocketAddr::new(
            IpAddr::V6(Ipv4Addr::new(127, 0, 0, 1).to_ipv6_mapped()),
            41234,
        );
        let resp = build_xor_mapped_v6_response(&txid, bogus);
        server.send_to(&resp, from).await.unwrap();
    });

    let client = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
    let err = query_reflexive_address(&client, server_addr, Duration::from_secs(2))
        .await
        .unwrap_err();
    assert_eq!(
        err,
        StunError::NoMappedAddress,
        "an IPv4-mapped reserved reflexive must be rejected, not smuggled past the V6 arm"
    );
}

#[tokio::test]
async fn stun_query_times_out_when_no_server() {
    let client = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
    let err = query_reflexive_address(
        &client,
        "127.0.0.1:9".parse().unwrap(),
        Duration::from_millis(150),
    )
    .await
    .unwrap_err();
    // No STUN server → Timeout (no reply) or Io (OS port-unreachable). Either is a graceful failure.
    assert!(
        matches!(err, StunError::Timeout | StunError::Io(_)),
        "graceful failure, got: {err:?}"
    );
}

/// Encode-map-request helper is reachable from an integration test (belt-and-suspenders on the
/// public encoder alongside the datagram unit tests).
#[test]
fn natpmp_map_encoder_public() {
    let req = encode_map_request(true, 1, 2, 3);
    assert_eq!(req.len(), 12);
}

// ---- helpers ----

fn ipv4(addr: SocketAddr) -> &'static Ipv4Addr {
    // Leak a small Ipv4Addr for the &'static return used only in test setup.
    match addr {
        SocketAddr::V4(v4) => Box::leak(Box::new(*v4.ip())),
        SocketAddr::V6(_) => Box::leak(Box::new(Ipv4Addr::LOCALHOST)),
    }
}

fn to_v4(addr: SocketAddr) -> std::net::SocketAddrV4 {
    match addr {
        SocketAddr::V4(v4) => v4,
        SocketAddr::V6(_) => std::net::SocketAddrV4::new(Ipv4Addr::LOCALHOST, addr.port()),
    }
}

/// Build a STUN Binding success response with an XOR-MAPPED-ADDRESS (IPv4).
fn build_xor_mapped_response(txid: &[u8; 12], addr: SocketAddr) -> Vec<u8> {
    let cookie_be = MAGIC_COOKIE.to_be_bytes();
    let mut value = vec![0u8]; // reserved
    value.push(0x01); // IPv4
    let port = addr.port() ^ ((MAGIC_COOKIE >> 16) as u16);
    value.extend_from_slice(&port.to_be_bytes());
    let IpAddr::V4(v4) = addr.ip() else {
        unreachable!()
    };
    let mut octets = v4.octets();
    for (i, o) in octets.iter_mut().enumerate() {
        *o ^= cookie_be[i];
    }
    value.extend_from_slice(&octets);

    let mut attr = Vec::new();
    attr.extend_from_slice(&0x0020u16.to_be_bytes()); // XOR-MAPPED-ADDRESS
    attr.extend_from_slice(&(value.len() as u16).to_be_bytes());
    attr.extend_from_slice(&value);

    let mut msg = Vec::new();
    msg.extend_from_slice(&BINDING_SUCCESS.to_be_bytes());
    msg.extend_from_slice(&(attr.len() as u16).to_be_bytes());
    msg.extend_from_slice(&cookie_be);
    msg.extend_from_slice(txid);
    msg.extend_from_slice(&attr);
    msg
}

/// Build a STUN Binding success response with an XOR-MAPPED-ADDRESS (IPv6). The 16-byte address is
/// XORed with the cookie‖transaction-id key (RFC 5389 §15.2) — exactly what a malicious server can
/// forge to smuggle an IPv4-mapped address past a V6-only classifier.
fn build_xor_mapped_v6_response(txid: &[u8; 12], addr: SocketAddr) -> Vec<u8> {
    let cookie_be = MAGIC_COOKIE.to_be_bytes();
    let mut value = vec![0u8]; // reserved
    value.push(0x02); // IPv6
    let port = addr.port() ^ ((MAGIC_COOKIE >> 16) as u16);
    value.extend_from_slice(&port.to_be_bytes());
    let IpAddr::V6(v6) = addr.ip() else {
        unreachable!()
    };
    let mut octets = v6.octets();
    let mut key = [0u8; 16];
    key[..4].copy_from_slice(&cookie_be);
    key[4..].copy_from_slice(txid);
    for (o, k) in octets.iter_mut().zip(key.iter()) {
        *o ^= *k;
    }
    value.extend_from_slice(&octets);

    let mut attr = Vec::new();
    attr.extend_from_slice(&0x0020u16.to_be_bytes()); // XOR-MAPPED-ADDRESS
    attr.extend_from_slice(&(value.len() as u16).to_be_bytes());
    attr.extend_from_slice(&value);

    let mut msg = Vec::new();
    msg.extend_from_slice(&BINDING_SUCCESS.to_be_bytes());
    msg.extend_from_slice(&(attr.len() as u16).to_be_bytes());
    msg.extend_from_slice(&cookie_be);
    msg.extend_from_slice(txid);
    msg.extend_from_slice(&attr);
    msg
}