From 32d0da5b4dc08c27c7458b9c6b08b19d6a443ed2 Mon Sep 17 00:00:00 2001 From: forkwright Date: Sat, 15 Aug 2026 17:04:14 -0500 Subject: [PATCH 1/3] feat(sphragis): narrow the public API to the envelope profile, define the adapter seam Sphragis earns authority as a versioned, multi-recipient content-key envelope, not as a generic X-Wing/KEM primitive library. Hides HybridKem, direct EncapsulationKey::encapsulate/DecapsulationKey::decapsulate, and derive_wrap_key behind a new `hazmat` feature (RustCrypto/rustls convention) -- reachable only for this crate's own known-answer/ conformance tests, no stability promise. `generate_recipient_keypair` is the new stable, profile-level entry point for device-key creation, replacing `HybridKem::generate()` for normal consumers. EncapsulationKey/DecapsulationKey and their key-management operations (to_bytes/from_bytes, from_seed/to_seed, encapsulation_key) stay public: seal_for/unseal require them in their own signatures, and publishing or persisting a device key is profile-level, not primitive-level. src/hybrid.rs is now the only module performing a raw KEM operation, and seal.rs/envelope.rs call it exclusively through that narrowed surface -- DECISION.md #9 documents this module boundary as the adapter seam a future upstream-X-Wing migration would use, and explicitly does not attempt that migration: upstream x-wing is still a release-candidate stack (DECISION.md #6), so the local transcription remains the implementation until a stable, audited release meets the migration gate. Deferred, to stay off files two sibling lanes are actively editing: - EncapsulationKey::encapsulate_deterministic's own visibility (#17 is privatizing it directly and moving its KAT inline to src/hybrid.rs). - tests/known_answer_vectors.rs, AGENTS.md, llms.txt (both #17 and #18 rewrite large spans of these; this change instead adds `required-features` on the known_answer_vectors test target so it compiles unmodified whenever `hazmat` is enabled, and leaves the doc file-tree listings for a follow-up sync once all three land). - SharedSecret's type-alias name stays reachable at `sphragis::hybrid::SharedSecret` (not narrowed) because encapsulate_deterministic returns it unconditionally; narrowing it there too would leak a private type through that method's public signature (private_interfaces, denied under -D warnings) without touching the method itself. Documented inline as inert: nothing reachable without `hazmat` can produce a real value of it. BREAKING CHANGE: `HybridKem` and `derive_wrap_key` are no longer exported from the crate root or the `hybrid`/`envelope` modules without the new `hazmat` feature; `EncapsulationKey::encapsulate`/ `DecapsulationKey::decapsulate` likewise require it. Use `generate_recipient_keypair`/`seal_for`/`unseal` instead -- no known consumer is affected (akroasis pins the crate via a git tag but has zero call sites). Part of #23 --- .github/workflows/ci.yml | 12 +++- .kanon-ci.toml | 10 +++- Cargo.toml | 17 ++++++ DECISION.md | 48 +++++++++++++++ README.md | 14 ++++- src/envelope.rs | 58 ++++++++++++++---- src/hybrid.rs | 125 ++++++++++++++++++++++++++++++++++++--- src/lib.rs | 22 ++++++- src/seal.rs | 18 +++++- tests/profile_api.rs | 49 +++++++++++++++ 10 files changed, 341 insertions(+), 32 deletions(-) create mode 100644 tests/profile_api.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04e0f1a..2399933 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,8 +57,10 @@ jobs: - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - name: cargo check (default) run: cargo check --all-targets - - name: cargo check (preview-pq) + - name: cargo check (preview-pq — narrowed public API) run: cargo check --all-targets --features preview-pq + - name: cargo check (preview-pq + hazmat) + run: cargo check --all-targets --features preview-pq,hazmat clippy: name: cargo clippy @@ -74,8 +76,10 @@ jobs: - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - name: clippy (default) run: cargo clippy --all-targets -- -D warnings - - name: clippy (preview-pq) + - name: clippy (preview-pq — narrowed public API) run: cargo clippy --all-targets --features preview-pq -- -D warnings + - name: clippy (preview-pq + hazmat) + run: cargo clippy --all-targets --features preview-pq,hazmat -- -D warnings test: name: cargo test @@ -89,5 +93,7 @@ jobs: - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - name: cargo test (default — inert build check) run: cargo test - - name: cargo test (preview-pq — KAT + round-trip + negatives) + - name: cargo test (preview-pq — narrowed public API, no hazmat) run: cargo test --features preview-pq + - name: cargo test (preview-pq + hazmat — KAT + round-trip + negatives) + run: cargo test --features preview-pq,hazmat diff --git a/.kanon-ci.toml b/.kanon-ci.toml index 4e009d5..0205548 100644 --- a/.kanon-ci.toml +++ b/.kanon-ci.toml @@ -12,15 +12,19 @@ cmd = "cargo fmt --all -- --check" timeout_secs = 300 [stages."cargo check"] -cmd = "cargo check --all-targets --features preview-pq --jobs 8" +# WHY(sphragis#23): hazmat included so this single local stage also compiles +# the KAT gate (tests/known_answer_vectors.rs), which required-features off +# without it; CI's matrix additionally checks preview-pq alone (narrowed +# public API, no hazmat) as its own job. +cmd = "cargo check --all-targets --features preview-pq,hazmat --jobs 8" timeout_secs = 600 [stages."cargo clippy"] -cmd = "cargo clippy --all-targets --features preview-pq --jobs 8 -- -D warnings" +cmd = "cargo clippy --all-targets --features preview-pq,hazmat --jobs 8 -- -D warnings" timeout_secs = 600 [stages."cargo test"] -cmd = "cargo test --features preview-pq --jobs 8 -- --test-threads 8" +cmd = "cargo test --features preview-pq,hazmat --jobs 8 -- --test-threads 8" timeout_secs = 600 [stages."kanon lint"] diff --git a/Cargo.toml b/Cargo.toml index ba1d836..e0ea070 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,13 @@ preview-pq = [ "dep:chacha20poly1305", "dep:rand_core", ] +# WHY(sphragis#23): reachability, not a dependency set — no crypto deps of its +# own. Ships the generic X-Wing/ML-KEM primitive surface (HybridKem, raw +# SharedSecret, direct encaps/decaps, derive_wrap_key) for known-answer-vector +# and conformance testing only. No stability promise. A normal consumer never +# enables this: `generate_recipient_keypair`/`seal_for`/`unseal` are the +# stable envelope-profile entry points and need no hazmat access. +hazmat = ["preview-pq"] [dependencies] snafu = "0.8" @@ -75,6 +82,16 @@ x25519-dalek = { version = "2.0.1", features = ["static_secrets"] } # fleet parser/decoder testing standard; proptest-regressions/ is tracked. proptest = "1" +# WHY(sphragis#23): the KAT gate calls the hazmat-only primitive surface +# directly (HybridKem, derive_wrap_key, direct encaps/decaps) to prove the +# construction against published vectors — declaring that requirement here +# means `cargo test --features preview-pq` (no hazmat) skips this target +# instead of failing to compile it, and `--features preview-pq,hazmat` runs +# it. +[[test]] +name = "known_answer_vectors" +required-features = ["preview-pq", "hazmat"] + [lints.rust] unsafe_code = "forbid" missing_docs = "warn" diff --git a/DECISION.md b/DECISION.md index ece8b4a..1626efe 100644 --- a/DECISION.md +++ b/DECISION.md @@ -202,3 +202,51 @@ Per #131 done-criterion 6, this lands explicitly **unaudited / Preview**: cryptographic review. - The KATs prove the construction matches the published standard; they do **not** substitute for an audit of the implementation. + +## 9. Public API boundary: envelope profile, not a primitive library (sphragis#23) + +Sphragis earns authority as a versioned, multi-recipient content-key +envelope — wire versioning, recipient identity, domain/AAD binding, +sealing/unsealing, key-epoch semantics. It does not earn authority over the +generic X-Wing/KEM primitive underneath it: that primitive is unaudited (§8), +pinned to this repo's own transcription of the draft (§6), and named in §6 as +something to be *replaced*, not depended on directly. + +**What's public.** `generate_recipient_keypair`, `seal_for`, `unseal`, +`RecipientId`, `WrappedContentKey`, `EncapsulationKey`, `DecapsulationKey`. +The last two stay public because they are the profile's recipient-identity +types — `seal_for`/`unseal` take and return them — not because they are +primitives; their key-management operations (`to_bytes`/`from_bytes`, +`from_seed`/`to_seed`, `encapsulation_key`) are profile-level (publish a +device's key, persist a device's secret) and stay reachable. Their *KEM* +operations (raw `encapsulate`/`decapsulate`) do not. + +**What moved behind `hazmat`.** `HybridKem`, the raw `SharedSecret` type, +direct `EncapsulationKey::encapsulate`/`DecapsulationKey::decapsulate`, and +`derive_wrap_key` — the generic hybrid-KEM primitive and its raw output. A +normal consumer has no way to assemble a bespoke construction from these +because it cannot name them; it can only call the versioned envelope +operations. `hazmat` carries no stability promise and exists solely so +`tests/known_answer_vectors.rs` can validate the primitive against published +vectors (X-Wing draft, RFC 5869) — the same justification RustCrypto and +rustls use the word "hazmat" for. + +**The adapter seam.** `src/hybrid.rs` is now the *only* module that performs +a raw KEM operation; `src/seal.rs` calls it exclusively through +`EncapsulationKey`/`DecapsulationKey`'s key-management surface plus the +crate-private `generate`/`encapsulate`/`decapsulate`/`derive_wrap_key` paths. +Swapping the local X-Wing combiner (§6) for a stable, audited upstream +implementation is therefore a change to `src/hybrid.rs` alone: the +`EncapsulationKey`/`DecapsulationKey` wire forms (`ENCAPSULATION_KEY_LEN`, +`CIPHERTEXT_LEN`, `DECAPSULATION_KEY_LEN`), `seal.rs`'s call shapes, and the +`seal_for`/`unseal`/`generate_recipient_keypair` public API do not move. + +**What this decision does not do.** It does not perform the migration §6 +already names as the target — upstream `x-wing` is still a release-candidate +stack (§6), and building the seam does not make a pre-release dependency +production-grade. The gate for the actual swap is unchanged from §6: a +stable, audited upstream X-Wing release, whose keypair/ciphertext/shared-secret +KATs are byte-identical to the vectors this repo already pins (a `v1`-wire +adapter, not a `v2` construction) — otherwise it is a new version, not a +drop-in. Until that gate is met, `src/hybrid.rs`'s transcription remains the +implementation and `hazmat` remains the only way to reach it directly. diff --git a/README.md b/README.md index 63e6afb..7b98bae 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,10 @@ sphragis = { git = "https://github.com/forkwright/sphragis", features = ["previe ``` ```rust,ignore -use sphragis::{HybridKem, seal_for, unseal}; +use sphragis::{generate_recipient_keypair, seal_for, unseal}; -// Each device holds an X-Wing keypair; publish the encapsulation (public) key. -let (dk, ek) = HybridKem::generate(); +// Each device holds a keypair; publish the encapsulation (public) key. +let (dk, ek) = generate_recipient_keypair(); // Seal a content key for a set of devices (one wrap each, same content key). let content_key = [0u8; 32]; @@ -43,12 +43,20 @@ let recovered = unseal(&dk, &wrapped[0])?; assert_eq!(recovered.as_slice(), &content_key); ``` +This is the entire public contract: the generic hybrid-KEM primitive +underneath (`HybridKem`, a raw shared secret, direct encaps/decaps) is not +exported — see "Features" below and `DECISION.md` for the envelope-vs-primitive +boundary (sphragis#23). + Revoke a device by re-running `seal_for` over the remaining recipients (with a fresh content key for forward secrecy, or the same one for a cheap revoke). ## Features - `preview-pq` - enables the hybrid KEM + envelope. **Off by default.** +- `hazmat` - exposes the generic hybrid-KEM primitive (`HybridKem`, raw shared + secret, direct encaps/decaps, `derive_wrap_key`) for known-answer/conformance + testing. **No stability promise; a normal consumer never enables this.** ## Testing diff --git a/src/envelope.rs b/src/envelope.rs index 4b3331e..3a9c8de 100644 --- a/src/envelope.rs +++ b/src/envelope.rs @@ -8,6 +8,11 @@ //! Sha256 cores and block buffers on drop (mirroring the sha3 0.11 property in //! `hybrid`), so the shared-secret-derived state inside the HKDF stack does not //! outlive the derivation. +//! +//! INVARIANT: this module is the primitive side of the envelope seam +//! (sphragis#23) — `derive_wrap_key` is reachable only under `hazmat`. +//! [`crate::seal::seal_for`]/[`crate::seal::unseal`] call the crate-private +//! path unconditionally; a normal consumer never derives a wrap key directly. use chacha20poly1305::aead::{Aead, KeyInit, Payload}; use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; @@ -18,24 +23,16 @@ use zeroize::{Zeroize, Zeroizing}; use crate::error::SealError; /// AEAD nonce length (ChaCha20-Poly1305). -pub const NONCE_LEN: usize = 12; // kanon:ignore RUST/pub-visibility -- public wire-shape constant (typed into WrappedContentKey) +pub(crate) const NONCE_LEN: usize = 12; /// AEAD authentication-tag length (Poly1305). -pub const TAG_LEN: usize = 16; // kanon:ignore RUST/pub-visibility -- public wire-shape constant (sealed_key length validation) +pub(crate) const TAG_LEN: usize = 16; /// Wrapping-key length derived from HKDF. -pub const WRAP_KEY_LEN: usize = 32; // kanon:ignore RUST/pub-visibility -- public constant in derive_wrap_key's signature +pub(crate) const WRAP_KEY_LEN: usize = 32; -/// Derives the 32-byte wrapping key from a hybrid shared secret. -/// /// `HKDF-SHA256(salt = 32 zero bytes, ikm = shared_secret, info = domain)`. /// A null (zero-filled) salt is used per the PQXDH/SP 800-56C convention for a /// uniformly-random IKM. -/// -/// # Errors -/// -/// Returns [`SealError::HkdfExpand`] if expansion fails (cannot occur for a -/// 32-byte output, but surfaced rather than panicking). -// kanon:ignore RUST/pub-visibility -- public API: the RFC 5869 KAT gate consumes it externally -pub fn derive_wrap_key( +fn derive_wrap_key_impl( shared_secret: &[u8], domain: &[u8], ) -> Result, SealError> { @@ -51,6 +48,43 @@ pub fn derive_wrap_key( Ok(okm) } +/// Derives the 32-byte wrapping key from a hybrid shared secret. Internal: +/// [`seal_for`](crate::seal::seal_for)/[`unseal`](crate::seal::unseal) are +/// the stable entry points a normal consumer calls instead. +/// +/// # Errors +/// +/// Returns [`SealError::HkdfExpand`] if expansion fails (cannot occur for a +/// 32-byte output, but surfaced rather than panicking). +#[cfg(not(feature = "hazmat"))] +pub(crate) fn derive_wrap_key( + shared_secret: &[u8], + domain: &[u8], +) -> Result, SealError> { + derive_wrap_key_impl(shared_secret, domain) +} + +/// Derives the 32-byte wrapping key from a hybrid shared secret. +/// +/// HAZMAT: primitive-level HKDF access, reachable only with the `hazmat` +/// feature, for RFC 5869 known-answer testing only — no stability promise. +/// A normal consumer calls +/// [`seal_for`](crate::seal::seal_for)/[`unseal`](crate::seal::unseal) +/// instead, which derive the wrap key internally. +/// +/// # Errors +/// +/// Returns [`SealError::HkdfExpand`] if expansion fails (cannot occur for a +/// 32-byte output, but surfaced rather than panicking). +// kanon:ignore RUST/pub-visibility -- hazmat-only primitive surface (sphragis#23): the RFC 5869 KAT gate consumes it externally, feature-gated off the normal public API +#[cfg(feature = "hazmat")] +pub fn derive_wrap_key( + shared_secret: &[u8], + domain: &[u8], +) -> Result, SealError> { + derive_wrap_key_impl(shared_secret, domain) +} + /// Seals `content_key` under `wrap_key`, binding `aad`. Returns /// `ciphertext || tag`. /// diff --git a/src/hybrid.rs b/src/hybrid.rs index b408569..2e2367b 100644 --- a/src/hybrid.rs +++ b/src/hybrid.rs @@ -7,6 +7,15 @@ //! public key, under the X-Wing domain label. //! //! WARNING: unaudited. Validated against the X-Wing draft known-answer vectors. +//! +//! INVARIANT: this module is the primitive side of the envelope seam +//! (sphragis#23). [`EncapsulationKey`]/[`DecapsulationKey`] are the stable, +//! always-public identity types [`crate::seal::seal_for`]/ +//! [`crate::seal::unseal`] operate on; everything that performs a raw KEM +//! operation on them (`HybridKem`, `SharedSecret`, direct encaps/decaps) is +//! reachable only with the `hazmat` feature. Migrating the combiner to a +//! stable, audited upstream X-Wing crate means editing this module alone — +//! `seal.rs`'s calls and the public identity types do not change. use ml_kem::array::Array; use ml_kem::kem::{Decapsulate, Key, KeyExport}; @@ -41,12 +50,47 @@ pub const DECAPSULATION_KEY_LEN: usize = 32; // kanon:ignore RUST/pub-visibility pub const SHARED_SECRET_LEN: usize = 32; // kanon:ignore RUST/pub-visibility -- public constant in the SharedSecret alias /// A hybrid shared secret. Zeroized on drop. -pub type SharedSecret = Zeroizing<[u8; SHARED_SECRET_LEN]>; // kanon:ignore RUST/pub-visibility -- re-exported in lib.rs +/// +/// 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 +/// `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 type MlKemDk = ml_kem::DecapsulationKey; type MlKemEk = ml_kem::EncapsulationKey; +/// The X-Wing hybrid KEM over X25519 + ML-KEM-768. Internal: a normal +/// consumer calls [`crate::seal::generate_recipient_keypair`] instead of +/// naming this type — see sphragis#23 (envelope-vs-primitive API boundary). +/// +/// Without `hazmat`, `HybridKem` is not exported from the crate root or +/// `hybrid` module — a downstream consumer cannot name it: +/// +/// ```compile_fail +/// # fn _f() -> Result<(), Box> { +/// let _ = sphragis::HybridKem::generate(); // unresolved: not exported without `hazmat` +/// # Ok(()) +/// # } +/// ``` +#[cfg(not(feature = "hazmat"))] +#[derive(Clone, Copy, Debug)] +pub(crate) struct HybridKem; /// The X-Wing hybrid KEM over X25519 + ML-KEM-768. +/// +/// HAZMAT: the generic hybrid-KEM primitive, reachable only with the +/// `hazmat` feature — no stability promise, and no upstream-adapter +/// migration promise either (see `DECISION.md`). A normal consumer calls +/// [`crate::seal::generate_recipient_keypair`] instead. +// 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")] #[derive(Clone, Copy, Debug)] pub struct HybridKem; @@ -75,16 +119,34 @@ impl core::fmt::Debug for DecapsulationKey { } } +// WHY: the seed is born inside Zeroizing so no bare stack copy ever exists. +fn generate_impl() -> (DecapsulationKey, EncapsulationKey) { + let mut seed = Zeroizing::new([0u8; DECAPSULATION_KEY_LEN]); + OsRng.fill_bytes(seed.as_mut_slice()); + let dk = DecapsulationKey { seed }; + let ek = dk.encapsulation_key(); + (dk, ek) +} + impl HybridKem { + /// Generates a fresh X-Wing keypair using the OS CSPRNG. Internal: + /// [`crate::seal::generate_recipient_keypair`] is the stable entry point. + #[cfg(not(feature = "hazmat"))] + #[must_use] + pub(crate) fn generate() -> (DecapsulationKey, EncapsulationKey) { + generate_impl() + } + /// Generates a fresh X-Wing keypair using the OS CSPRNG. - // WHY: the seed is born inside Zeroizing so no bare stack copy ever exists. + /// + /// HAZMAT: reachable only with the `hazmat` feature — no stability + /// promise. A normal consumer calls + /// [`crate::seal::generate_recipient_keypair`] instead. + // kanon:ignore RUST/pub-visibility -- hazmat-only primitive surface (sphragis#23): reachable for KAT/conformance testing, feature-gated off the normal public API + #[cfg(feature = "hazmat")] #[must_use] pub fn generate() -> (DecapsulationKey, EncapsulationKey) { - let mut seed = Zeroizing::new([0u8; DECAPSULATION_KEY_LEN]); - OsRng.fill_bytes(seed.as_mut_slice()); - let dk = DecapsulationKey { seed }; - let ek = dk.encapsulation_key(); - (dk, ek) + generate_impl() } } @@ -117,16 +179,38 @@ impl DecapsulationKey { } /// Decapsulates a ciphertext to recover the hybrid shared secret. + /// Internal: [`crate::seal::unseal`] is the stable entry point. /// /// # Errors /// /// Returns [`SealError::WrongLength`] if the ciphertext is malformed, or /// [`SealError::InvalidMlKem`] if the ML-KEM component is rejected. + #[cfg(not(feature = "hazmat"))] + pub(crate) fn decapsulate(&self, ct: &[u8]) -> Result { + self.decapsulate_impl(ct) + } + + /// Decapsulates a ciphertext to recover the hybrid shared secret. + /// + /// HAZMAT: reachable only with the `hazmat` feature, for known-answer + /// testing only — no stability promise. A normal consumer calls + /// [`crate::seal::unseal`] instead, which decapsulates internally. + /// + /// # Errors + /// + /// Returns [`SealError::WrongLength`] if the ciphertext is malformed, or + /// [`SealError::InvalidMlKem`] if the ML-KEM component is rejected. + // kanon:ignore RUST/pub-visibility -- hazmat-only primitive surface (sphragis#23): reachable for KAT/conformance testing, feature-gated off the normal public API + #[cfg(feature = "hazmat")] + pub fn decapsulate(&self, ct: &[u8]) -> Result { + self.decapsulate_impl(ct) + } + #[expect( clippy::similar_names, reason = "ss_m/ss_x/ct_x/sk_x/pk_x mirror the X-Wing spec notation; spec-faithful names beat the similar_names heuristic (upstream does the same)" )] - pub fn decapsulate(&self, ct: &[u8]) -> Result { + fn decapsulate_impl(&self, ct: &[u8]) -> Result { ensure!( ct.len() == CIPHERTEXT_LEN, WrongLengthSnafu { @@ -163,6 +247,7 @@ impl DecapsulationKey { impl EncapsulationKey { /// Encapsulates to this public key, returning `(ciphertext, shared_secret)`. + /// Internal: [`crate::seal::seal_for`] is the stable entry point. /// /// Uses the OS CSPRNG. Ciphertext wire form is `ML-KEM ct || X25519 ct`. /// @@ -171,7 +256,31 @@ impl EncapsulationKey { /// Returns [`SealError::WrongLength`] if the ML-KEM message seed cannot be /// formed from the sampled randomness (unreachable for a well-formed /// 64-byte buffer; propagated rather than silently defaulted). + #[cfg(not(feature = "hazmat"))] + pub(crate) fn encapsulate(&self) -> Result<(Vec, SharedSecret), SealError> { + self.encapsulate_impl() + } + + /// Encapsulates to this public key, returning `(ciphertext, shared_secret)`. + /// + /// HAZMAT: reachable only with the `hazmat` feature, for known-answer + /// testing only — no stability promise. A normal consumer calls + /// [`crate::seal::seal_for`] instead, which encapsulates internally. + /// + /// Uses the OS CSPRNG. Ciphertext wire form is `ML-KEM ct || X25519 ct`. + /// + /// # Errors + /// + /// Returns [`SealError::WrongLength`] if the ML-KEM message seed cannot be + /// formed from the sampled randomness (unreachable for a well-formed + /// 64-byte buffer; propagated rather than silently defaulted). + // kanon:ignore RUST/pub-visibility -- hazmat-only primitive surface (sphragis#23): reachable for KAT/conformance testing, feature-gated off the normal public API + #[cfg(feature = "hazmat")] pub fn encapsulate(&self) -> Result<(Vec, SharedSecret), SealError> { + self.encapsulate_impl() + } + + fn encapsulate_impl(&self) -> Result<(Vec, SharedSecret), SealError> { let mut rnd = Zeroizing::new([0u8; 64]); OsRng.fill_bytes(rnd.as_mut_slice()); self.encapsulate_deterministic(&rnd) diff --git a/src/lib.rs b/src/lib.rs index ddd70ad..255f32b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,6 +18,20 @@ //! known-answer tests prove the construction matches the published standards; //! they do not substitute for a cryptographic review. Do not use on the default //! binary path. See `DECISION.md` and akroasis#131. +//! +//! # Public surface — envelope profile, not a primitive library (sphragis#23) +//! +//! The stable contract is the versioned envelope: +//! [`generate_recipient_keypair`](seal::generate_recipient_keypair), +//! [`seal_for`], [`unseal`], [`RecipientId`], [`WrappedContentKey`]. The +//! generic hybrid-KEM primitive underneath it (`HybridKem`, a raw +//! `SharedSecret`, direct encaps/decaps, `derive_wrap_key`) is reachable only +//! with the `hazmat` feature, for known-answer/conformance testing — no +//! stability promise, and no migration promise: `DECISION.md` records why the +//! local X-Wing combiner exists (upstream is pre-release) and what gates +//! swapping it for a stable, audited upstream implementation. That swap +//! changes `src/hybrid.rs` alone; this profile's API and wire contract do +//! not move. #![cfg_attr(docsrs, feature(doc_cfg))] #![deny(missing_docs)] @@ -34,9 +48,13 @@ pub mod seal; #[cfg(feature = "preview-pq")] pub use error::SealError; #[cfg(feature = "preview-pq")] -pub use hybrid::{DecapsulationKey, EncapsulationKey, HybridKem, SharedSecret}; +pub use hybrid::{DecapsulationKey, EncapsulationKey}; +#[cfg(all(feature = "preview-pq", feature = "hazmat"))] +pub use hybrid::{HybridKem, SharedSecret}; #[cfg(feature = "preview-pq")] -pub use seal::{seal_for, unseal, RecipientId, WrappedContentKey, CONTENT_KEY_LEN}; +pub use seal::{ + generate_recipient_keypair, seal_for, unseal, RecipientId, WrappedContentKey, CONTENT_KEY_LEN, +}; /// Wire-format version for the v1 sealing construction. /// diff --git a/src/seal.rs b/src/seal.rs index a522af0..8133713 100644 --- a/src/seal.rs +++ b/src/seal.rs @@ -13,7 +13,7 @@ use crate::envelope::{derive_wrap_key, open, seal, NONCE_LEN, TAG_LEN}; use crate::error::{ EnvelopeTooLargeSnafu, SealError, TrailingDataSnafu, UnsupportedVersionSnafu, WrongLengthSnafu, }; -use crate::hybrid::{DecapsulationKey, EncapsulationKey, CIPHERTEXT_LEN}; +use crate::hybrid::{DecapsulationKey, EncapsulationKey, HybridKem, CIPHERTEXT_LEN}; use crate::{SEAL_VERSION_V1, WRAP_DOMAIN_V1}; /// Content-key length (the symmetric key the consuming store uses for payloads). @@ -229,6 +229,22 @@ impl WrappedContentKey { } } +/// Generates a fresh recipient keypair: an [`EncapsulationKey`] (public — +/// publish it so others can seal to this device) and a [`DecapsulationKey`] +/// (secret — persist it via [`DecapsulationKey::to_seed`]). +/// +/// This is the versioned Sphragis operation for device-key creation. It is +/// the only supported way to obtain a keypair for [`seal_for`]/[`unseal`]: +/// the underlying hybrid-KEM primitive (`HybridKem`) is not part of the +/// normal public API (sphragis#23) — see `DECISION.md` for the +/// envelope-vs-primitive boundary and the upstream-adapter seam this exists +/// to keep stable across a future primitive-provider swap. +// kanon:ignore RUST/pub-visibility -- re-exported in lib.rs +#[must_use] +pub fn generate_recipient_keypair() -> (DecapsulationKey, EncapsulationKey) { + HybridKem::generate() +} + /// Seals a content key for each recipient device. /// /// Returns one [`WrappedContentKey`] per recipient; all unseal to the same diff --git a/tests/profile_api.rs b/tests/profile_api.rs new file mode 100644 index 0000000..945d663 --- /dev/null +++ b/tests/profile_api.rs @@ -0,0 +1,49 @@ +//! Normal-consumer acceptance for the envelope profile (sphragis#23). +//! +//! Exercises only the stable public surface — `generate_recipient_keypair`, +//! `seal_for`, `unseal` — with `preview-pq` alone, `hazmat` OFF. This is the +//! proof that narrowing the public API (hiding `HybridKem`, `SharedSecret`, +//! `derive_wrap_key`, and direct encaps/decaps behind `hazmat`) did not also +//! narrow what a normal consumer can *do*: every operation the profile +//! promises still works without naming a single primitive-level item. + +#![cfg(feature = "preview-pq")] + +use sphragis::{generate_recipient_keypair, seal_for, unseal, CONTENT_KEY_LEN}; + +/// A normal consumer generates a keypair, seals a content key for it, and +/// unseals it back — using only the versioned envelope operations. +#[expect( + clippy::unwrap_used, + reason = "integration test: a failed unwrap on our own API's output IS the test failure" +)] +#[test] +fn generate_seal_unseal_round_trip_without_hazmat() { + let (dk, ek) = generate_recipient_keypair(); + let content_key = [0x42u8; CONTENT_KEY_LEN]; + + let wrapped = seal_for(&content_key, &[ek]).unwrap(); + assert_eq!(wrapped.len(), 1); + + let recovered = unseal(&dk, &wrapped[0]).unwrap(); + assert_eq!(recovered.as_slice(), &content_key); +} + +/// Two calls to `generate_recipient_keypair` produce independent devices: +/// device 2's key does not unseal a wrap addressed to device 1. +#[expect( + clippy::unwrap_used, + reason = "integration test: a failed unwrap on our own API's output IS the test failure" +)] +#[test] +fn independently_generated_keypairs_do_not_cross_unseal() { + let (_dk1, ek1) = generate_recipient_keypair(); + let (dk2, _ek2) = generate_recipient_keypair(); + let content_key = [0x24u8; CONTENT_KEY_LEN]; + + let wrapped = seal_for(&content_key, &[ek1]).unwrap(); + assert!( + unseal(&dk2, &wrapped[0]).is_err(), + "an independently generated device must not unseal another device's wrap" + ); +} From b261967f4fbceb6d17d122c07daae5cb89488fae Mon Sep 17 00:00:00 2001 From: forkwright Date: Sat, 15 Aug 2026 17:10:35 -0500 Subject: [PATCH 2/3] fix(sphragis): split too-long first doc paragraphs on the envelope-seam comments clippy::too_long_first_doc_paragraph (CI, cargo clippy preview-pq) flagged generate_recipient_keypair's doc: the summary sentence and the elaboration that followed it were merged into one first paragraph with no blank line between them. The same merge pattern -- appending an "Internal: ..." sentence directly onto an existing single-line summary instead of starting a new paragraph -- was introduced in five more places across hybrid.rs/envelope.rs by the same change; split all of them the same way. Part of #23 --- src/envelope.rs | 7 ++++--- src/hybrid.rs | 16 +++++++++++----- src/seal.rs | 8 +++++--- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/envelope.rs b/src/envelope.rs index 3a9c8de..f869309 100644 --- a/src/envelope.rs +++ b/src/envelope.rs @@ -48,9 +48,10 @@ fn derive_wrap_key_impl( Ok(okm) } -/// Derives the 32-byte wrapping key from a hybrid shared secret. Internal: -/// [`seal_for`](crate::seal::seal_for)/[`unseal`](crate::seal::unseal) are -/// the stable entry points a normal consumer calls instead. +/// Derives the 32-byte wrapping key from a hybrid shared secret. +/// +/// Internal: [`seal_for`](crate::seal::seal_for)/[`unseal`](crate::seal::unseal) +/// are the stable entry points a normal consumer calls instead. /// /// # Errors /// diff --git a/src/hybrid.rs b/src/hybrid.rs index 2e2367b..ea6d8e9 100644 --- a/src/hybrid.rs +++ b/src/hybrid.rs @@ -67,9 +67,11 @@ pub type SharedSecret = Zeroizing<[u8; SHARED_SECRET_LEN]>; // kanon:ignore RUST type MlKemDk = ml_kem::DecapsulationKey; type MlKemEk = ml_kem::EncapsulationKey; -/// The X-Wing hybrid KEM over X25519 + ML-KEM-768. Internal: a normal -/// consumer calls [`crate::seal::generate_recipient_keypair`] instead of -/// naming this type — see sphragis#23 (envelope-vs-primitive API boundary). +/// The X-Wing hybrid KEM over X25519 + ML-KEM-768. +/// +/// Internal: a normal consumer calls +/// [`crate::seal::generate_recipient_keypair`] instead of naming this type — +/// see sphragis#23 (envelope-vs-primitive API boundary). /// /// Without `hazmat`, `HybridKem` is not exported from the crate root or /// `hybrid` module — a downstream consumer cannot name it: @@ -129,8 +131,10 @@ fn generate_impl() -> (DecapsulationKey, EncapsulationKey) { } impl HybridKem { - /// Generates a fresh X-Wing keypair using the OS CSPRNG. Internal: - /// [`crate::seal::generate_recipient_keypair`] is the stable entry point. + /// Generates a fresh X-Wing keypair using the OS CSPRNG. + /// + /// Internal: [`crate::seal::generate_recipient_keypair`] is the stable + /// entry point. #[cfg(not(feature = "hazmat"))] #[must_use] pub(crate) fn generate() -> (DecapsulationKey, EncapsulationKey) { @@ -179,6 +183,7 @@ impl DecapsulationKey { } /// Decapsulates a ciphertext to recover the hybrid shared secret. + /// /// Internal: [`crate::seal::unseal`] is the stable entry point. /// /// # Errors @@ -247,6 +252,7 @@ impl DecapsulationKey { impl EncapsulationKey { /// Encapsulates to this public key, returning `(ciphertext, shared_secret)`. + /// /// Internal: [`crate::seal::seal_for`] is the stable entry point. /// /// Uses the OS CSPRNG. Ciphertext wire form is `ML-KEM ct || X25519 ct`. diff --git a/src/seal.rs b/src/seal.rs index 8133713..cd043b3 100644 --- a/src/seal.rs +++ b/src/seal.rs @@ -229,9 +229,11 @@ impl WrappedContentKey { } } -/// Generates a fresh recipient keypair: an [`EncapsulationKey`] (public — -/// publish it so others can seal to this device) and a [`DecapsulationKey`] -/// (secret — persist it via [`DecapsulationKey::to_seed`]). +/// Generates a fresh recipient keypair. +/// +/// Returns an [`EncapsulationKey`] (public — publish it so others can seal +/// to this device) and a [`DecapsulationKey`] (secret — persist it via +/// [`DecapsulationKey::to_seed`]). /// /// This is the versioned Sphragis operation for device-key creation. It is /// the only supported way to obtain a keypair for [`seal_for`]/[`unseal`]: From 08a798f34b6126bda026ff0cf57973a19f173bbd Mon Sep 17 00:00:00 2001 From: forkwright Date: Sat, 15 Aug 2026 17:13:12 -0500 Subject: [PATCH 3/3] fix(sphragis): avoid indexing in the new profile_api integration test clippy::indexing_slicing (CI, cargo clippy preview-pq) flagged both `&wrapped[0]` sites in tests/profile_api.rs. Use `.first()` instead, per the crate's own no-indexing convention (STANDARDS.md) -- the new test file did not carry the KAT harness's `#![expect(clippy::indexing_slicing, ...)]` because indexing there is not equally justified. Part of #23 --- tests/profile_api.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/profile_api.rs b/tests/profile_api.rs index 945d663..ddb7c7b 100644 --- a/tests/profile_api.rs +++ b/tests/profile_api.rs @@ -25,7 +25,7 @@ fn generate_seal_unseal_round_trip_without_hazmat() { let wrapped = seal_for(&content_key, &[ek]).unwrap(); assert_eq!(wrapped.len(), 1); - let recovered = unseal(&dk, &wrapped[0]).unwrap(); + let recovered = unseal(&dk, wrapped.first().unwrap()).unwrap(); assert_eq!(recovered.as_slice(), &content_key); } @@ -43,7 +43,7 @@ fn independently_generated_keypairs_do_not_cross_unseal() { let wrapped = seal_for(&content_key, &[ek1]).unwrap(); assert!( - unseal(&dk2, &wrapped[0]).is_err(), + unseal(&dk2, wrapped.first().unwrap()).is_err(), "an independently generated device must not unseal another device's wrap" ); }