fips-core 0.4.33

Reusable FIPS mesh, endpoint, transport, and protocol library
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
use super::*;

#[test]
fn pending_discovery_lookup_queue_owns_dedup_and_capacity() {
    let mut lookups = crate::node::handlers::discovery::PendingDiscoveryLookups::default();
    let first = NodeAddr::from_bytes([1u8; 16]);
    let second = NodeAddr::from_bytes([2u8; 16]);

    assert!(lookups.admission_for(&first, 1).accepted());
    assert!(lookups.insert_new(first, 123).is_none());
    assert_eq!(lookups.len(), 1);
    assert_eq!(
        lookups.get(&first).map(|lookup| lookup.initiated_ms),
        Some(123)
    );

    assert!(lookups.admission_for(&first, 1).deduplicated());
    assert!(lookups.admission_for(&second, 1).queue_full());
    assert!(lookups.remove(&first).is_some());
    assert!(lookups.admission_for(&second, 1).accepted());
}

/// Pin the per-attempt timeout sequence in `check_pending_lookups`.
///
/// Drives the state machine deterministically through the default
/// `node.discovery.attempt_timeouts_secs = [1, 2, 4, 8]` sequence.
/// Asserts:
///   1. **Sequence timing** — retries fire at the cumulative deadlines
///      (t=1100ms, 3100ms, 7100ms) and unreachable at t=15100ms.
///   2. **Fresh `initiate_lookup` per attempt** — `req_initiated` counter
///      increments by exactly one on each retry. The actual `request_id`
///      is generated by `LookupRequest::generate(...)` via `rand::random()`
///      inside `initiate_lookup` and is not stored on the originator
///      side, so per-attempt freshness is verified indirectly: each
///      `req_initiated` increment corresponds to one fresh
///      `LookupRequest::generate` call.
///   3. **Final-timeout state transitions** — `pending_lookups` entry is
///      removed, `discovery.resp_timed_out` counter ticks, queued packet
///      is drained, and an ICMPv6 Destination Unreachable frame is
///      emitted via the TUN sender.
///
/// Skipped: direct request_id capture (originator does not record its
/// own request_ids; would require production instrumentation). The
/// `req_initiated` counter is the strongest cleanly-observable signal
/// that `initiate_lookup` ran fresh on each attempt.
#[tokio::test]
async fn reply_learned_zero_peer_lookup_does_not_backoff_destination() {
    let mut node = make_node();
    node.config.node.routing.mode = RoutingMode::ReplyLearned;
    let target = make_node_addr(0x45);

    let baseline_initiated = node.stats().discovery.req_initiated;
    node.maybe_initiate_lookup(&target).await;
    assert_eq!(
        node.stats().discovery.req_initiated,
        baseline_initiated + 1,
        "the first attempt should still construct one lookup request"
    );
    assert!(
        !node.pending_lookups.contains_key(&target),
        "a zero-carrier lookup must not remain pending"
    );
    assert!(
        !node.discovery_backoff.is_suppressed(&target),
        "no lookup was put on the wire, so the destination must stay retryable"
    );

    node.maybe_initiate_lookup(&target).await;
    assert_eq!(
        node.stats().discovery.req_initiated,
        baseline_initiated + 2,
        "a later retry should not be suppressed by zero-carrier startup"
    );
}

