varta-client 0.2.0

Varta agent API — emits VLP frames over a Unix Domain Socket.
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
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
//! Secure UDP transport backed by ChaCha20-Poly1305 AEAD.
//!
//! [`SecureUdpTransport`] wraps a UDP socket with authenticated encryption
//! using `varta_vlp::crypto`. Every 32-byte VLP frame is encrypted and
//! authenticated before transmission; the 60-byte wire format includes a
//! per-session IV prefix, a monotonic message counter, the encrypted frame,
//! and a Poly1305 tag.
//!
//! # IV scheme (H6 — counter-mode KDF)
//!
//! A 16-byte **session salt** is read from OS entropy exactly once at
//! `connect()` time. All subsequent 8-byte IV prefixes are derived from
//! the salt via [`varta_vlp::crypto::kdf::derive_iv_prefix`] — a
//! deterministic HKDF-SHA256 expansion keyed by a `u32` prefix index. On
//! `u32` AEAD-counter wrap the prefix index advances by one and a new
//! prefix is derived; **no OS entropy syscall fires on the beat path**.
//!
//! * **Nonce uniqueness**: each `(prefix_index, iv_counter)` pair yields a
//!   unique 96-bit nonce. The product space is `u32 × u32 = 2^64` distinct
//!   nonces per session — ~584M years at 1 kHz beat rate.
//! * **Counter capacity**: the 32-bit counter allows ~4 billion beats per
//!   prefix (at 1 kHz this is ~50 days; at 1 Hz, ~136 years). On wrap the
//!   transport rotates the prefix in-process — the observer sees a new IV
//!   prefix (treated as a new session, same as today's behaviour).
//! * **Prefix-index exhaustion**: if the `u32` prefix index ever wraps
//!   (unreachable in any realistic deployment), `send()` calls
//!   [`SecureUdpTransport::reconnect`] — the documented manual escape
//!   hatch — to re-read OS entropy and start a fresh session.
//!
//! # Why no entropy on the beat path
//!
//! `getrandom(2)` on Linux with `flags=0` blocks until the kernel entropy
//! pool is initialised. At boot or under fork-bomb conditions this can
//! stall for seconds. Heartbeat liveness MUST NOT depend on a syscall that
//! can block; the counter-mode KDF gives us cryptographically independent
//! prefixes with zero syscalls.
//!
//! # Fork-safety
//!
//! After `fork(2)` the child inherits the parent's `iv_session_salt`,
//! `iv_prefix_index`, and `iv_counter` — three nominally-independent
//! fields whose product defines the AEAD nonce. Without intervention,
//! the child's first beat would reuse a 12-byte ChaCha20-Poly1305 nonce
//! the parent has already emitted under the same key — a catastrophic
//! confidentiality and integrity failure.
//!
//! [`crate::Varta`] enforces fork-safety **structurally** by snapshotting
//! [`std::process::id`] at [`crate::Varta::connect`] time and comparing on
//! every [`crate::Varta::beat`]. On mismatch, the wrapper calls
//! [`BeatTransport::reconnect`] *before* the frame is built — re-reading
//! OS entropy into a fresh 16-byte session salt and resetting
//! `iv_prefix_index`/`iv_counter` to zero. The forked child therefore
//! emits frames keyed by an IV prefix derived from independent entropy,
//! making nonce collision across the fork boundary impossible. The
//! recovery is silent to the caller and observable via
//! [`crate::Varta::fork_recoveries`].
//!
//! **Advanced callers using `SecureUdpTransport` directly** (without the
//! `Varta` wrapper) do not get this auto-detection — they must call
//! [`SecureUdpTransport::reconnect`] in the child themselves. The
//! [`BeatTransport`] trait is intentionally low-level; the safety policy
//! lives one layer up.
//!
//! Historical note (cerebrum 2026-05-13): a prior `last_pid` field in
//! `Varta` was removed because it detected fork but only reset clock
//! state — the IV state was still inherited, so the "fix" was theatre.
//! The current design is structurally different in that the PID-mismatch
//! response is `transport.reconnect()`, which is precisely where the IV
//! salt rotates.
//!
//! **This transport is designed for trusted local networks.**

use std::io;
use std::net::{SocketAddr, UdpSocket};

use varta_vlp::crypto::{self, Key, NONCE_BYTES, SECURE_FRAME_MASTER_BYTES};

use crate::transport::{bind_ephemeral, BeatTransport};

/// Wire length for a shared-key frame.
const SECURE_FRAME_LEN: usize = crypto::SECURE_FRAME_BYTES;

/// Wire length for a master-key frame.
const SECURE_FRAME_MASTER_LEN: usize = SECURE_FRAME_MASTER_BYTES;

