From 30518fbe3a7c1d840bce49bba8e40040a4ff8e0b Mon Sep 17 00:00:00 2001 From: forkwright Date: Sat, 15 Aug 2026 15:33:26 -0500 Subject: [PATCH 1/3] fix(sphragis)!: make deterministic encapsulation private encapsulate_deterministic accepted caller-supplied KEM randomness and was pub (gated only by #[doc(hidden)], which hides rustdoc output, not visibility) -- reachable from any downstream crate despite its own doc comment warning "never call with non-uniform or reused randomness." Reused or predictable coins deterministically collapse the ephemeral X25519 secret, the ML-KEM message, the ciphertext, and the shared secret -- the exact guarantee EncapsulationKey::encapsulate exists to provide. Drop pub and #[doc(hidden)]: the method stays a private inherent method, not cfg(test), because encapsulate() calls straight through to it with fresh OsRng bytes in every build, so it must compile unconditionally. Privacy alone already makes it unreachable outside this crate -- no downstream crate can name it, and no external conformance consumer exists today (akroasis, the only consumer of sphragis, never references it). Relocate the X-Wing draft known-answer test beside the implementation, in hybrid.rs's own #[cfg(test)] mod tests -- the integration test in tests/ compiles as a separate crate and can no longer see the now-private method. Add a compile_fail doctest on EncapsulationKey proving the API-surface assertion directly (basanos/standards/RUST.md Compile-Fail tests convention): calling encapsulate_deterministic from outside the crate must fail to build. BREAKING CHANGE: EncapsulationKey::encapsulate_deterministic is no longer part of the public API. No known external caller. Part of #17 --- crypto-provenance.toml | 24 ++++--- src/hybrid.rs | 116 ++++++++++++++++++++++++++++++++-- tests/known_answer_vectors.rs | 63 +++--------------- 3 files changed, 135 insertions(+), 68 deletions(-) diff --git a/crypto-provenance.toml b/crypto-provenance.toml index 0a722e9..01c504e 100644 --- a/crypto-provenance.toml +++ b/crypto-provenance.toml @@ -17,9 +17,9 @@ trigger = """ Any of the following requires re-deriving and re-vendoring the affected vector(s) against the new source, updating this file's source_sha256 / -locked_version / revision fields, and confirming the corresponding test in -tests/known_answer_vectors.rs still asserts full construction output (not -just a shared secret) — before merge, not after: +locked_version / revision fields, and confirming the corresponding test +(location per that vector's `executed_by` field, below) still asserts full +construction output (not just a shared secret) — before merge, not after: 1. `cargo update` changes the resolved version of any crate listed under [[dependency]] below. 2. The X-Wing draft (draft-connolly-cfrg-xwing-kem) publishes a revision @@ -92,12 +92,16 @@ revision = "RFC 8439" source_url = "https://www.rfc-editor.org/rfc/rfc8439" # --------------------------------------------------------------------------- -# Vectors. Every entry here MUST have a corresponding executed assertion in -# tests/known_answer_vectors.rs (`executed_by`). Vendored vectors are -# committed verbatim, byte-for-byte, from the cited upstream commit — never -# hand-trimmed or re-typed — so `source_sha256` is checkable both against the -# vendored file on disk (tests/provenance_lock.rs does this) and against a -# fresh fetch of `origin_url` at any time. +# Vectors. Every entry here MUST have a corresponding executed assertion, +# named in that entry's own `executed_by` field — almost all live in +# tests/known_answer_vectors.rs; xwing-kat-0 lives in src/hybrid.rs's own +# #[cfg(test)] mod tests instead, because it drives deterministic +# encapsulation, a private method (forkwright/sphragis#17) that only the +# crate's own test build can name. Vendored vectors are committed verbatim, +# byte-for-byte, from the cited upstream commit — never hand-trimmed or +# re-typed — so `source_sha256` is checkable both against the vendored file +# on disk (tests/provenance_lock.rs does this) and against a fresh fetch of +# `origin_url` at any time. # --------------------------------------------------------------------------- [[vector]] @@ -107,7 +111,7 @@ origin_url = "https://github.com/dconnolly/draft-connolly-cfrg-xwing-kem/blob/ma vendored_from = "https://github.com/RustCrypto/KEMs/blob/bd482f292dedc95002eca3aa35e26222f0cb1064/x-wing/tests/test-vectors.json" vendored_file = "tests/vectors/xwing-draft-connolly-test-vectors.json" source_sha256 = "a8726596f4c7629590f727b1bbeb483f6932292fbf5fd85c9cbb803190014f00" -executed_by = "tests/known_answer_vectors.rs::xwing_draft_kat_vector_0" +executed_by = "src/hybrid.rs::tests::deterministic_encapsulate_reproduces_xwing_draft_kat" asserts = ["seed -> decapsulation key (sk)", "encapsulation key (pk)", "ciphertext (ct)", "shared secret (ss), send and receive side"] [[vector]] diff --git a/src/hybrid.rs b/src/hybrid.rs index ea6d8e9..748ff33 100644 --- a/src/hybrid.rs +++ b/src/hybrid.rs @@ -100,6 +100,19 @@ pub struct HybridKem; /// /// Public data: freely serializable and shareable. Wire form is /// `ML-KEM-768 ek (1184) || X25519 pk (32)`. +/// +/// [`encapsulate`](Self::encapsulate) — which always draws fresh OS +/// randomness — is the only encapsulation entry point reachable from outside +/// this crate. The deterministic path the known-answer test needs is a +/// private method, not part of this type's public API: +/// +/// ```compile_fail +/// # use sphragis::HybridKem; +/// let (_dk, ek) = HybridKem::generate(); +/// let randomness = [0u8; 64]; +/// // `encapsulate_deterministic` is private — this does not compile. +/// let _ = ek.encapsulate_deterministic(&randomness); +/// ``` #[derive(Clone)] pub struct EncapsulationKey { ek_m: MlKemEk, @@ -293,17 +306,29 @@ impl EncapsulationKey { } /// Deterministic encapsulation from 64 bytes of randomness (first 32 → ML-KEM - /// message, last 32 → X25519 ephemeral). For known-answer testing only. + /// message, last 32 → X25519 ephemeral). /// - /// WARNING: never call with non-uniform or reused randomness. + /// INVARIANT: private by construction. Deterministic KEM encapsulation + /// with caller-supplied randomness is a known-answer-test affordance: on + /// reused or non-uniform input it deterministically collapses the + /// ephemeral X25519 secret, the ML-KEM coins, the ciphertext, and the + /// shared secret — the exact failure [`encapsulate`](Self::encapsulate) + /// exists to make impossible. It stays a private inherent method rather + /// than gaining a `cfg(test)` gate because [`encapsulate`] itself calls + /// straight through to it in every build (with fresh `OsRng` bytes), so + /// the method must compile unconditionally; privacy alone already keeps + /// it unreachable from any downstream crate. The known-answer test that + /// exercises this method directly with the published draft vector lives + /// beside it, in this module's own `#[cfg(test)] mod tests` below — a + /// `tests/` integration file compiles as a separate crate and cannot + /// name a private item. /// /// # Errors /// /// Returns [`SealError::WrongLength`] if the ML-KEM message seed cannot be /// formed from `randomness` (unreachable for a `[u8; 64]` input; propagated /// per the crate's no-silent-fallback discipline). - #[doc(hidden)] - pub fn encapsulate_deterministic( + fn encapsulate_deterministic( &self, randomness: &[u8; 64], ) -> Result<(Vec, SharedSecret), SealError> { @@ -423,3 +448,86 @@ fn x_public_from_slice(bytes: &[u8]) -> Result { })?; Ok(XPublic::from(arr)) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Reads a vendored vector fixture (`tests/vectors/`) as JSON. + /// + /// `crypto-provenance.toml` records this file's provenance and hash; + /// `tests/provenance_lock.rs` checks the hash. Reading it here rather + /// than re-typing its fields as a parallel set of hex literals means the + /// executed assertion and the hash-locked file can never silently + /// desync. + #[expect( + clippy::unwrap_used, + reason = "KAT harness: this repo's own vendored, hash-locked vector fixture; a failed read/parse IS the test failure" + )] + fn vector_json(name: &str) -> serde_json::Value { + let path = format!("{}/tests/vectors/{name}", env!("CARGO_MANIFEST_DIR")); + let raw = std::fs::read_to_string(&path).unwrap(); + serde_json::from_str(&raw).unwrap() + } + + #[expect( + clippy::unwrap_used, + reason = "KAT harness: inputs are fixed known-answer vectors; a failed unwrap IS the test failure" + )] + fn hex_field(v: &serde_json::Value, field: &str) -> Vec { + hex::decode(v[field].as_str().unwrap()).unwrap() + } + + /// X-Wing draft known-answer vector (`crypto-provenance.toml`: + /// xwing-kat-0; draft-connolly-cfrg-xwing-kem). `seed` -> keypair; + /// `eseed` -> deterministic encapsulation. Only this crate's own test + /// build can name `encapsulate_deterministic` — see its doc comment + /// above. + #[test] + #[expect( + clippy::unwrap_used, + clippy::similar_names, + reason = "KAT harness: inputs are fixed known-answer vectors, a failed unwrap IS the test failure; expected_sk/pk/ct/ss mirror the vendored vector's own field names (sk/pk/ct/ss), which mirror the X-Wing spec notation; spec-faithful names beat the similar_names heuristic" + )] + fn deterministic_encapsulate_reproduces_xwing_draft_kat() { + let doc = vector_json("xwing-draft-connolly-test-vectors.json"); + let v = &doc[0]; + let seed: [u8; DECAPSULATION_KEY_LEN] = hex_field(v, "seed").try_into().unwrap(); + let eseed: [u8; 64] = hex_field(v, "eseed").try_into().unwrap(); + let expected_sk = hex_field(v, "sk"); + let expected_pk = hex_field(v, "pk"); + let expected_ct = hex_field(v, "ct"); + let expected_ss = hex_field(v, "ss"); + + let dk = DecapsulationKey::from_seed(seed); + assert_eq!( + dk.to_seed().as_slice(), + expected_sk.as_slice(), + "to_seed must export exactly the seed the key was built from" + ); + let ek = dk.encapsulation_key(); + assert_eq!( + ek.to_bytes(), + expected_pk, + "X-Wing keygen must reproduce the draft KAT encapsulation key" + ); + + let (ct, ss_send) = ek.encapsulate_deterministic(&eseed).unwrap(); + assert_eq!( + ct, expected_ct, + "X-Wing deterministic encaps must reproduce the draft KAT ciphertext" + ); + assert_eq!( + ss_send.as_slice(), + expected_ss.as_slice(), + "X-Wing deterministic encaps must reproduce the draft KAT shared secret" + ); + + let ss_recv = dk.decapsulate(&ct).unwrap(); + assert_eq!( + ss_recv.as_slice(), + expected_ss.as_slice(), + "X-Wing decaps must recover the draft KAT shared secret" + ); + } +} diff --git a/tests/known_answer_vectors.rs b/tests/known_answer_vectors.rs index da67d06..16a93f8 100644 --- a/tests/known_answer_vectors.rs +++ b/tests/known_answer_vectors.rs @@ -5,7 +5,11 @@ //! ones) a hash `tests/provenance_lock.rs` checks against the file on disk. //! Proves the construction matches the published standards: //! - X-Wing draft KAT: full hybrid keypair, ciphertext, and shared secret -//! (both directions), not shared-secret-only. +//! (both directions), not shared-secret-only. Lives beside the +//! implementation, in `src/hybrid.rs`'s own `#[cfg(test)] mod tests` — not +//! here. It drives deterministic encapsulation, which is a private method +//! on `EncapsulationKey`; this file compiles as a separate crate and +//! cannot name it (forkwright/sphragis#17). //! - FIPS-203 ML-KEM-768 ACVP: keygen (seed -> ek), encapsulation //! (ek, m -> ct, k), and decapsulation (dk, ct -> k) — executed locally, //! not delegated to the `ml-kem` crate's own test suite (a consumer @@ -54,60 +58,12 @@ fn hex_field(v: &serde_json::Value, field: &str) -> Vec { // --------------------------------------------------------------------------- // X-Wing draft known-answer vector (crypto-provenance.toml: xwing-kat-0). // draft-connolly-cfrg-xwing-kem. seed -> keypair; eseed -> deterministic -// encaps. Origin: the spec authors' own spec/test-vectors.json, vendored via -// RustCrypto/KEMs. +// encaps. Lives beside the implementation, in `src/hybrid.rs`'s own +// `#[cfg(test)] mod tests` — deterministic encapsulation is a private method +// on `EncapsulationKey`; this file compiles as a separate crate and cannot +// name it (forkwright/sphragis#17). // --------------------------------------------------------------------------- -/// X-Wing KAT: deterministic encapsulation reproduces the published -/// encapsulation key, ciphertext, and shared secret; decapsulation recovers -/// the same shared secret. -#[expect( - clippy::similar_names, - reason = "expected_sk/pk/ct/ss mirror the vendored vector's own field names (sk/pk/ct/ss), which mirror the X-Wing spec notation; spec-faithful names beat the similar_names heuristic (hybrid.rs does the same for ss_m/ss_x/ct_x/pk_x)" -)] -#[test] -fn xwing_draft_kat_vector_0() { - let doc = vector_json("xwing-draft-connolly-test-vectors.json"); - let v = &doc[0]; - let seed: [u8; 32] = hex_field(v, "seed").try_into().unwrap(); - let eseed: [u8; 64] = hex_field(v, "eseed").try_into().unwrap(); - let expected_sk = hex_field(v, "sk"); - let expected_pk = hex_field(v, "pk"); - let expected_ct = hex_field(v, "ct"); - let expected_ss = hex_field(v, "ss"); - - let dk = DecapsulationKey::from_seed(seed); - assert_eq!( - dk.to_seed().as_slice(), - expected_sk.as_slice(), - "to_seed must export exactly the seed the key was built from" - ); - let ek = dk.encapsulation_key(); - assert_eq!( - ek.to_bytes(), - expected_pk, - "X-Wing keygen must reproduce the draft KAT encapsulation key" - ); - - let (ct, ss_send) = ek.encapsulate_deterministic(&eseed).unwrap(); - assert_eq!( - ct, expected_ct, - "X-Wing deterministic encaps must reproduce the draft KAT ciphertext" - ); - assert_eq!( - ss_send.as_slice(), - expected_ss.as_slice(), - "X-Wing deterministic encaps must reproduce the draft KAT shared secret" - ); - - let ss_recv = dk.decapsulate(&ct).unwrap(); - assert_eq!( - ss_recv.as_slice(), - expected_ss.as_slice(), - "X-Wing decaps must recover the draft KAT shared secret" - ); -} - // --------------------------------------------------------------------------- // FIPS-203 ML-KEM-768 known-answer vectors (crypto-provenance.toml: // mlkem768-keygen-acvp, mlkem768-encapdecap-acvp). Executed locally: this is @@ -327,7 +283,6 @@ fn chacha20poly1305_rfc8439_2_8_2() { ); } -// --------------------------------------------------------------------------- // RFC 5869 HKDF-SHA256 — Test Case 1. // --------------------------------------------------------------------------- From 88e2c54a6b2129fb13f1fcd4866cd916cc0851fe Mon Sep 17 00:00:00 2001 From: forkwright Date: Sat, 15 Aug 2026 17:40:02 -0500 Subject: [PATCH 2/3] fix(sphragis): gate the SharedSecret alias itself behind hazmat, matching HybridKem #32 (sphragis#23) narrowed the public API but left the `SharedSecret` type alias unconditionally `pub`, reachable via the `sphragis::hybrid::SharedSecret` module path even without `hazmat` -- unlike `HybridKem`, which #32 gated at the type-definition level (`pub(crate)` without `hazmat`, `pub` with it). The doc comment on the alias explained why: `EncapsulationKey:: encapsulate_deterministic` returned a `SharedSecret` unconditionally as a `pub` (doc-hidden) method, so narrowing the alias's visibility would have left it leaking through that method's signature (`private_interfaces`, denied under `-D warnings`). `encapsulate_deterministic` is now a private method (this branch, #17), so that blocker is gone. Complete the gating `HybridKem` already has: split the alias into `pub(crate)` (without `hazmat`) / `pub` (with `hazmat`) variants, and correct the doc comment, which cited the now-superseded public-method reasoning. Part of #17 --- src/hybrid.rs | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/hybrid.rs b/src/hybrid.rs index 748ff33..bb5cfa1 100644 --- a/src/hybrid.rs +++ b/src/hybrid.rs @@ -51,18 +51,22 @@ pub const SHARED_SECRET_LEN: usize = 32; // kanon:ignore RUST/pub-visibility -- /// A hybrid shared secret. Zeroized on drop. /// -/// The alias name stays reachable at `sphragis::hybrid::SharedSecret` even -/// without `hazmat` — [`EncapsulationKey::encapsulate_deterministic`] (a -/// pre-existing `#[doc(hidden)]` KAT-only method, unrelated to sphragis#23) -/// returns it unconditionally, so narrowing this alias's own visibility -/// would leak it through that method's signature instead -/// (`private_interfaces`, denied under `-D warnings`). This is inert: no -/// operation reachable without `hazmat` (`HybridKem::generate`, direct +/// Internal: without `hazmat`, no operation (`HybridKem::generate`, direct /// `encapsulate`/`decapsulate`, `derive_wrap_key` — see sphragis#23) can -/// produce a real X-Wing-derived value of it, and `Zeroizing<[u8; 32]>` — the -/// type this aliases — carries no capability a consumer could not already -/// construct directly from the public `zeroize` crate. -pub type SharedSecret = Zeroizing<[u8; SHARED_SECRET_LEN]>; // kanon:ignore RUST/pub-visibility -- re-exported in lib.rs under hazmat only (sphragis#23); stays reachable via the hybrid module path regardless, see doc comment above +/// produce one, so the alias itself is `pub(crate)`, matching `HybridKem`'s +/// gating above. `EncapsulationKey::encapsulate_deterministic` (the one +/// other former source of a `SharedSecret`) is a private method +/// (forkwright/sphragis#17), not a public one, so it does not force this +/// alias to stay reachable. +#[cfg(not(feature = "hazmat"))] +pub(crate) type SharedSecret = Zeroizing<[u8; SHARED_SECRET_LEN]>; +/// A hybrid shared secret. Zeroized on drop. +/// +/// HAZMAT: the generic hybrid-KEM primitive's raw output, reachable only +/// with the `hazmat` feature — no stability promise (sphragis#23). +// kanon:ignore RUST/pub-visibility -- hazmat-only primitive surface (sphragis#23): re-exported for KAT/conformance testing, feature-gated off the normal public API +#[cfg(feature = "hazmat")] +pub type SharedSecret = Zeroizing<[u8; SHARED_SECRET_LEN]>; type MlKemDk = ml_kem::DecapsulationKey; type MlKemEk = ml_kem::EncapsulationKey; From a299676158f936903289d34d2b32a385bbfa6f1b Mon Sep 17 00:00:00 2001 From: forkwright Date: Sat, 15 Aug 2026 17:56:43 -0500 Subject: [PATCH 3/3] fix(sphragis): suppress indexing_slicing on the relocated KAT test's vector index Relocating the X-Wing KAT into src/hybrid.rs's own #[cfg(test)] mod tests (this branch) moved it out of tests/known_answer_vectors.rs, which carries a file-level #![expect(clippy::indexing_slicing, ...)] covering its own `&doc[0]` vector-array access. A library test module has no such blanket exemption, so clippy::indexing_slicing (denied under -D warnings) fired on this test's own `&doc[0]` under --features preview-pq (no hazmat). Part of #17 --- src/hybrid.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/hybrid.rs b/src/hybrid.rs index bb5cfa1..b223c44 100644 --- a/src/hybrid.rs +++ b/src/hybrid.rs @@ -490,8 +490,9 @@ mod tests { #[test] #[expect( clippy::unwrap_used, + clippy::indexing_slicing, clippy::similar_names, - reason = "KAT harness: inputs are fixed known-answer vectors, a failed unwrap IS the test failure; expected_sk/pk/ct/ss mirror the vendored vector's own field names (sk/pk/ct/ss), which mirror the X-Wing spec notation; spec-faithful names beat the similar_names heuristic" + reason = "KAT harness: inputs are fixed known-answer vectors, a failed unwrap or out-of-bounds index IS the test failure; expected_sk/pk/ct/ss mirror the vendored vector's own field names (sk/pk/ct/ss), which mirror the X-Wing spec notation; spec-faithful names beat the similar_names heuristic" )] fn deterministic_encapsulate_reproduces_xwing_draft_kat() { let doc = vector_json("xwing-draft-connolly-test-vectors.json");