#[tokio::test]
async fn test_check_pending_lookups_default_sequence_unreachable() {
    use crate::bloom::BloomFilter;
    use crate::node::handlers::discovery::PendingLookup;
    use crate::peer::ActivePeer;
    use crate::transport::LinkId;

    let mut node = make_node();

    // Default attempt_timeouts_secs is [1, 2, 4, 8]. Confirm so the test
    // cannot silently drift if the default changes.
    assert_eq!(
        node.config.node.discovery.attempt_timeouts_secs,
        vec![1, 2, 4, 8],
        "test pins the [1,2,4,8] default; update the test if the default changes"
    );

    // Inject a TUN sender so `send_icmpv6_dest_unreachable` is observable.
    let (tun_tx, tun_rx) = crate::upper::tun::write_channel();
    node.tun_tx = Some(tun_tx);

    // Build a target identity (the unreachable destination).
    let target_identity = Identity::generate();
    let target_addr = *target_identity.node_addr();

    // Build a tree-peer that:
    //   - has the target in its inbound bloom filter (so `may_reach` is true),
    //   - declares us as its parent (so `is_tree_peer` returns true).
    // The peer has no Noise session, so dataplane FMP-link output will
    // fail at the wire-send step — but `initiate_lookup` already incremented
    // `req_initiated` and the failure is logged at `debug!`. The state-
    // machine bookkeeping we want to test runs to completion either way.
    let peer_identity_full = Identity::generate();
    let peer_addr = *peer_identity_full.node_addr();
    let peer_identity = crate::PeerIdentity::from_pubkey(peer_identity_full.pubkey());
    let mut peer = ActivePeer::new(peer_identity, LinkId::new(1), 0);
    let mut bloom = BloomFilter::new();
    bloom.insert(&target_addr);
    peer.update_filter(bloom, 1, 0);
    node.peers.insert(peer_addr, peer);

    // Make the peer a tree-peer: install a peer declaration that names us
    // as its parent. `is_tree_peer` checks both directions — the child
    // direction (peer.parent_id == self.node_addr) is what we exercise.
    let our_addr = *node.node_addr();
    let peer_decl = crate::tree::ParentDeclaration::new(peer_addr, our_addr, 1, 0);
    let peer_coords = TreeCoordinate::from_addrs(vec![peer_addr, our_addr]).unwrap();
    node.tree_state_mut().update_peer(peer_decl, peer_coords);
    assert!(node.is_tree_peer(&peer_addr), "peer must be a tree peer");

    // Queue an IPv6 packet for the target so the final-timeout drop +
    // ICMPv6 emission can be observed. Build a minimal valid IPv6 header
    // with a non-multicast, non-unspecified source so
    // `should_send_icmp_error` returns true.
    let mut ipv6_pkt = vec![0u8; 40];
    ipv6_pkt[0] = 0x60; // version 6
    ipv6_pkt[6] = 17; // next_header = UDP (not ICMPv6)
    ipv6_pkt[7] = 64; // hop limit
    // src = fd00::1 (non-multicast, non-unspecified)
    ipv6_pkt[8] = 0xfd;
    ipv6_pkt[23] = 0x01;
    // dst = target's IPv6 representation (not strictly required, just non-multicast)
    let target_ipv6 = crate::FipsAddress::from_node_addr(&target_addr).to_ipv6();
    ipv6_pkt[24..40].copy_from_slice(&target_ipv6.octets());
    node.pending_session_traffic
        .push_tun_packet(target_addr, ipv6_pkt, usize::MAX, usize::MAX);

    // Inject a PendingLookup directly: attempt=1, last_sent_ms=0. This
    // mirrors the post-condition of a successful `maybe_initiate_lookup`
    // at t=0 without depending on wall-clock-derived `Self::now_ms()`.
    node.pending_lookups
        .insert(target_addr, PendingLookup::new(0));

    let baseline_initiated = node.stats().discovery.req_initiated;
    let baseline_timed_out = node.stats().discovery.resp_timed_out;

    // --- t = 1100ms: first retry deadline (1*1000) ---
    node.check_pending_lookups(1100).await;
    {
        let entry = node
            .pending_lookups
            .get(&target_addr)
            .expect("still pending");
        assert_eq!(entry.attempt, 2, "after retry #1, attempt should be 2");
        assert_eq!(entry.last_sent_ms, 1100);
    }
    assert_eq!(
        node.stats().discovery.req_initiated,
        baseline_initiated + 1,
        "retry #1 must invoke initiate_lookup exactly once"
    );

    // --- t = 3100ms: second retry deadline (cumulative 1+2 = 3s) ---
    node.check_pending_lookups(3100).await;
    {
        let entry = node
            .pending_lookups
            .get(&target_addr)
            .expect("still pending");
        assert_eq!(entry.attempt, 3, "after retry #2, attempt should be 3");
        assert_eq!(entry.last_sent_ms, 3100);
    }
    assert_eq!(
        node.stats().discovery.req_initiated,
        baseline_initiated + 2,
        "retry #2 must invoke initiate_lookup exactly once more"
    );

    // --- t = 7100ms: third retry deadline (cumulative 1+2+4 = 7s) ---
    node.check_pending_lookups(7100).await;
    {
        let entry = node
            .pending_lookups
            .get(&target_addr)
            .expect("still pending");
        assert_eq!(entry.attempt, 4, "after retry #3, attempt should be 4");
        assert_eq!(entry.last_sent_ms, 7100);
    }
    assert_eq!(
        node.stats().discovery.req_initiated,
        baseline_initiated + 3,
        "retry #3 must invoke initiate_lookup exactly once more"
    );

    // --- Just-before-final: at t=15099ms the 8s window is not yet reached ---
    node.check_pending_lookups(15_099).await;
    assert!(
        node.pending_lookups.contains_key(&target_addr),
        "8s window not yet expired: pending_lookup must persist"
    );
    assert_eq!(
        node.stats().discovery.req_initiated,
        baseline_initiated + 3,
        "no new attempt before final deadline"
    );
    assert_eq!(
        node.stats().discovery.resp_timed_out,
        baseline_timed_out,
        "no timeout before final deadline"
    );

    // --- t = 15100ms: final deadline (cumulative 1+2+4+8 = 15s) ---
    // Drain any TUN frames that may have leaked from earlier steps so the
    // post-final-timeout drain observes only the unreachable-emission output.
    while tun_rx.try_recv_packet().is_ok() {}

    node.check_pending_lookups(15_100).await;

    // Pending lookup is dropped.
    assert!(
        !node.pending_lookups.contains_key(&target_addr),
        "final timeout must remove the pending_lookups entry"
    );
    // resp_timed_out counter ticked.
    assert_eq!(
        node.stats().discovery.resp_timed_out,
        baseline_timed_out + 1,
        "final timeout must increment discovery.resp_timed_out"
    );
    // No additional initiate_lookup on the timeout step.
    assert_eq!(
        node.stats().discovery.req_initiated,
        baseline_initiated + 3,
        "the final-timeout step must NOT call initiate_lookup"
    );
    // Queued packet was drained from pending_tun_packets.
    assert!(
        node.pending_session_traffic
            .tun_packets_for(&target_addr)
            .is_none(),
        "queued packets for the unreachable target must be drained"
    );

    // ICMPv6 Destination Unreachable was emitted to the TUN sender.
    let icmp_frame = tun_rx
        .try_recv_packet()
        .map(|packet| packet.as_slice().to_vec())
        .expect("ICMPv6 Destination Unreachable must be emitted on final timeout");
    assert!(
        icmp_frame.len() >= 48,
        "ICMPv6 frame must be at least IPv6 header (40) + ICMPv6 header (8)"
    );
    assert_eq!(icmp_frame[0] >> 4, 6, "must be IPv6");
    assert_eq!(icmp_frame[6], 58, "next_header must be IPPROTO_ICMPV6 (58)");
    assert_eq!(icmp_frame[40], 1, "ICMPv6 type 1 = Destination Unreachable");

    let baseline_suppressed = node.stats().discovery.req_backoff_suppressed;
    node.maybe_initiate_lookup(&target_addr).await;
    assert_eq!(
        node.stats().discovery.req_backoff_suppressed,
        baseline_suppressed + 1,
        "an immediately repeated lookup for the same offline target must be backoff-suppressed"
    );
    assert_eq!(
        node.stats().discovery.req_initiated,
        baseline_initiated + 3,
        "backoff suppression must not send another fresh LookupRequest"
    );
    assert!(
        !node.pending_lookups.contains_key(&target_addr),
        "backoff suppression must not re-open the pending lookup"
    );
}