/// UDP transport with ChaCha20-Poly1305 AEAD encryption and authentication.
///
/// Created via [`SecureUdpTransport::connect`] and used as the backend for
/// [`Varta::connect_secure_udp`].
///
/// On each `send`, the 32-byte VLP frame is encrypted with a unique 96-bit
/// nonce (8-byte random prefix + 4-byte monotonic counter). The resulting
/// 60-byte AEAD frame is sent over UDP.
///
/// # Security properties
///
/// * **Confidentiality**: Frame contents are encrypted (ChaCha20 stream cipher).
/// * **Integrity**: Tampering is detected (Poly1305 authentication tag).
/// * **Replay resistance**: Monotonic counter per connection; observer verifies
///   that counter values strictly increase for a given IV prefix.
/// * **Nonce uniqueness**: The 8-byte random prefix (from `/dev/urandom` at
///   connect time) plus the 4-byte counter ensures no nonce reuse within a
///   connection lifetime.
///
/// # Security
///
/// See the [module-level security documentation](self) for important
/// caveats about the 32-bit counter space.
///
/// [`Varta::connect_secure_udp`]: crate::Varta::connect_secure_udp
/// [module-level security documentation]: self#security
pub struct SecureUdpTransport {
    sock: UdpSocket,
    addr: SocketAddr,
    key: Key,
    iv_counter: u32,
    /// Read once from OS entropy at `connect()`; never re-read on the beat
    /// path. Used as HKDF input to derive `iv_prefix` per-session.
    iv_session_salt: [u8; 16],
    /// Increments on AEAD-counter wrap; mixed into the HKDF info string.
    iv_prefix_index: u32,
    /// Cache of `derive_iv_prefix(session_salt, prefix_index)`. Recomputed
    /// only on `connect`, `reconnect`, or counter wrap — not per beat.
    iv_prefix: [u8; 8],
    is_master_mode: bool,
}

impl SecureUdpTransport {
    /// Create a non-blocking secure UDP socket connected to `addr`.
    ///
    /// The socket is bound to an ephemeral source port. A 16-byte session
    /// salt is read from OS entropy at connect time (no syscall on the
    /// beat path — see module-level docs).
    ///
    /// # Errors
    ///
    /// Returns an [`io::Error`] if the socket cannot be created, connected,
    /// switched to non-blocking mode, or if OS entropy is unavailable.
    pub fn connect(addr: SocketAddr, key: Key) -> io::Result<Self> {
        use varta_vlp::crypto::kdf;

        let sock = bind_ephemeral(&addr)?;
        sock.connect(addr)?;
        sock.set_nonblocking(true)?;

        let iv_session_salt = read_iv_session_salt()?;
        let iv_prefix = kdf::derive_iv_prefix(&iv_session_salt, 0)
            .map_err(|_| io::Error::new(io::ErrorKind::Other, "key derivation failure"))?;

        Ok(SecureUdpTransport {
            sock,
            addr,
            key,
            iv_counter: 0,
            iv_session_salt,
            iv_prefix_index: 0,
            iv_prefix,
            is_master_mode: false,
        })
    }

    /// Create a secure UDP socket using a master key with per-agent key
    /// derivation.
    ///
    /// The agent key is derived from the master key and the calling
    /// process's PID using [`varta_vlp::crypto::kdf::derive_agent_key`].
    /// On each beat the PID is sent as a 4-byte plaintext prefix (AAD)
    /// so the observer can derive the same agent key before decrypting.
    ///
    /// The 8-byte `iv_random` is filled entirely from OS entropy — the PID
    /// is no longer embedded in it. This gives a 64-bit random birthday
    /// bound (~2^32 reconnects before collision probability reaches 50%),
    /// versus the old 32-bit bound of ~2^16 reconnects.
    ///
    /// # Wire format (master-key mode, 64 bytes)
    ///
    /// ```text
    /// [agent_pid: 4] [iv_random: 8] [iv_counter: 4] [ciphertext: 32] [tag: 16]
    /// ```
    ///
    /// `agent_pid` is bound as Additional Authenticated Data (AAD) into the
    /// Poly1305 tag; tampering the on-wire PID causes authentication failure.
    ///
    /// # Security
    ///
    /// Per-agent key derivation means compromising one agent's derived key
    /// does not reveal other agents' keys or the master key.
    pub fn connect_with_master(addr: SocketAddr, master_key: Key) -> io::Result<Self> {
        use varta_vlp::crypto::kdf;

        let peer_pid = std::process::id();
        let agent_key = kdf::derive_agent_key(&master_key, peer_pid)
            .map_err(|_| io::Error::new(io::ErrorKind::Other, "key derivation failure"))?;

        let sock = bind_ephemeral(&addr)?;
        sock.connect(addr)?;
        sock.set_nonblocking(true)?;

        // 16-byte session salt — HKDF-expanded into the 8-byte on-wire
        // `iv_random` field. The PID is sent as a plaintext AAD field in
        // the 64-byte wire frame.
        let iv_session_salt = read_iv_session_salt()?;
        let iv_prefix = kdf::derive_iv_prefix(&iv_session_salt, 0)
            .map_err(|_| io::Error::new(io::ErrorKind::Other, "key derivation failure"))?;

        Ok(SecureUdpTransport {
            sock,
            addr,
            key: agent_key,
            iv_counter: 0,
            iv_session_salt,
            iv_prefix_index: 0,
            iv_prefix,
            is_master_mode: true,
        })
    }

    /// Test-only setter to fast-forward the AEAD counter, exercising the
    /// counter-wrap rotation path without sending billions of beats.
    #[cfg(any(test, feature = "test-hooks"))]
    pub fn set_iv_counter_for_test(&mut self, value: u32) {
        self.iv_counter = value;
    }

