Author[]Andy Green <andy@warmcat.com> 2026-10-03 09:24 UTC
Committer[]Andy Green <andy@warmcat.com> 2026-10-05 06:32 UTC
Treed39490fbf88b5259bb7bff7141d8b6e97eb1008b   Raw Patch
 
npro-core: lws' random as an input, and its seeded stream for replay
npro-core: lws' random as an input, and its seeded stream for replay

The new npro-core crate is no_std, and is the first one built for a
target with no std (thumbv7em-none-eabihf) in scripts/ci.sh.  CI now also
runs clippy with default features, beside --all-features.

Where C calls lws_get_random(), a protocol crate here is handed a Random
by the IO side or a test, so it never reaches for the platform.  One
fill() is one draw, as one lws_get_random() call is.  A source  order give the same bytes as C.
+
+/// A source of random bytes, handed in by the IO side or a test.
+///
+/// What the protocols use it for, a ws key and masks, must be
+/// unpredictable to the peer, so a real connection's source must be a
+/// cryptographically secure one, such as the operating system's.
+pub trait Random {
+    /// Fills `buf` with random bytes: one draw.
+    ///
+    /// # Errors
+    ///
+    /// [`Unavailable`] when the source cannot give random bytes now.  The
+    /// connection drawing them fails, as C fails a short
+    /// `lws_get_random()`, rather than going on with predictable ones.
+    fn fill(&mut self, buf: &mut [u8]) -> Result<(), Unavailable>;
+}
+
+/// The random source could not give random bytes.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct Unavailable;
+
+impl core::fmt::Display for Unavailable {
+    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+        f.write_str("random source unavailable")
+    }
+}
+
+impl core::error::Error for Unavailable {}
+
+/// lws' seeded random: xoshiro256** seeded through C's splitmix64.
+///
+/// C lws puts this in place of the platform's random under fault injection
+/// (`lws_fi_random_seed()`), so a run making the same draws in the same
+/// order gets the same bytes, and its transcripts record them.  This is the
+/// same stream, for replaying those transcripts.
+///
+/// **It is predictable by design.**  Never use it as the source for a real
+/// connection: a peer that can predict a ws client's masks can use them
+/// against intermediaries, which is what masking exists to prevent.
+///
+/// Each draw takes as many 64-bit words as cover it, each laid out
+/// little-endian, and drops what is left of the last one, as C does.  So a
+/// 4-byte draw spends a whole word:
+///
+/// ```
+/// use npro_core::random::{Random, SeededRandom};
+///
+/// let mut r = SeededRandom::new(1);
+/// let mut key = [0; 16];
+/// let mut mask = [0; 4];
+/// r.fill(&mut key)?;
+/// r.fill(&mut mask)?;
+///
+/// // a ws client's key and its first frame's mask, as C's ws-client
+/// // transcript records them
+/// assert_eq!(&key[..4], &[0x3a, 0xfa, 0x26, 0xb5]);
+/// assert_eq!(mask, [0x16, 0x73, 0x98, 0x76]);
+/// # Ok::<(), npro_core::random::Unavailable>(())
+/// ```
+#[cfg(feature = "replay")]
+#[derive(Clone, Debug)]
+pub struct SeededRandom {
+    s: [u64; 4],
+}
+
+#[cfg(feature = "replay")]
+impl SeededRandom {
+    /// The stream C's `lws_fi_random_seed(cx, seed)` starts.
+    #[must_use]
+    pub fn new(mut seed: u64) -> Self {
+        let mut s = [0; 4];
+        for w in &mut s {
+            *w = splitmix64(&mut seed);
+        }
+        Self { s }
+    }
+
+    /// The next 64-bit word of the stream (C's `lws_xos()`).
+    pub fn next_u64(&mut self) -> u64 {
+        let [s0, s1, s2, s3] = &mut self.s;
+        let result = s1.wrapping_mul(5).rotate_left(7).wrapping_mul(9);
+        let t = s1.wrapping_shl(17);
+
+        *s2 ^= *s0;
+        *s3 ^= *s1;
+        *s1 ^= *s2;
+        *s0 ^= *s3;
+        *s2 ^= t;
+        *s3 = s3.rotate_left(45);
+
+        result
+    }
+}
+
+#[cfg(feature = "replay")]
+impl Random for SeededRandom {
+    fn fill(&mut self, buf: &mut [u8]) -> Result<(), Unavailable> {
+        for chunk in buf.chunks_mut(8) {
+            for (b, w) in chunk.iter_mut().zip(self.next_u64().to_le_bytes()) {
+                *b = w;
+            }
+        }
+        Ok(())
+    }
+}
+
+/// C's splitmix64 (`lib/misc/prng.c`).
+///
+/// It mixes the state as it was *before* adding the increment, where the
+/// reference splitmix64 mixes it after.  This is C's, so that the streams
+/// match; the textbook one would seed a different stream.
+#[cfg(feature = "replay")]
+fn splitmix64(s: &mut u64) -> u64 {
+    let mut r = *s;
+    *s = s.wrapping_add(0x9e37_79b9_7f4a_7c15);
+    r = (r ^ (r >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
+    r = (r ^ (r >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
+    r ^ (r >> 31)
+}
+
+#[cfg(all(test, feature = "replay"))]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn matches_c_api_test_random_prng() {
+        // minimal-examples-lowlevel/api-tests/api-test-random-prng: the
+        // first bytes lws_fi_random_seed(cx, 1234) gives
+        let mut r = SeededRandom::new(1234);
+        let mut b = [0; 16];
+        r.fill(&mut b).unwrap();
+        assert_eq!(
+            b,
+            [
+                0x6b, 0x42, 0x89, 0x9e, 0xa3, 0x63, 0xa1, 0xa3, 0x24, 0x60, 0x07, 0xbb, 0xb7, 0x67,
+                0x64, 0xdc
+            ]
+        );
+        assert_eq!(r.next_u64(), 0xbdf5_c7a4_e6e0_a48b);
+    }
+
+    #[test]
+    fn a_draw_spends_whole_words() {
+        // seed 1: the ws-client transcript's key, then its first mask from
+        // the low half of the third word
+        let mut r = SeededRandom::new(1);
+        let mut key = [0; 16];
+        let mut mask = [0; 4];
+        r.fill(&mut key).unwrap();
+        r.fill(&mut mask).unwrap();
+        assert_eq!(
+            key,
+            [
+                0x3a, 0xfa, 0x26, 0xb5, 0x0a, 0x4a, 0x09, 0x65, 0x27, 0x65, 0x6e, 0xed, 0x31, 0x1e,
+                0x86, 0xab
+            ]
+        );
+        assert_eq!(mask, [0x16, 0x73, 0x98, 0x76]);
+
+        // and a second mask starts on the next word, not the 4 bytes left
+        let mut a = SeededRandom::new(1);
+        let mut skip = [0; 24];
+        a.fill(&mut skip).unwrap();
+        let mut next = [0; 4];
+        r.fill(&mut next).unwrap();
+        let mut want = [0; 4];
+        a.fill(&mut want).unwrap();
+        assert_eq!(next, want);
+    }
+
+    #[test]
+    fn reseeding_restarts_the_stream() {
+        let mut a = SeededRandom::new(7);
+        let first = a.next_u64();
+        a.next_u64();
+        assert_eq!(SeededRandom::new(7).next_u64(), first);
+        assert_ne!(SeededRandom::new(8).next_u64(), first);
+    }
+
+    #[test]
+    fn an_empty_draw_spends_nothing() {
+        let mut a = SeededRandom::new(1);
+        a.fill(&mut []).unwrap();
+        assert_eq!(a.next_u64(), SeededRandom::new(1).next_u64());
+    }
+}
diff --git a/scripts/ci.sh b/scripts/ci.sh
index 3771cea..dc27d3a 100755
--- a/scripts/ci.sh
+++ b/scripts/ci.sh
@@ -8,7 +8,8 @@
 # Set CARGO_TARGET_DIR to keep build output out of the tree.  The MSRV build
 # needs the toolchain named in Cargo.toml's rust-version (rustup toolchain
 # install 1.85 --profile minimal); cargo-deny and cargo-audit must be
-# installed.
+# installed, and the no_std check needs the thumbv7em-none-eabihf target
+# (rustup target add thumbv7em-none-eabihf).
 
 set -eu
 
@@ -21,6 +22,7 @@ cargo fmt --all --check
 
 echo "== clippy"
 cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
+cargo clippy --workspace --all-targets --locked -- -D warnings
 
 echo "== test"
 cargo test --workspace --all-features --locked
@@ -31,6 +33,12 @@ RUSTDOCFLAGS="-D warnings" cargo doc --workspace --all-features --no-deps --lock
 echo "== msrv $msrv"
 cargo "+$msrv" check --workspace --all-targets --all-features --locked
 
+echo "== no_std"
+# the sans-IO crates must build for a target with no std at all
+for c in npro-core; do
+	cargo build -p "$c" --all-features --target thumbv7em-none-eabihf --locked
+done
+
 echo "== deny"
 cargo deny --all-features check
 
Page fetched 0s ago, creation time: 7ms (vhost etag hits: 0%, cache hits: 0%)