#[tokio::test]
async fn lookup_timeout_preserves_endpoint_data_for_fsp_responder_awaiting_msg3() {
    use crate::node::handlers::discovery::PendingLookup;
    use crate::node::session::{EndToEndState, SessionEntry};

    let mut node = make_node();
    let target = Identity::generate();
    let target_addr = *target.node_addr();
    let responder = crate::noise::HandshakeState::new_xk_responder(node.identity().keypair());
    node.sessions.insert(
        target_addr,
        SessionEntry::new(
            target_addr,
            node.identity().pubkey_full(),
            EndToEndState::AwaitingMsg3(responder),
            1_000,
            false,
        ),
    );
    node.pending_session_traffic
        .push_endpoint_data_batch_with_enqueued_at_ms(
            target_addr,
            vec![
                crate::node::EndpointDataPayload::from_service_datagram(
                    7_370,
                    7_370,
                    b"tcp-syn".to_vec(),
                )
                .expect("pending service datagram"),
            ],
            usize::MAX,
            usize::MAX,
            1_000,
        );
    let mut lookup = PendingLookup::new(0);
    lookup.attempt = node.config.node.discovery.attempt_timeouts_secs.len() as u8;
    node.pending_lookups.insert(target_addr, lookup);
    let baseline_timed_out = node.stats().discovery.resp_timed_out;

    node.check_pending_lookups(8_000).await;

    assert!(
        !node.pending_lookups.contains_key(&target_addr),
        "the exhausted lookup should stop while the FSP handshake continues"
    );
    assert_eq!(
        node.pending_session_traffic
            .endpoint_data_for(&target_addr)
            .map(|queue| queue.len()),
        Some(1),
        "lookup exhaustion must not discard endpoint data owned by an active FSP handshake"
    );
    assert_eq!(
        node.stats().discovery.resp_timed_out,
        baseline_timed_out,
        "an active authenticated-session handshake is not an unreachable destination"
    );
    assert!(
        !node.discovery_backoff.is_suppressed(&target_addr),
        "an active FSP handshake must not poison later discovery"
    );
}

