Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 14 additions & 10 deletions crypto-provenance.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]]
Expand All @@ -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]]
Expand Down
143 changes: 128 additions & 15 deletions src/hybrid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<MlKem768>;
type MlKemEk = ml_kem::EncapsulationKey<MlKem768>;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<u8>, SharedSecret), SealError> {
Expand Down Expand Up @@ -423,3 +452,87 @@ fn x_public_from_slice(bytes: &[u8]) -> Result<XPublic, SealError> {
})?;
Ok(XPublic::from(arr))
}

#[cfg(test)]
mod tests {
use super::*;

/// Reads a vendored vector fixture (`tests/vectors/<name>`) 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<u8> {
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"
);
}
}
63 changes: 9 additions & 54 deletions tests/known_answer_vectors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -54,60 +58,12 @@ fn hex_field(v: &serde_json::Value, field: &str) -> Vec<u8> {
// ---------------------------------------------------------------------------
// 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
Expand Down Expand Up @@ -327,7 +283,6 @@ fn chacha20poly1305_rfc8439_2_8_2() {
);
}

// ---------------------------------------------------------------------------
// RFC 5869 HKDF-SHA256 — Test Case 1.
// ---------------------------------------------------------------------------

Expand Down