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
36 changes: 34 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,39 @@
# Changelog

## [Unreleased]

### Changed

- `HybridKem::generate` now returns `Result<(DecapsulationKey,
EncapsulationKey), SealError>` instead of a bare tuple — **breaking**,
taken deliberately before the `preview-pq` API stabilizes (#16).
- `EncapsulationKey::encapsulate` and `seal_for` now return
`SealError::Entropy` if the OS entropy source fails, in addition to their
existing error paths.

### Fixed

- OS entropy failure no longer panics (#16). `rand_core` 0.6's
`OsRng::fill_bytes` panics on OS-RNG failure; every entropy draw — key
generation, encapsulation, and the AEAD nonce inside `seal_for` — now uses
`try_fill_bytes` and propagates as the new `SealError::Entropy { source:
rand_core::Error, location }`. A recoverable host entropy failure is now a
typed, auditable `Result`, not a process abort.

### Added

- `HybridKem::generate_with_rng`, `EncapsulationKey::encapsulate_with_rng`,
and `seal_for_with_rng`: the same operations with a caller-supplied
`&mut R: RngCore + CryptoRng`, the trait bound `x25519-dalek`'s own
`random_from_rng` requires (by reference here, so one RNG's state threads
through every draw in a call). The OS RNG cannot be made to fail on demand,
so this is what makes the entropy-failure path (above) testable at all —
proven in `tests/entropy_failure.rs` with an injected RNG that fails on
demand, including mid-batch inside `seal_for_with_rng` (no partial wrap set
is ever returned).
- `rand_core`'s `std` feature (alongside `getrandom`), so `rand_core::Error`
implements `std::error::Error` and chains behind `SealError::Entropy`.

## [0.1.2](https://github.com/forkwright/sphragis/compare/v0.1.1...v0.1.2) (2026-07-29)


Expand All @@ -20,8 +54,6 @@
* resolve all open audit findings (crypto correctness + zeroization) + lint-clean + Tier-U CI ([#5](https://github.com/forkwright/sphragis/issues/5)) ([3ddcf0e](https://github.com/forkwright/sphragis/commit/3ddcf0edbb7b21039edfc75a8e47e345eba54a47))
* **sphragis:** zeroize HKDF/sha2 digest state via the digest-0.11 generation ([#7](https://github.com/forkwright/sphragis/issues/7)) ([860d7f9](https://github.com/forkwright/sphragis/commit/860d7f95ca4c51868116fa3eabfe9a370a2d37e9))

## [Unreleased]

Audit-hardening pass (issues #1, #3, #4): error propagation, zeroization
coverage, parse-boundary validation, dependency hygiene, test coverage.
Follow-up (#6): HKDF/sha2 digest-state zeroization.
Expand Down
21 changes: 20 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,11 @@ sha3 = { version = "0.11", features = ["zeroize"], optional = true }
sha2 = { version = "0.11", features = ["zeroize"], optional = true }
hkdf = { version = "0.13", optional = true }
chacha20poly1305 = { version = "0.10", optional = true }
rand_core = { version = "0.6", features = ["getrandom"], optional = true }
# WHY: "std" (not just "getrandom") so `rand_core::Error` implements
# `std::error::Error` and can sit behind `SealError::Entropy`'s `source` field
# — without it, `Error` is the no_std variant (a bare error code, no source
# chain) and `.context(EntropySnafu)` does not compile.
rand_core = { version = "0.6", features = ["getrandom", "std"], optional = true }

# Post-quantum hybrid KEM stack (preview-pq only).
# WHY: released RustCrypto primitives, not the rc-pinned `x-wing` aggregate crate.
Expand Down Expand Up @@ -87,6 +91,9 @@ proptest = "1"
# hash-locked file (crypto-provenance.toml, tests/provenance_lock.rs).
serde_json = "1"
hex = "0.4"
# WHY: tests/entropy_failure.rs implements a mock RngCore to inject an
# on-demand entropy failure into the `_with_rng` seams (#16).
rand_core = { version = "0.6", features = ["getrandom"] }

# WHY(sphragis#23): the KAT gate calls the hazmat-only primitive surface
# directly (HybridKem, derive_wrap_key, direct encaps/decaps) to prove the
Expand All @@ -98,6 +105,18 @@ hex = "0.4"
name = "known_answer_vectors"
required-features = ["preview-pq", "hazmat"]

# WHY(sphragis#16, sphragis#23): proves the injectable-RNG entropy-failure
# path on the hazmat-only primitive surface directly (`HybridKem::
# generate_with_rng`, `EncapsulationKey::encapsulate_with_rng`) — the same
# reachability reasoning as `known_answer_vectors` above. The runtime logic
# under test is identical regardless of `hazmat` (the feature only toggles
# `pub` vs `pub(crate)` on the seam, never its body), so gating this target
# does not narrow what behavior CI's `preview-pq,hazmat` job actually
# exercises.
[[test]]
name = "entropy_failure"
required-features = ["preview-pq", "hazmat"]

[lints.rust]
unsafe_code = "forbid"
missing_docs = "warn"
Expand Down
46 changes: 46 additions & 0 deletions DECISION.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,3 +260,49 @@ 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.

## 10. Entropy failures are typed, not panics (#16)

`rand_core` 0.6's `OsRng::fill_bytes` panics on OS-RNG failure instead of
returning a `Result` — a transient host entropy failure would otherwise abort
the process rather than surface through `SealError`. Every entropy draw
(`HybridKem::generate`, `EncapsulationKey::encapsulate`, and the AEAD nonce
inside `seal_for`) now goes through `try_fill_bytes`, propagated as
`SealError::Entropy { source: rand_core::Error, location }`.

`HybridKem::generate` becomes fallible (`-> Result<(DecapsulationKey,
EncapsulationKey), SealError>`) — a breaking change taken now, before the
crate's `preview-pq` API stabilizes. `generate_recipient_keypair` (§9's
actual public entry point) becomes fallible with it, for the same reason:
it is a thin wrapper over `HybridKem::generate` and cannot swallow the
`Result` without either panicking (the exact defect this section fixes) or
silently discarding the OS-entropy-failure case its caller needs to see.

The RNG is caller-injectable at the primitive layer
(`HybridKem::generate_with_rng` / `EncapsulationKey::encapsulate_with_rng`,
each `<R: RngCore + CryptoRng>`) — the same trait bound `x25519-dalek`'s own
`random_from_rng` requires, taken by `&mut R` rather than by value so one
injected RNG's state carries across every draw in a call. Per §9's hazmat
boundary, both seams follow `HybridKem::generate`'s own split: `pub(crate)`
without `hazmat`, `pub` with it — an injectable RNG is a
conformance-testing affordance (`tests/entropy_failure.rs`), not something
a normal consumer needs, since `generate_recipient_keypair` always draws
fresh OS randomness. `EncapsulationKey::encapsulate` (the fixed-OsRng
convenience wrapper around `encapsulate_with_rng`) goes one step further and
is `hazmat`-only outright, with no `pub(crate)` variant: `seal_for` never
calls it — `seal_for_with_rng` is itself generic over the RNG and calls
`encapsulate_with_rng` directly — so a non-`hazmat` build has no internal
caller for it (`dead_code`, denied under `-D warnings`) and does not
compile it at all. The envelope layer gets its own seam instead:
`seal_for_with_rng` (not hazmat-gated, alongside `seal_for`) draws twice per
recipient across N recipients — the KEM encapsulation randomness and the
AEAD nonce — which is why it takes
`&mut R` rather than by-value: a by-value take-and-drop parameter would not
let one injected RNG's state carry across every draw in a multi-recipient
batch. This is not a cryptographic choice — the OS RNG cannot be made to
fail on demand, so injection is the only way to exercise the
entropy-failure branch under test at all; a failure mode with no test is an
unverified claim. `rand_core`'s `std` feature is enabled (in addition to
`getrandom`) so `rand_core::Error` implements `std::error::Error` and can
sit behind `SealError::Entropy`'s `source` field with a real chain, rather
than being flattened to a string.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ sphragis = { git = "https://github.com/forkwright/sphragis", features = ["previe
use sphragis::{generate_recipient_keypair, seal_for, unseal};

// Each device holds a keypair; publish the encapsulation (public) key.
let (dk, ek) = generate_recipient_keypair();
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 Down
15 changes: 15 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,21 @@ pub enum SealError {
reason: String,
},

/// The entropy source failed to supply randomness for key generation,
/// encapsulation, or nonce sampling.
// WHY: `OsRng::fill_bytes` panics on OS-RNG failure (rand_core 0.6
// `os.rs`); every call site uses the fallible `try_fill_bytes` and
// surfaces its error here instead, so a transient host entropy failure
// is a typed, recoverable `Result`, never a process abort.
#[snafu(display("entropy source failed: {source}"))]
Entropy {
/// The underlying RNG failure.
source: rand_core::Error,
/// Source location of the failed entropy call.
#[snafu(implicit)]
location: snafu::Location,
},

/// HKDF expansion failed (invalid output length request).
#[snafu(display("HKDF expand failed"))]
HkdfExpand,
Expand Down
Loading