Project homepage Mailing List  Warmcat.com  API Docs  Github Mirror 
    npro  
 Modern all-safe Rust Network Protocol library supporting h1, h2, h3, ws, wt sans-IO and with socket IO + tls
git clone https://npro.rs/repo/npro
 
root / fuzz / seeds / ws-client / ws-client-ping-close.ws
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 that
cannot give bytes says Unavailable, and the connection drawing them fails,
as C fails a short lws_get_random(), rather than go on with predictable
bytes.

SeededRandom, behind the additive "replay" feature, is C's fault-injection
stream (lws_fi_random_seed()):
- xoshiro256**, seeded through C's splitmix64;
- each draw takes ceil(len / 8) words, laid out little-endian, and drops
  what is left of the last one.

That is what lets C's seeded transcripts replay byte for byte.  C's
splitmix64 mixes the state before adding the increment, not after as the
reference one does.  The textbook version seeds a different stream, so
the function says so where it is defined.

The type is documented as predictable and never for a real connection's
keys or masks, and it only exists with the feature.  Its tests pin:
- api-test-random-prng's vector for seed 1234;
- the ws-client transcript's key and first mask for seed 1, which shows a
  4-byte draw spending a whole word;
- that reseeding restarts the stream, and an empty draw spends nothing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kg5Eemy68ZaqDBcUJQG6J
diff --git a/Cargo.lock b/Cargo.lock index e86a423..febbb52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7,5 +7,9 @@ name = "npro" version = "0.0.2" [[package]] +name = "npro-core" +version = "0.0.2" + +[[package]] name = "npro-test" version = "0.0.2" diff --git a/crates/npro-core/Cargo.toml b/crates/npro-core/Cargo.toml new file mode 100644 index 0000000..4a38c2a --- /dev/null +++ b/crates/npro-core/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "npro-core" +description = "The sans-IO core of npro: connection state, time and random as inputs, and the substrate the protocol crates share" +readme = "../../README.md" +keywords = ["sans-io", "no-std", "networking"] +categories = ["network-programming", "no-std"] +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true + +[features] +# SeededRandom: lws' deterministic random, for replaying C's transcripts. +# Never a source for a real connection's keys or masks. +replay = [] + +[lints] +workspace = true diff --git a/crates/npro-core/src/lib.rs b/crates/npro-core/src/lib.rs new file mode 100644 index 0000000..d21ea3c --- /dev/null +++ b/crates/npro-core/src/lib.rs @@ -0,0 +1,11 @@ +//! The sans-IO core of npro. +//! +//! What the protocol crates share: the connection's state machines, time +//! and random as inputs rather than things read from the platform, and the +//! substrate the protocols are built from. Nothing here owns a socket, a +//! thread or a clock; the IO side, or a test, supplies all of them. + +#![no_std] +#![forbid(unsafe_code)] + +pub mod random; diff --git a/crates/npro-core/src/random.rs b/crates/npro-core/src/random.rs new file mode 100644 index 0000000..9791f94 --- /dev/null +++ b/crates/npro-core/src/random.rs @@ -0,0 +1,198 @@ +//! Random, as an input. +//! +//! The protocols draw random bytes for a few things: a ws client's +//! `Sec-WebSocket-Key` and its frame masks, a multipart boundary. In C +//! they call `lws_get_random()`; here the caller hands in a [`Random`], so +//! a test or a replay chooses where the bytes come from, and the protocol +//! crates never reach for the platform. +//! +//! A draw is the unit: one call to [`Random::fill`] is one +//! `lws_get_random()` in C. That matters for [`SeededRandom`], which, like +//! C, spends whole 64-bit words per draw, so the same draws in the same +//! 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: 2ms (vhost etag hits: 0%, cache hits: 0%)