Skip to content

fix(sphragis): return typed entropy failures instead of panicking - #28

Merged
forkwright merged 5 commits into
mainfrom
fix/16-entropy-error
Aug 15, 2026
Merged

forkwright merged 5 commits into
mainfrom
fix/16-entropy-error

Conversation

@forkwright

@forkwright forkwright commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Summary

OsRng.fill_bytes panics on OS-RNG failure (rand_core 0.6 os.rs), and this crate called it directly at three sites: HybridKem::generate, EncapsulationKey::encapsulate, and the AEAD nonce draw inside seal_for. A recoverable host entropy failure (an early-boot RNG-not-seeded window, a sandboxed/misconfigured environment getrandom cannot service) crashed the process instead of returning a SealError.

Every entropy draw now goes through try_fill_bytes and propagates as a new SealError::Entropy { source: rand_core::Error, location }. HybridKem::generate becomes fallible. The RNG is caller-injectable at each site (generate_with_rng, encapsulate_with_rng, seal_for_with_rng, each &mut R: RngCore + CryptoRng) — the trait bound x25519-dalek's own random_from_rng requires — because the OS RNG cannot be made to fail on demand and this is the only way to prove the typed-error path under test. rand_core's std feature is now enabled (alongside getrandom) so rand_core::Error implements std::error::Error and chains behind SealError::Entropy's source field rather than being flattened to a string.

Closes #16

Breaking change

HybridKem::generate() -> (DecapsulationKey, EncapsulationKey) is now HybridKem::generate() -> Result<(DecapsulationKey, EncapsulationKey), SealError>. Taken deliberately, per the issue, before the preview-pq API stabilizes. akroasis consumes sphragis via an unpinned bare git = reference and will take this break on its next build.

EncapsulationKey::encapsulate and seal_for keep their existing Result<_, SealError> signatures — adding SealError::Entropy to what they can return is non-breaking against #[non_exhaustive].

What I verified, not just assumed

  • rand_core::Error does not implement std::error::Error under this crate's prior feature set (getrandom only, no std) — confirmed by reading rand_core-0.6.4/src/error.rs, where the impl std::error::Error for Error block is #[cfg(feature = "std")]-gated off. Enabling std (additive, no new transitive dependencies — Cargo.lock is unchanged) was necessary before SealError::Entropy's source field could compile against .context().
  • The injected-RNG bound is &mut R rather than x25519-dalek's by-value T, deliberately: seal_for_with_rng draws twice per recipient across N recipients from the same RNG, and a mid-batch injected-failure test needs that RNG's state to survive across draws, which a take-and-drop-per-call parameter would not allow.

Test evidence (the failing-test-before)

The OS RNG cannot be made to fail on demand, so there is no way to git-bisect a literal "same test, red before / green after" against main — the injectable seam this PR adds is what makes the defect observable at all. tests/entropy_failure.rs's injected CountdownRng (succeeds N calls, then fails every call after) drives generate_with_rng, encapsulate_with_rng, and seal_for_with_rng — including mid-batch across two recipients, and specifically isolating the nonce draw from the encapsulation draw inside seal_for_with_rng — asserting every case returns Err(SealError::Entropy { .. }) with a call-site-accurate location, never panics.

The mock's own fill_bytes (the trait's infallible method, distinct from the try_fill_bytes production code calls) is written to .expect()-panic if anything ever calls it — mirroring exactly what real OsRng::fill_bytes does on failure — so it doubles as a live check that production code has not regressed onto the panicking path: if any _with_rng call site were reverted to .fill_bytes(), generate_with_rng_returns_entropy_error_not_panic (which injects a zero-succeeds CountdownRng) would abort with CountdownRng: production code must use try_fill_bytes, not fill_bytes instead of returning Err.