    /// Test-only accessor for the current committed `iv_counter`. Used to
    /// assert commit-on-success behaviour — that a failed `send` (e.g.
    /// `WouldBlock`) does NOT advance the counter.
    #[cfg(any(test, feature = "test-hooks"))]
    pub fn iv_counter_for_test(&self) -> u32 {
        self.iv_counter
    }

    /// Test-only accessor for the current derived prefix.
    #[cfg(any(test, feature = "test-hooks"))]
    pub fn iv_prefix_for_test(&self) -> [u8; 8] {
        self.iv_prefix
    }

    /// Test-only accessor for the prefix index.
    #[cfg(any(test, feature = "test-hooks"))]
    pub fn iv_prefix_index_for_test(&self) -> u32 {
        self.iv_prefix_index
    }

    /// Test-only setter to fast-forward the prefix index, exercising the
    /// doubly-exhausted (counter + prefix-index wrap → reconnect) path.
    #[cfg(any(test, feature = "test-hooks"))]
    pub fn set_iv_prefix_index_for_test(&mut self, value: u32) {
        self.iv_prefix_index = value;
    }

    /// Advance the AEAD nonce state and return the `iv_counter` value the
    /// next frame should use. Three branches, in order:
    ///
    /// 1. **Common case** — `iv_counter.checked_add(1)` succeeds; return it.
    /// 2. **Counter wrap** — `iv_counter` exhausted but `iv_prefix_index`
    ///    can still advance. Bump the index, re-derive `iv_prefix` via
    ///    HKDF (no entropy syscall — see module docs), return `1`.
    /// 3. **Doubly-exhausted** — both `u32`s exhausted (`2^64` nonces,
    ///    ~584M years at 1 kHz). Fall back to [`Self::reconnect`] — the
    ///    documented manual escape hatch — which refreshes the salt and
    ///    zeroes both counters; return `1` for the first beat under the
    ///    fresh session.
    ///
    /// Linear control flow — no recursion into `send`. The `debug_assert`s
    /// guard against a future regression that breaks `reconnect()`'s
    /// reset contract.
    fn advance_nonce(&mut self) -> io::Result<u32> {
        if let Some(n) = self.iv_counter.checked_add(1) {
            return Ok(n);
        }
        // AEAD counter exhausted — rotate the per-session IV prefix via
        // HKDF derivation. No OS entropy syscall: the session salt was
        // sampled once at connect() and the KDF gives us cryptographically
        // independent prefixes.
        if let Some(next_index) = self.iv_prefix_index.checked_add(1) {
            self.iv_prefix_index = next_index;
            self.iv_prefix = varta_vlp::crypto::kdf::derive_iv_prefix(
                &self.iv_session_salt,
                self.iv_prefix_index,
            )
            .map_err(|_| io::Error::new(io::ErrorKind::Other, "key derivation failure"))?;
            return Ok(1);
        }
        // Prefix index also exhausted (2^64 nonces — ~584M years at 1
        // kHz). Fall back to the documented manual escape hatch: refresh
        // the salt via the entropy chain. Linear control flow — replaces
        // the prior `return self.send(buf)` recursion.
        self.reconnect()?;
        debug_assert_eq!(
            self.iv_counter, 0,
            "reconnect() must zero iv_counter — see secure_transport module docs"
        );
        debug_assert_eq!(
            self.iv_prefix_index, 0,
            "reconnect() must zero iv_prefix_index — see secure_transport module docs"
        );
        Ok(1)
    }
}

