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..b223c44 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; @@ -100,6 +104,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 +310,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 +452,87 @@ 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::indexing_slicing, + clippy::similar_names, + 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"); + 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. // ---------------------------------------------------------------------------