cargo build --features preview-pq and cargo fmt --all -- --check were confirmed clean locally before the first push. metis was under sustained heavy load from a concurrent fleet-wide push at the time (uptime load average in the 20s-30s against an 8-core box, vgate admits nothing above ~7.2), so the full local cargo clippy --all-targets --features preview-pq -- -D warnings / cargo test --features preview-pq run never got admitted before I pushed — CI (ci.yml + the gate/full-gate-build reusable workflow, both GitHub-hosted ubuntu-latest, uncontended) is what actually ran both, and caught three real findings the local cargo build-only check couldn't have: clippy::similar_names (the new rng parameter next to a pre-existing local named rnd) and two more clippy::expect_used/clippy::panic misses in the new test file itself (this crate's [lints.clippy] denies those crate-wide, including tests/*.rs, and I'd only carried over unwrap_used's file-level #![expect] from the existing test files' convention, not the other two). All fixed in follow-up commits on this branch; CI is green as of the current HEAD.

Also fixed inline (adjacent, not scope creep)

CHANGELOG.md had a stale ## [Unreleased] heading sitting below the already-released 0.1.2/0.1.1 sections — its rich prose documented commits already shipped in 0.1.1 (issues #1/#3/#4/#6), mislabeled as unreleased. Removed the stale heading (the prose now reads as 0.1.1 detail, where it belongs) and opened a fresh ## [Unreleased] at the top for this change.

Filed, not bundled

Found two adjacent issues while in this code and filed them separately rather than growing this diff:

@forkwright
forkwright force-pushed the fix/16-entropy-error branch 2 times, most recently from 31e8e57 to 284b679 Compare August 15, 2026 22:49
@forkwright

Copy link
Copy Markdown
Owner Author

Rebased onto current main (now carrying #24 bounded-CBOR, #27 KAT provenance lock, #32 public-API narrowing). Conflicts in Cargo.toml, DECISION.md, README.md, src/hybrid.rs, src/lib.rs resolved by hand; src/seal.rs/tests/known_answer_vectors.rs auto-merged.

Two coherence decisions this rebase had to make, since #32 landed after this branch was written:

DECISION.md §9/§10 and README.md's example updated to describe the merged state accurately, not the pre-#32 one. CI green on the current head (cargo test/check/clippy × default/preview-pq/preview-pq,hazmat, gate/full-gate-build, security scans).

forkwright added 5 commits August 15, 2026 18:04
OsRng.fill_bytes panics on OS-RNG failure (rand_core 0.6 os.rs); this crate
called it directly in HybridKem::generate, EncapsulationKey::encapsulate,
and the AEAD nonce draw inside seal_for. A recoverable host entropy failure
crashed the process instead of returning a SealError, and the two already-
fallible operations had no error variant covering it.

Every entropy draw now goes through try_fill_bytes and propagates as a new
SealError::Entropy { source: rand_core::Error, location }. HybridKem::generate
becomes fallible (a breaking pre-1.0 change, taken now per the issue, before
the preview-pq API stabilizes). The RNG is caller-injectable at each site
(generate_with_rng, encapsulate_with_rng, seal_for_with_rng, each
&mut R: RngCore + CryptoRng) because the OS RNG cannot be made to fail on
demand and this is the only way to prove the typed-error path under test.
rand_core's std feature is enabled (alongside getrandom) so rand_core::Error
implements std::error::Error and chains behind SealError::Entropy's source
field.

tests/entropy_failure.rs injects a CountdownRng (succeeds N calls, then
fails every call after) through all three seams, including mid-batch across
two recipients inside seal_for_with_rng and specifically isolating the nonce
draw from the encapsulation draw, and asserts every case returns
Err(SealError::Entropy) with a call-site-accurate location, never panics.

Also: removed a stale CHANGELOG.md "Unreleased" heading that sat below the
already-released 0.1.2/0.1.1 sections describing commits shipped in 0.1.1.

Part of #16
clippy::similar_names (promoted to error by -D warnings) flagged the
64-byte randomness buffer 'rnd' against the new 'rng' CSPRNG parameter.
Renamed to 'randomness', matching encapsulate_deterministic's own
parameter name for the same buffer.

Part of #16
…ilure.rs

The crate's [lints.clippy] denies unwrap_used/expect_used/panic crate-wide,
including tests/*.rs (matching every other integration test file's own
#![expect(...)] overrides, not a per-target exemption). Missed two of the
three needed here: CountdownRng::fill_bytes deliberately expect()-panics
(mirroring OsRng's real behavior — the point of the mock), and several
test bodies use panic!/an unmatched-arm panic! the same way assert! does
internally. Also swapped a match-with-empty-arm for let-else in the last
test, avoiding a possible clippy::single_match_else round-trip.

Part of #16
…lic API

Rebasing fix/16-entropy-error onto main (which now carries #23's hazmat
narrowing) surfaced two defects the merge itself introduced, both caught by
the preview-pq (no hazmat) CI job:

- generate_recipient_keypair became fallible along with HybridKem::generate,
  but tests/profile_api.rs (added by #23, exercises the narrowed API without
  hazmat) still called it as if infallible. Added the missing .unwrap() at
  each of its three call sites; each test already carries a file-scoped
  #[expect(clippy::unwrap_used, ...)].

- EncapsulationKey::encapsulate's non-hazmat pub(crate) variant lost its
  only caller: seal_for now delegates to seal_for_with_rng, which is generic
  over the RNG and calls encapsulate_with_rng directly, never the fixed-OsRng
  wrapper. Without hazmat that left encapsulate() genuinely unused inside the
  crate -- dead_code, denied under -D warnings. Made it hazmat-only outright
  (no pub(crate) variant), matching its real reachability: its only consumer
  is tests/known_answer_vectors.rs, which already requires hazmat.

DECISION.md corrected to match: it had described encapsulate_with_rng's
hazmat split as mirroring encapsulate's, which stopped being true once
encapsulate lost its non-hazmat variant.

Part of #16
"matching HybridKem's gating above" was wrong: HybridKem's pub(crate)/pub
split is declared after SharedSecret's in this file, not before it.
Reworded to avoid a positional claim that silently goes stale if either
declaration moves again.

Part of #16
@forkwright
forkwright force-pushed the fix/16-entropy-error branch from 55213ae to 3bf69ad Compare August 15, 2026 23:06
@forkwright

Copy link
Copy Markdown
Owner Author

Rebased onto current main (now carrying merged #31). No textual conflicts — #31's changes (the SharedSecret pub(crate)/pub split, privatizing encapsulate_deterministic, relocating the KAT test) and this branch's changes (the generate/generate_with_rng/encapsulate_with_rng split, encapsulate's hazmat-only gating) sit in non-overlapping regions of src/hybrid.rs.

Checked the two specific things flagged for this rebase, by reading the merged file:

  1. No duplicate/contradictory hazmat split. generate_with_rng/encapsulate_with_rng's split is not redundant with fix(sphragis)!: make deterministic encapsulation private #31's SharedSecret split — it's a different axis (method reachability vs. type reachability) and the two are now mutually dependent: encapsulate_with_rng returns a SharedSecret, and since fix(sphragis)!: make deterministic encapsulation private #31 made that type pub(crate) without hazmat, an unconditionally-pub encapsulate_with_rng would now be a private_interfaces violation (a public fn leaking a pub(crate) type). The split I added is load-bearing for that reason, not just convention-following. generate_with_rng doesn't touch SharedSecret at all — its split exists solely to match HybridKem's own reachability (a method on a pub(crate)-without-hazmat type). One coherent story: every seam's visibility tracks the visibility of the types it touches.
  2. EncapsulationKey::encapsulate stays hazmat-only, and the premise still holds. fix(sphragis)!: make deterministic encapsulation private #31 didn't touch src/seal.rs at all — seal_for_with_rng still calls ek.encapsulate_with_rng(rng) directly, never ek.encapsulate(), so the fixed-OsRng wrapper still has zero internal callers without hazmat. fix(sphragis)!: make deterministic encapsulation private #31's relocated KAT test (in src/hybrid.rs's own mod tests) calls encapsulate_deterministic and decapsulate directly, not encapsulate() either. tests/known_answer_vectors.rs (still hazmat-required) is still encapsulate's only caller, at 4 call sites, unchanged. Nothing reopened the reachability gap.

Also fixed a small positional error in the SharedSecret doc comment from my own #31 rebase work — it said "matching HybridKem's gating above," but HybridKem's split is declared after SharedSecret's in the file, not before.

CI green on the current head. Not merging — leaving that to you.

@forkwright
forkwright merged commit 2ff3a08 into main Aug 15, 2026
12 checks passed
@forkwright
forkwright deleted the fix/16-entropy-error branch August 15, 2026 23:10
forkwright added a commit that referenced this pull request Aug 15, 2026
…ation (#33)

## Summary

- `SharedSecret` was a bare `pub type SharedSecret = Zeroizing<[u8;
32]>` alias, so it inherited `[u8; 32]`'s derived `Debug` — `{:?}`
printed the live X-Wing shared secret. `Zeroizing` protects memory on
drop, not the value while it is alive. Replaced with a newtype carrying
a manual, redacting `Debug` (`"SharedSecret([REDACTED])"`), matching
`DecapsulationKey`'s existing pattern. Zeroize-on-drop is unchanged (the
newtype still wraps `Zeroizing`). An `as_slice()` accessor preserves
every existing call site (`derive_wrap_key`, the KAT assertions) — no
behavior change beyond the `Debug` surface.
- Finishes the `#[snafu(implicit)] location` retrofit onto `SealError`
that #16/#28 started: every remaining variant (`WrongLength`,
`InvalidMlKem`, `HkdfExpand`, `AeadSeal`, `AeadOpen`,
`UnsupportedVersion`, `Serialization`, `EnvelopeTooLarge`,
`TrailingData`) now carries a location, and every construction site that
built the enum directly (`map_err` closures, a bare `return Err(..)`)
now goes through its context selector (`.context()` / `.build()` /
`.fail()`) so the field actually populates.

## Tests

- New `shared_secret_debug_is_redacted`
(`tests/known_answer_vectors.rs`): formats a real `SharedSecret`
obtained from `encapsulate()`, asserts the output contains `"REDACTED"`
and does **not** contain the hex-encoded secret bytes. This is the
mandatory negative-case fixture — a type-compiles check would prove
nothing.
- Extended `wrong_length_ek_and_ct_rejected` to capture one
`SealError::WrongLength` and assert
`location.file.ends_with("hybrid.rs")`, mirroring the pattern
`tests/entropy_failure.rs` already uses for `SealError::Entropy`.
- All existing KAT / round-trip / negative tests are unchanged in
behavior; `SealError` construction now goes through context selectors
instead of struct literals, but every variant's fields (other than the
new `location`) are identical.

## Notes

- Adjudicated #26 vs #29 (duplicate SealError-location findings)
separately; this PR closes out #26's scope. See the issue comments for
the duplicate ruling.

Closes #25
Closes #26

---------

Co-authored-by: forkwright <cody@forkwright.com>
forkwright pushed a commit that referenced this pull request Aug 16, 2026
🤖 I have created a release *beep* *boop*
---


##
[0.2.0](v0.1.2...v0.2.0)
(2026-08-16)


### ⚠ BREAKING CHANGES

* **sphragis:** make deterministic encapsulation private
([#31](#31))

### Features

* **sphragis:** define revocation as key rotation, not re-wrapping
([#34](#34))
([37ba8e3](37ba8e3))
* **sphragis:** narrow the public API to the envelope profile, define
the adapter seam
([#32](#32))
([d0a0bb8](d0a0bb8))


### Bug Fixes

* **sphragis:** bind the KAT gate to a machine-readable crypto
provenance lock
([#27](#27))
([65e3433](65e3433))
* **sphragis:** bound and fully consume untrusted CBOR before accepting
a wrapped key ([#24](#24))
([d165e5b](d165e5b))
* **sphragis:** make deterministic encapsulation private
([#31](#31))
([cf48668](cf48668))
* **sphragis:** redact SharedSecret's Debug and retrofit SealError
location ([#33](#33))
([11216eb](11216eb))
* **sphragis:** return typed entropy failures instead of panicking
([#28](#28))
([2ff3a08](2ff3a08))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Return typed entropy failures instead of panicking in cryptographic operations

1 participant