impl BeatTransport for SecureUdpTransport {
    fn send(&mut self, buf: &[u8; 32]) -> io::Result<usize> {
        // Speculatively compute the next counter. `advance_nonce` may still
        // mutate `self.iv_prefix`/`self.iv_prefix_index` for the wrap path
        // and re-bind the socket via `reconnect()` for the doubly-exhausted
        // path — those side effects are structural and cannot be deferred.
        // The common path (`checked_add` succeeds, > 99.999...% of real
        // calls) is purely functional here: `pending_counter` lives as a
        // local until the kernel confirms it accepted the datagram.
        let pending_counter = self.advance_nonce()?;

        // Build 12-byte nonce: iv_prefix (8) || pending_counter (4) LE
        let mut nonce = [0u8; NONCE_BYTES];
        nonce[..8].copy_from_slice(&self.iv_prefix);
        nonce[8..12].copy_from_slice(&pending_counter.to_le_bytes());

        let result = if self.is_master_mode {
            // Master-key wire format (64 bytes):
            // [agent_pid: 4] [iv_random: 8] [iv_counter: 4] [ciphertext: 32] [tag: 16]
            //
            // The on-wire `iv_random` field is now sourced from the
            // KDF-derived `iv_prefix` cache — byte budget preserved.
            //
            // agent_pid is read fresh each beat (never cached — see cerebrum
            // 2026-05-11) and bound as AAD so tampering the PID prefix fails
            // authentication.
            let agent_pid = std::process::id();
            let agent_pid_bytes = agent_pid.to_le_bytes();
            let (ciphertext, tag) =
                crypto::seal(self.key.as_bytes(), &nonce, &agent_pid_bytes, buf)
                    .map_err(|_| io::Error::new(io::ErrorKind::Other, "AEAD seal failure"))?;

            let mut frame = [0u8; SECURE_FRAME_MASTER_LEN];
            frame[0..4].copy_from_slice(&agent_pid_bytes);
            frame[4..12].copy_from_slice(&self.iv_prefix);
            frame[12..16].copy_from_slice(&pending_counter.to_le_bytes());
            frame[16..48].copy_from_slice(&ciphertext);
            frame[48..64].copy_from_slice(&tag);

            self.sock.send(&frame)
        } else {
            // Shared-key wire format (60 bytes):
            // [iv_random: 8] [iv_counter: 4] [ciphertext: 32] [tag: 16]
            let (ciphertext, tag) = crypto::seal(self.key.as_bytes(), &nonce, b"", buf)
                .map_err(|_| io::Error::new(io::ErrorKind::Other, "AEAD seal failure"))?;

            let mut frame = [0u8; SECURE_FRAME_LEN];
            frame[..8].copy_from_slice(&self.iv_prefix);
            frame[8..12].copy_from_slice(&pending_counter.to_le_bytes());
            frame[12..44].copy_from_slice(&ciphertext);
            frame[44..60].copy_from_slice(&tag);

            self.sock.send(&frame)
        };

        // Commit the counter advance only when the kernel accepted the
        // datagram. `WouldBlock`/`EAGAIN` means the ciphertext never escaped
        // the process, so the next call can reuse `pending_counter` with no
        // observable nonce reuse on the wire. UDP `send(2)` is datagram-
        // atomic — either the full datagram is queued or nothing is — so
        // there is no "half-sent under this nonce" state to reason about.
        if result.is_ok() {
            self.iv_counter = pending_counter;
        }
        result
    }

    /// Manual session refresh — re-binds the ephemeral socket, re-reads OS
    /// entropy for a fresh 16-byte session salt, and resets prefix/counter
    /// state. This is the **only** path after `connect()` that touches OS
    /// entropy.
    ///
    /// Called automatically by [`crate::Varta::beat`] when a `fork(2)`
    /// transition is detected (PID mismatch against the connect-time
    /// snapshot). Advanced callers using `SecureUdpTransport` directly
    /// must invoke this themselves in the forked child — the inherited
    /// `iv_session_salt` would otherwise cause catastrophic AEAD nonce
    /// reuse. Also called by operators wanting a fresh session for
    /// forward-secrecy hygiene.
    fn reconnect(&mut self) -> io::Result<()> {
        use varta_vlp::crypto::kdf;

        // --- Prepare phase: every fallible call writes to a local.  Any
        //     `?` below this comment block returns with `self` byte-identical
        //     to entry.  The observer tracks per-sender state by
        //     (SocketAddr, iv_prefix); a new salt produces a fresh prefix
        //     series that the observer treats as a new session.
        let sock = bind_ephemeral(&self.addr)?;
        sock.connect(self.addr)?;
        sock.set_nonblocking(true)?;
        let new_salt = read_iv_session_salt()?;
        let new_prefix = kdf::derive_iv_prefix(&new_salt, 0)
            .map_err(|_| io::Error::new(io::ErrorKind::Other, "key derivation failure"))?;

        // --- Commit phase: NO `?` operator below this line.  Any future
        //     change that introduces a fallible call here is a transactional
        //     regression — a partial commit could leave `self.sock` paired
        //     with a stale `iv_session_salt`/`iv_prefix`, an internally
        //     inconsistent state that subsequent `advance_nonce` calls would
        //     have to converge out of via retry.
        self.sock = sock;
        self.iv_session_salt = new_salt;
        self.iv_prefix = new_prefix;
        self.iv_prefix_index = 0;
        self.iv_counter = 0;
        Ok(())
    }
}

// --- OS-level random bytes via kernel syscall ---------------------------
//
// `os_random` tries the most direct kernel interface first (`getrandom(2)`
// on Linux, `getentropy(3)` on macOS/BSD).  These do not require a mounted
// `/dev`, so they work inside chroots and stripped containers where
// `/dev/urandom` may be absent.  `read_iv_random` falls through to
// `/dev/urandom` only when `os_random` fails.

#[cfg(target_os = "linux")]
#[allow(unsafe_code)]
fn os_random(buf: &mut [u8]) -> io::Result<()> {
    extern "C" {
        // glibc 2.25+ / musl 1.1.20+ wraps the getrandom(2) syscall.
        fn getrandom(buf: *mut u8, buflen: usize, flags: u32) -> isize;
    }
    // flags = 0: block until the entropy pool is initialised (correct for
    // connect-time calls that are never on the beat path). EINTR is retried;
    // ENOSYS (kernel < 3.17) propagates and the caller falls through to
    // /dev/urandom.
    //
    // SAFETY: `buf` is a valid slice of `buf.len()` bytes; getrandom writes
    // at most `buflen` bytes and returns the number written on success.
    let mut filled = 0usize;
    while filled < buf.len() {
        let n = unsafe { getrandom(buf.as_mut_ptr().add(filled), buf.len() - filled, 0) };
        if n < 0 {
            let e = io::Error::last_os_error();
            if e.kind() == io::ErrorKind::Interrupted {
                continue;
            }
            return Err(e);
        }
        filled += n as usize;
    }
    Ok(())
}

