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
12 changes: 9 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
10 changes: 7 additions & 3 deletions .kanon-ci.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
17 changes: 17 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
48 changes: 48 additions & 0 deletions DECISION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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

Expand Down
59 changes: 47 additions & 12 deletions src/envelope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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<Zeroizing<[u8; WRAP_KEY_LEN]>, SealError> {
Expand All @@ -51,6 +48,44 @@ 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<Zeroizing<[u8; WRAP_KEY_LEN]>, 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<Zeroizing<[u8; WRAP_KEY_LEN]>, SealError> {
derive_wrap_key_impl(shared_secret, domain)
}

/// Seals `content_key` under `wrap_key`, binding `aad`. Returns
/// `ciphertext || tag`.
///
Expand Down
Loading