#[tokio::test]
async fn established_session_keeps_path_recovery_lookup_and_endpoint_data() {
    use crate::node::handlers::discovery::PendingLookup;
    use crate::node::session::{EndToEndState, SessionEntry};
    use crate::noise::HandshakeState;

    let mut node = make_node();
    let target = Identity::generate();
    let target_addr = *target.node_addr();

    let mut initiator =
        HandshakeState::new_initiator(node.identity().keypair(), target.pubkey_full());
    let mut responder = HandshakeState::new_responder(target.keypair());
    initiator.set_local_epoch([1; 8]);
    responder.set_local_epoch([2; 8]);
    let msg1 = initiator.write_message_1().expect("session msg1");
    responder.read_message_1(&msg1).expect("session read msg1");
    let msg2 = responder.write_message_2().expect("session msg2");
    initiator.read_message_2(&msg2).expect("session read msg2");
    let session = initiator.into_session().expect("established session");
    node.sessions.insert(
        target_addr,
        SessionEntry::new(
            target_addr,
            target.pubkey_full(),
            EndToEndState::Established(session),
            1_000,
            false,
        ),
    );
    node.pending_session_traffic
        .push_endpoint_data_batch_with_enqueued_at_ms(
            target_addr,
            vec![
                crate::node::EndpointDataPayload::from_service_datagram(
                    7_370,
                    7_370,
                    b"tcp-syn".to_vec(),
                )
                .expect("pending service datagram"),
            ],
            usize::MAX,
            usize::MAX,
            1_000,
        );
    node.pending_lookups
        .insert(target_addr, PendingLookup::new(0));
    let baseline_initiated = node.stats().discovery.req_initiated;
    let baseline_timed_out = node.stats().discovery.resp_timed_out;

    node.check_pending_lookups(1_100).await;

    let lookup = node
        .pending_lookups
        .get(&target_addr)
        .expect("established but broken path must keep recovery lookup active");
    assert_eq!(lookup.attempt, 2, "path recovery must advance to retry two");
    assert_eq!(
        node.stats().discovery.req_initiated,
        baseline_initiated + 1,
        "path recovery must send another lookup despite the established session"
    );

    let lookup = node
        .pending_lookups
        .get_mut(&target_addr)
        .expect("recovery lookup");
    lookup.attempt = node.config.node.discovery.attempt_timeouts_secs.len() as u8;
    lookup.last_sent_ms = 0;
    node.check_pending_lookups(8_000).await;

    assert!(
        !node.pending_lookups.contains_key(&target_addr),
        "exhausted recovery lookup should leave the bounded pending set"
    );
    assert_eq!(
        node.pending_session_traffic
            .endpoint_data_for(&target_addr)
            .map(|queue| queue.len()),
        Some(1),
        "an established FSP session must retain endpoint data after lookup exhaustion"
    );
    assert_eq!(
        node.stats().discovery.resp_timed_out,
        baseline_timed_out,
        "an established authenticated session is not an unreachable destination"
    );
}