#[cfg(any(
    target_os = "macos",
    target_os = "ios",
    target_os = "freebsd",
    target_os = "netbsd",
    target_os = "openbsd",
    target_os = "dragonfly",
))]
#[allow(unsafe_code)]
fn os_random(buf: &mut [u8]) -> io::Result<()> {
    extern "C" {
        // Available since macOS 10.12, FreeBSD 12, NetBSD 10, OpenBSD 5.6.
        fn getentropy(buf: *mut u8, buflen: usize) -> i32;
    }
    // getentropy(3) requires buflen <= 256.  Both call sites request 4 or 8
    // bytes, so this assertion is always satisfied.
    assert!(buf.len() <= 256, "getentropy: buflen must be <= 256");
    // SAFETY: `buf` is a valid slice; getentropy writes exactly `buflen`
    // bytes on success.
    let rc = unsafe { getentropy(buf.as_mut_ptr(), buf.len()) };
    if rc == 0 {
        Ok(())
    } else {
        Err(io::Error::last_os_error())
    }
}

#[cfg(not(any(
    target_os = "linux",
    target_os = "macos",
    target_os = "ios",
    target_os = "freebsd",
    target_os = "netbsd",
    target_os = "openbsd",
    target_os = "dragonfly",
)))]
fn os_random(_buf: &mut [u8]) -> io::Result<()> {
    Err(io::Error::new(
        io::ErrorKind::Unsupported,
        "no OS random source on this platform",
    ))
}

// -----------------------------------------------------------------------

/// Read a cryptographically-random 8-byte IV prefix.
///
/// Called once at `connect()` / `reconnect()` time — never on the beat path.
/// Tries `getrandom(2)` / `getentropy(3)` first (no `/dev` mount required),
/// then falls back to `/dev/urandom`.
///
/// **Note:** `SecureUdpTransport` no longer calls this — it uses
/// [`read_iv_session_salt`] for the 16-byte session salt. Retained here for
/// the panic-hook installer which emits a one-shot frame with a fresh
/// 8-byte IV.
#[cfg_attr(
    not(any(test, all(feature = "panic-handler", feature = "secure-udp"))),
    allow(dead_code)
)]
pub(crate) fn read_iv_random() -> io::Result<[u8; 8]> {
    let mut buf = [0u8; 8];
    if os_random(&mut buf).is_ok() {
        return Ok(buf);
    }
    std::fs::File::open("/dev/urandom").and_then(|mut f| {
        use std::io::Read;
        f.read_exact(&mut buf)
    })?;
    Ok(buf)
}

/// Read a cryptographically-random 16-byte session salt.
///
/// Called once at `connect()` / `reconnect()` time — never on the beat path
/// (H6 contract). The salt seeds the per-session HKDF that produces 8-byte
/// IV prefixes on counter wrap, so subsequent prefix rotations require no
/// OS entropy.
///
/// Tries `getrandom(2)` / `getentropy(3)` first, then falls back to
/// `/dev/urandom`. 16 bytes is well below `getentropy(3)`'s 256-byte limit.
pub(crate) fn read_iv_session_salt() -> io::Result<[u8; 16]> {
    let mut buf = [0u8; 16];
    if os_random(&mut buf).is_ok() {
        return Ok(buf);
    }
    std::fs::File::open("/dev/urandom").and_then(|mut f| {
        use std::io::Read;
        f.read_exact(&mut buf)
    })?;
    Ok(buf)
}

/// Hashed 8-byte IV prefix — last-resort fallback for the panic hook.
///
/// Reached only when both `getrandom(2)`/`getentropy(3)` and `/dev/urandom`
/// fail (typically: extremely constrained embedded environments).  Mixes
/// multiple entropy sources through a `RandomState`-keyed SipHash-2-4
/// hasher:
///
/// * `RandomState::new()` uses OS entropy for its key on most platforms; even
///   where it falls back to a deterministic startup seed, the time deltas and
///   counter below keep successive calls distinct.
/// * Monotonic elapsed time since the first call (high-resolution).
/// * Wall-clock nanos (independent entropy axis).
/// * PID + TID + monotonic call counter.
///
/// **Stack-address entropy deliberately omitted**: it contributes zero bits on
/// no-ASLR platforms (QNX, VxWorks, some RTOS), which are exactly the
/// deployments that reach this fallback.
///
/// **Not cryptographically secure.**  Use only when the above sources are
/// unavailable.
#[cfg(any(feature = "accept-degraded-entropy", test))]
#[cfg_attr(not(any(test, feature = "accept-degraded-entropy")), allow(dead_code))]
pub(crate) fn fallback_iv_random() -> [u8; 8] {
    use std::collections::hash_map::RandomState;
    use std::hash::{BuildHasher, Hash, Hasher};
    use std::sync::atomic::{AtomicU64, Ordering};
    use std::sync::OnceLock;
    use std::time::{Instant, SystemTime, UNIX_EPOCH};

    static SEQ: AtomicU64 = AtomicU64::new(0);
    static START: OnceLock<Instant> = OnceLock::new();

    let mut hasher = RandomState::new().build_hasher();
    std::process::id().hash(&mut hasher);
    std::thread::current().id().hash(&mut hasher);
    SEQ.fetch_add(1, Ordering::Relaxed).hash(&mut hasher);
    START
        .get_or_init(Instant::now)
        .elapsed()
        .as_nanos()
        .hash(&mut hasher);
    if let Ok(d) = SystemTime::now().duration_since(UNIX_EPOCH) {
        d.as_nanos().hash(&mut hasher);
    }

    hasher.finish().to_le_bytes()
}

/// 16-byte session-salt analogue of [`fallback_iv_random`] — last-resort
/// fallback when both `getrandom(2)`/`getentropy(3)` and `/dev/urandom` are
/// unavailable. Two independent SipHash passes (distinct `SEQ` ticks per
/// pass) are concatenated to produce 128 bits.
///
/// **Entropy density is degraded** vs. an OS read: the underlying
/// `RandomState` key is the dominant entropy source, plus the time / PID /
/// TID mixers. Use only when no OS entropy source is reachable.
///
/// Currently consumed only by the in-module collision test; retained as a
/// parity API for a future panic-hook `accept_degraded_entropy` variant
/// that needs a 16-byte salt (mirroring the existing 8-byte
/// `install_panic_handler_secure_udp_accept_degraded_entropy`).
#[cfg(any(feature = "accept-degraded-entropy", test))]
#[allow(dead_code)]
pub(crate) fn fallback_iv_session_salt() -> [u8; 16] {
    let lo = fallback_iv_random();
    let hi = fallback_iv_random();
    let mut out = [0u8; 16];
    out[..8].copy_from_slice(&lo);
    out[8..].copy_from_slice(&hi);
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::net::{Ipv6Addr, SocketAddrV6};

    #[test]
    fn ipv6_connect_does_not_fail_with_einval() {
        let addr = SocketAddr::V6(SocketAddrV6::new(Ipv6Addr::LOCALHOST, 9876, 0, 0));
        let key = Key::from_bytes([0x42; 32]);
        let result = SecureUdpTransport::connect(addr, key);
        assert!(result.is_ok(), "IPv6 connect failed: {:?}", result.err());
    }

    #[test]
    fn fallback_iv_random_unique_across_calls() {
        use std::collections::HashSet;
        let outputs: HashSet<[u8; 8]> = (0..1000).map(|_| fallback_iv_random()).collect();
        assert_eq!(
            outputs.len(),
            1000,
            "collisions detected in fallback_iv_random"
        );
    }

    #[test]
    fn os_random_yields_distinct_outputs() {
        let mut a = [0u8; 32];
        let mut b = [0u8; 32];
        match (os_random(&mut a), os_random(&mut b)) {
            (Ok(()), Ok(())) => assert_ne!(a, b, "os_random returned identical outputs"),
            (Err(e), _) | (_, Err(e)) if e.kind() == io::ErrorKind::Unsupported => {}
            (Err(e), _) | (_, Err(e)) => panic!("os_random failed: {e}"),
        }
    }

    #[test]
    fn read_iv_random_succeeds() {
        assert!(
            read_iv_random().is_ok(),
            "read_iv_random failed on this platform"
        );
    }

    #[test]
    fn fallback_iv_session_salt_unique_across_calls() {
        use std::collections::HashSet;
        let outputs: HashSet<[u8; 16]> = (0..1000).map(|_| fallback_iv_session_salt()).collect();
        assert_eq!(
            outputs.len(),
            1000,
            "collisions detected in fallback_iv_session_salt"
        );
    }

    #[test]
    fn read_iv_session_salt_succeeds() {
        assert!(
            read_iv_session_salt().is_ok(),
            "read_iv_session_salt failed on this platform"
        );
    }

    /// Once `connect()` has returned, any further call to the entropy
    /// chain on the steady-state beat path is a regression. This test
    /// guards by setting a poison flag that an entropy-mock in
    /// `BeatTransport::send` would trip; since the new scheme does NOT
    /// call any entropy helper on `send`, we simply verify that
    /// `send_local_loopback_after_wrap` does not panic and rotates state
    /// without calling `read_iv_session_salt`.  The latter is observable
    /// indirectly: the prefix changes, prefix_index increments, and
    /// `iv_counter` resets to 1.
    #[test]
    fn counter_wrap_rotates_prefix_without_entropy_read() {
        // Use a loopback UDP socket as a black-hole receiver. We don't
        // actually need anyone to receive; we just need send() to succeed.
        let addr = SocketAddr::V6(SocketAddrV6::new(Ipv6Addr::LOCALHOST, 9876, 0, 0));
        let key = Key::from_bytes([0u8; 32]);
        let mut tx = SecureUdpTransport::connect(addr, key).expect("connect");

        let prefix_before = tx.iv_prefix_for_test();
        let salt_before = tx.iv_session_salt;
        tx.set_iv_counter_for_test(u32::MAX);

        // Send a stub buffer — the destination is a closed ephemeral
        // address so the send may fail at the network layer, but the
        // wrap-rotation logic runs before the syscall.
        let buf = [0u8; 32];
        let _ = <SecureUdpTransport as BeatTransport>::send(&mut tx, &buf);

        // Salt must NOT have changed — no entropy refresh.
        assert_eq!(
            tx.iv_session_salt, salt_before,
            "salt rotated unexpectedly on wrap"
        );
        // Prefix index advanced; prefix differs from the prior session-0.
        assert_eq!(
            tx.iv_prefix_index_for_test(),
            1,
            "prefix_index should advance to 1 on wrap"
        );
        assert_ne!(
            tx.iv_prefix_for_test(),
            prefix_before,
            "rotated prefix should differ from prior prefix"
        );
        assert_eq!(tx.iv_counter, 1, "counter should reset to 1 on wrap");
    }

    /// The wrap path must NOT call the OS entropy chain.  We assert this
    /// structurally: after `connect()`, freezing the salt and forcing a
    /// wrap must leave the salt unchanged.  Any future regression that
    /// re-introduces an entropy call on `send()` will flip the salt and
    /// fail this assertion.
    #[test]
    fn wrap_path_does_not_call_read_iv_session_salt() {
        let addr = SocketAddr::V6(SocketAddrV6::new(Ipv6Addr::LOCALHOST, 9876, 0, 0));
        let key = Key::from_bytes([0u8; 32]);
        let mut tx = SecureUdpTransport::connect(addr, key).expect("connect");

        let salt_snapshot = tx.iv_session_salt;
        // Run several wrap rotations back-to-back.
        for expected_index in 1..=4 {
            tx.set_iv_counter_for_test(u32::MAX);
            let buf = [0u8; 32];
            let _ = <SecureUdpTransport as BeatTransport>::send(&mut tx, &buf);
            assert_eq!(
                tx.iv_session_salt, salt_snapshot,
                "salt mutated during wrap rotation (regression)"
            );
            assert_eq!(tx.iv_prefix_index_for_test(), expected_index);
        }
    }

    /// Both `iv_counter` AND `iv_prefix_index` exhausted — `send()` must
    /// fall back to `reconnect()`, refresh the salt, and resume from
    /// `iv_prefix_index = 0`, `iv_counter = 1`. This exercises the path
    /// that previously recursed into `self.send(buf)`; it must now run
    /// linearly without stack growth and still produce identical state.
    #[test]
    fn doubly_exhausted_nonce_falls_back_to_reconnect() {
        let addr = SocketAddr::V6(SocketAddrV6::new(Ipv6Addr::LOCALHOST, 9876, 0, 0));
        let key = Key::from_bytes([0u8; 32]);
        let mut tx = SecureUdpTransport::connect(addr, key).expect("connect");

        let salt_before = tx.iv_session_salt;
        let prefix_before = tx.iv_prefix_for_test();

        // Force both u32s to the brink of exhaustion.
        tx.set_iv_counter_for_test(u32::MAX);
        tx.set_iv_prefix_index_for_test(u32::MAX);

        let buf = [0u8; 32];
        let _ = <SecureUdpTransport as BeatTransport>::send(&mut tx, &buf);

        // Salt MUST have rotated — reconnect() is the only way out of
        // doubly-exhausted state.
        assert_ne!(
            tx.iv_session_salt, salt_before,
            "reconnect should refresh the session salt on double exhaustion"
        );
        // Both counters reset to a fresh session, then bumped by one beat.
        assert_eq!(tx.iv_prefix_index_for_test(), 0);
        assert_eq!(tx.iv_counter, 1);
        // Prefix-0 of the new salt is overwhelmingly likely to differ.
        assert_ne!(tx.iv_prefix_for_test(), prefix_before);
    }

    /// Successful `reconnect()` must atomically update all five state
    /// fields: the connected socket (verified by the source port changing
    /// after re-bind), the session salt, the derived prefix-0,
    /// `iv_prefix_index = 0`, and `iv_counter = 0`.
    ///
    /// Regression guard for the transactional contract: every fallible
    /// step in `reconnect()` writes to a local, and the five `self.*`
    /// writes happen in a tail block with no `?` operator.  An inverted
    /// write order or a partial commit (e.g. `self.sock = sock` ahead of
    /// a still-fallible step) would either trip this test or leave one of
    /// the five assertions below false.
    ///
    /// The port assertion is deterministic, not probabilistic: the old
    /// `self.sock` is still held when `bind_ephemeral` runs for the new
    /// socket, so the kernel cannot grant the same ephemeral port to both.
    #[test]
    fn reconnect_success_updates_all_iv_state_and_socket_port() {
        let addr = SocketAddr::V6(SocketAddrV6::new(Ipv6Addr::LOCALHOST, 9876, 0, 0));
        let key = Key::from_bytes([0x42; 32]);
        let mut tx = SecureUdpTransport::connect(addr, key).expect("connect");

        // Drive non-default state so every reset is observable.
        tx.set_iv_counter_for_test(123);
        tx.set_iv_prefix_index_for_test(7);

        let port_before = tx.sock.local_addr().expect("local_addr").port();
        let salt_before = tx.iv_session_salt;
        let prefix_before = tx.iv_prefix_for_test();

        <SecureUdpTransport as BeatTransport>::reconnect(&mut tx)
            .expect("reconnect on loopback must succeed");

        assert_eq!(tx.iv_counter, 0, "iv_counter must reset to 0");
        assert_eq!(
            tx.iv_prefix_index_for_test(),
            0,
            "iv_prefix_index must reset to 0"
        );
        assert_ne!(
            tx.iv_session_salt, salt_before,
            "salt must be re-read from OS entropy (1-in-2^128 collision)"
        );
        assert_ne!(
            tx.iv_prefix_for_test(),
            prefix_before,
            "prefix must be re-derived from the new salt"
        );
        assert_ne!(
            tx.sock.local_addr().expect("local_addr").port(),
            port_before,
            "ephemeral source port must differ after re-bind"
        );
    }

    /// Commit-on-success contract: a failed `send(2)` (e.g. `WouldBlock` on
    /// the beat path) must NOT advance `iv_counter`. The kernel never
    /// accepted the datagram, so the speculative nonce is unobserved on
    /// the wire and can be re-tried on the next call with no AEAD
    /// nonce-reuse risk.
    ///
    /// We force a deterministic failure by `mem::replace`-ing the connected
    /// socket with an unconnected `UdpSocket::bind`. Calling `send(2)` on
    /// an unconnected datagram socket yields `ENOTCONN` / `EDESTADDRREQ` —
    /// platform-portable and immediate.
    #[test]
    fn iv_counter_commits_only_on_successful_send() {
        use std::mem;
        use std::net::{Ipv4Addr, UdpSocket};

        let addr = SocketAddr::V6(SocketAddrV6::new(Ipv6Addr::LOCALHOST, 9876, 0, 0));
        let key = Key::from_bytes([0u8; 32]);
        let mut tx = SecureUdpTransport::connect(addr, key).expect("connect");

        // Sanity baseline: a normal send on the connected socket commits
        // the counter advance.
        let baseline = tx.iv_counter;
        let buf = [0u8; 32];
        let ok = <SecureUdpTransport as BeatTransport>::send(&mut tx, &buf);
        assert!(
            ok.is_ok(),
            "baseline send on connected socket failed: {ok:?}"
        );
        assert_eq!(
            tx.iv_counter,
            baseline + 1,
            "successful send must advance iv_counter by exactly 1"
        );

        // Swap the connected socket for an unconnected one. send(2) on an
        // unconnected UDP socket fails with ENOTCONN / EDESTADDRREQ.
        let unconnected =
            UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind unconnected UDP socket");
        unconnected
            .set_nonblocking(true)
            .expect("set_nonblocking on unconnected");
        let _replaced = mem::replace(&mut tx.sock, unconnected);

        let counter_before = tx.iv_counter;
        let prefix_before = tx.iv_prefix_for_test();
        let prefix_index_before = tx.iv_prefix_index_for_test();

        for attempt in 0..5 {
            let r = <SecureUdpTransport as BeatTransport>::send(&mut tx, &buf);
            assert!(
                r.is_err(),
                "send #{attempt} on unconnected socket unexpectedly succeeded: {r:?}"
            );
        }

        // Commit-on-success: none of the five failed sends may have moved
        // the committed AEAD state.
        assert_eq!(
            tx.iv_counter, counter_before,
            "iv_counter advanced despite send() failures \
             (commit-on-success contract violated)"
        );
        assert_eq!(
            tx.iv_prefix_for_test(),
            prefix_before,
            "iv_prefix mutated on failed send"
        );
        assert_eq!(
            tx.iv_prefix_index_for_test(),
            prefix_index_before,
            "iv_prefix_index mutated on failed send"
        );
    }

    /// `reconnect()` IS allowed to re-read entropy — it's the documented
    /// manual escape hatch for fork-safety and salt refresh.
    #[test]
    fn manual_reconnect_does_re_read_entropy() {
        let addr = SocketAddr::V6(SocketAddrV6::new(Ipv6Addr::LOCALHOST, 9876, 0, 0));
        let key = Key::from_bytes([0u8; 32]);
        let mut tx = SecureUdpTransport::connect(addr, key).expect("connect");

        let salt_before = tx.iv_session_salt;
        let prefix_before = tx.iv_prefix_for_test();
        tx.iv_prefix_index = 42;
        tx.iv_counter = 12345;

        <SecureUdpTransport as BeatTransport>::reconnect(&mut tx).expect("reconnect");

        // Counter / index reset.
        assert_eq!(tx.iv_prefix_index_for_test(), 0);
        assert_eq!(tx.iv_counter, 0);
        // Salt should be fresh (cryptographically near-impossible to collide
        // with the previous read at 16 bytes).
        assert_ne!(
            tx.iv_session_salt, salt_before,
            "reconnect should refresh the session salt"
        );
        // Prefix-0 of the new salt is overwhelmingly likely to differ from
        // prefix-0 of the old salt.
        assert_ne!(tx.iv_prefix_for_test(), prefix_before);
    }
}