feat(sphragis): narrow the public API to the envelope profile, define the adapter seam - #32
Merged
Merged
Conversation
added 3 commits
August 15, 2026 17:04
… 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
…am 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
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
forkwright
pushed a commit
that referenced
this pull request
Aug 15, 2026
…hing HybridKem #32 (sphragis#23) narrowed the public API but left the `SharedSecret` type alias unconditionally `pub`, reachable via the `sphragis::hybrid::SharedSecret` module path even without `hazmat` -- unlike `HybridKem`, which #32 gated at the type-definition level (`pub(crate)` without `hazmat`, `pub` with it). The doc comment on the alias explained why: `EncapsulationKey:: encapsulate_deterministic` returned a `SharedSecret` unconditionally as a `pub` (doc-hidden) method, so narrowing the alias's visibility would have left it leaking through that method's signature (`private_interfaces`, denied under `-D warnings`). `encapsulate_deterministic` is now a private method (this branch, #17), so that blocker is gone. Complete the gating `HybridKem` already has: split the alias into `pub(crate)` (without `hazmat`) / `pub` (with `hazmat`) variants, and correct the doc comment, which cited the now-superseded public-method reasoning. Part of #17
This was referenced Aug 15, 2026
forkwright
added a commit
that referenced
this pull request
Aug 16, 2026
Closes #14 ## The defect `seal_for` distributes a content key to a recipient set. It has no memory of who has ever recovered that key, so re-running it over a smaller recipient list only changes who receives the *next* wrap — a recipient who already unsealed the key keeps it regardless. README.md and DECISION.md called that operation revocation anyway, including a "cheap revoke" that reused the same content key; `src/seal.rs`'s own module doc made the same claim. The prior `revocation_excludes_device` test never gave device 2 a wrap or let it recover the key, so it proved recipient omission, not revocation of a previously authorized device — exactly the gap the issue's evidence names. `kanon`'s `projects/sphragis/vision.md` makes the same claim ("revocation = re-run `seal_for` over the remaining recipients"). That file lives in a different repo and this PR does not touch it, but it is now stale planning prose and needs correcting on that side. ## What this PR adds A typed rotation protocol in `src/rotate.rs`, on the profile surface (no `hazmat`, per #32's narrowed public API) — a typestate chain that makes the stage ordering a compile error to violate: 1. **New key** — `generate_content_key()` (OS CSPRNG; `_with_rng` variant for injection, same pattern as `seal_for`/`seal_for_with_rng`). 2. **Publish new wraps** — `PendingRotation::begin(epoch, &new_key, &old_key)` then `.publish_wraps_for(&retained_recipients)`. `begin` rejects a new key equal to the old one (`SealError::ContentKeyUnchanged`), compared via `subtle::ConstantTimeEq` since both operands are secret material — this is the crate's first direct secret-vs-secret comparison, so `subtle` becomes a direct (optional, `preview-pq`-gated) dependency; DECISION.md §6 is corrected, it previously stated the crate needed none. 3. **Switch the epoch** — `PublishedWraps::commit()`. 4. **Retire the old key** — `CommittedEpoch::retire_old_key(old_key)`, which drops (zeroizes) the orchestrating caller's own copy. Wire-compatible: rotation calls the same `seal_for_with_rng` internals `seal_for` does, so `WrappedContentKey`'s CBOR shape and version don't move. ## The adversarial test `tests/rotation.rs::rotation_actually_revokes_a_device_that_held_the_old_key`: - Device 2 is provisioned alongside device 1, and **actually recovers the old content key** via `unseal` (not just issued a wrap — genuinely holds the secret). - Device 2's recovered key decrypts a payload encrypted under the old content key (the "still holds access to what it already had" half). - Rotation runs, retaining device 1 only. - **The load-bearing assertion:** device 2's old key, used against a payload encrypted under the *new* content key, fails to decrypt. Device 2 has no wrap in the new epoch (`published.wraps().len() == 1`) is asserted too, but the comment marks it explicitly as the weaker, already-true property — the failing decrypt is what makes this a revocation test rather than a recipient-omission test. - Device 1 continues to work against the new epoch (happy path). `begin_rejects_unchanged_content_key` and `full_protocol_reaches_rotation_complete` cover the guard and the typestate's happy path. ## What rotation does not protect — stated plainly, not left implicit Ciphertext already written under the old content key stays readable by anyone holding that key, **forever**. Rotation cannot retract a secret from memory it does not control; it protects data written *after* the epoch switch, not data written before it. This is in the module doc (top of `src/rotate.rs`), README.md, and DECISION.md §11 — not just this PR body — because a rotation API that let a reader believe it was retroactive would be a worse failure than the mis-naming this issue exists to fix. ## Design decisions the issue left open (surfaced, not chosen silently) - **Does rotation re-encrypt existing payloads, or only protect data going forward?** Forward-only. Sphragis wraps content keys; it has never touched payload data and this PR doesn't change that — there is nothing for it to re-encrypt. If a consumer wants old data protected too, that's their own operation against their own store. This is the conservative reading, chosen explicitly and stated in the module doc rather than left for a reader to assume either way. - **Who allocates `EpochId`?** The caller. Sphragis holds no state across calls, so it cannot allocate or validate a monotonic sequence — that already belongs to whatever store tracks "which wrap set is current." `EpochId` is an opaque `u64` carried through the typestate chain unmodified. - **What does "atomically switch the epoch" mean for a crate with no storage?** The consumer's own store transaction is the only thing that can make it atomic. `PublishedWraps::commit()` doesn't perform that transaction — what it guarantees is ordering: the type system refuses a `CommittedEpoch` (and therefore refuses `retire_old_key`) until `commit()` is called, so the old key can't be destroyed before the caller has at least acknowledged the new epoch is durably live. This is stated explicitly in `commit()`'s doc comment rather than implied. ## Conformance `#[non_exhaustive]` `SealError` gains one variant (`ContentKeyUnchanged`, `snafu` + `.context()`/`#[snafu(implicit)] location`, matching every existing variant). No `unwrap`/`expect` in library code. No `as` casts. No indexing/slicing (`tests/rotation.rs` uses `.get()`/`.first()` throughout). `subtle::ConstantTimeEq` for the one secret comparison. `#![deny(missing_docs)]` holds; every public fallible fn has `# Errors`. `cargo fmt --check` is clean. ## Build reality / observed state metis is saturated by concurrent dispatch work (`uptime` load average 11–16 against 8 cores) for the duration of this session; a local `VGATE_CORE_BUDGET=6 cargo check --features preview-pq,hazmat --tests` sat queued at vgate admission the entire time and produced no output — never admitted, not a failure. `cargo fmt --check` (no admission needed) is clean on every changed file. I have not observed a green compile/test/clippy locally; CI on this push is the first real signal — check the Actions run on this PR before treating it as verified. --------- 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
sphragis#23 asks Sphragis to earn authority as a versioned, multi-recipient
content-key envelope — not as a generic X-Wing/KEM primitive library — by
hiding
HybridKem,EncapsulationKey,DecapsulationKey,SharedSecret,and
derive_wrap_keyfrom the normal public API, and by defining the seamfor an eventual upstream-X-Wing adapter.
What this PR does:
hazmatfeature (the RustCrypto/rustls convention for exactly thisshape of surface) that gates the generic hybrid-KEM primitive:
HybridKem,direct
EncapsulationKey::encapsulate/DecapsulationKey::decapsulate, andderive_wrap_key. Off by default; carries no stability promise; existsonly so this crate's own known-answer/conformance tests can reach the
primitive directly.
generate_recipient_keypair()— the new stable, profile-level entrypoint for device-key creation, replacing
HybridKem::generate()for anormal consumer.
EncapsulationKey/DecapsulationKeyand their key-managementoperations (
to_bytes/from_bytes,from_seed/to_seed,encapsulation_key) public —seal_for/unsealrequire them in their ownsignatures, and publishing/persisting a device key is a profile-level
operation, not a primitive one.
DECISION.mdci: delegate gate-attestation to the fleet reusable #9 documents the module boundary (src/hybrid.rsis now theonly place that performs a raw KEM operation) as the adapter seam a
future migration would use, and is explicit that this PR does not
perform that migration: upstream
x-wingis still a release-candidatestack (
DECISION.mdZeroize HKDF-SHA256 / sha2 digest state (hkdf 0.13 + sha2 0.11 coherent-generation migration) #6), so the local X-Wing transcription remains theimplementation until a stable, audited release meets the existing
migration gate. Building the seam now and gating the migration on
upstream stabilizing is the honest scope here, per the task brief.
tests/profile_api.rs: a normal-consumer round-trip(
generate_recipient_keypair→seal_for→unseal) compiled withpreview-pqalone,hazmatoff — the proof that narrowing the API did notalso narrow what a normal consumer can do.
compile_faildoctest onHybridKem's non-hazmat declaration proves thehiding: it fails to compile under
--features preview-pq(pre-fixbehaviour:
HybridKem::generate()was reachable and this snippet wouldhave compiled) and is entirely absent — so never even attempted — under
--features preview-pq,hazmat, since that cfg arm doesn't exist there.ci.yml) and the local gate (.kanon-ci.toml) both gain apreview-pq,hazmatcheck/clippy/test lane sotests/known_answer_vectors.rskeeps compiling and running unmodified (see below) instead of silently
going unbuilt.
Sequencing (sphragis#17, sphragis#18)
Per the task brief, this rebases onto current
origin/mainand stays offfiles those two sibling lanes are actively editing:
src/lib.rs/src/envelope.rs: zero overlap — neither Keep deterministic encapsulation out of the safe public API #17 nor Make the cryptographic acceptance gate execute every claimed KAT and bind its provenance #18touches these files.
src/hybrid.rs: Keep deterministic encapsulation out of the safe public API #17 (unmerged, no PR yet) touches two disjoint regions— the
EncapsulationKeydoc comment andencapsulate_deterministic'ssignature/body, appending an inline KAT test at EOF. This PR's edits
(
SharedSecret's doc,HybridKem,HybridKem::generate,EncapsulationKey::encapsulate,DecapsulationKey::decapsulate) sitoutside those hunks. Deferred:
encapsulate_deterministic's ownvisibility is left exactly as on
main(#[doc(hidden)] pub fn) — Keep deterministic encapsulation out of the safe public API #17 isalready privatizing it directly; touching the same lines here would be
fighting for the same file for no benefit, since Keep deterministic encapsulation out of the safe public API #17 lands a strictly
tighter result (fully private, not hazmat-gated).
tests/known_answer_vectors.rs: Keep deterministic encapsulation out of the safe public API #17 removes/relocates one test there;Make the cryptographic acceptance gate execute every claimed KAT and bind its provenance #18 (PR fix(sphragis): bind the KAT gate to a machine-readable crypto provenance lock #27, mid-rebase) rewrites ~270 lines of it (ACVP vectors,
provenance-lock helpers). This PR does not touch that file at all — instead,
Cargo.tomlgains a[[test]] required-features = ["preview-pq", "hazmat"]entry for it, so it silently skips under
--features preview-pq(nohazmat) instead of failing to compile, and keeps compiling/running
byte-for-byte unmodified under
--features preview-pq,hazmat— itsexisting calls to
HybridKem::generate(),.encapsulate(),.decapsulate(),derive_wrap_key()all still resolve because thoseitems are genuinely
pubin that feature combination.DECISION.md: Make the cryptographic acceptance gate execute every claimed KAT and bind its provenance #18 edits section 7 (lines ~185-199, inside its ownhunk). This PR's new section 9 is appended after section 8, at the file's
end — disjoint from that hunk.
Cargo.toml: Make the cryptographic acceptance gate execute every claimed KAT and bind its provenance #18 appends two[dev-dependencies](serde_json,hex) afterproptest = "1". This PR'shazmatfeature line sits in the[features]block near the top, and the new[[test]]block sits rightbefore
[lints.rust]— both disjoint from Make the cryptographic acceptance gate execute every claimed KAT and bind its provenance #18's dev-dependency hunk.README.md: Make the cryptographic acceptance gate execute every claimed KAT and bind its provenance #18 inserts a paragraph after the "Testing" code block(~line 56+). This PR edits the earlier "Usage" example and the "Features"
list (~lines 31-51) — disjoint.
AGENTS.md,llms.txt— both listtests/contents in a region Make the cryptographic acceptance gate execute every claimed KAT and bind its provenance #18 is actively rewriting for its own new files
(
provenance_lock.rs,vectors/,crypto-provenance.toml). Left for afollow-up doc-sync pass once Keep deterministic encapsulation out of the safe public API #17/Make the cryptographic acceptance gate execute every claimed KAT and bind its provenance #18/Make Sphragis a versioned envelope profile over upstream cryptographic primitives #23 have all landed, rather than
risk a three-way collision on the same lines.
Deliberate scope limit:
SharedSecret's alias nameSharedSecret's type-alias visibility stays exactly as it is today (public,reachable at
sphragis::hybrid::SharedSecret) rather than being hazmat-gatedlike
HybridKem/derive_wrap_key/direct encaps-decaps. Reason:EncapsulationKey::encapsulate_deterministic(pre-existing,#[doc(hidden)],unrelated to this issue and explicitly left untouched per the sequencing
above) returns
SharedSecretunconditionally — narrowing the alias's ownvisibility would leak a private type through that method's still-fully-public
signature (
private_interfaces, denied under-D warningsin CI), withoutever touching the method causing it. This is inert: no operation reachable
without
hazmat(HybridKem::generate, directencapsulate/decapsulate,derive_wrap_key) can produce a real X-Wing-derived value of it, andZeroizing<[u8; 32]>— the concrete type this aliases — carries no capabilitya consumer couldn't already construct directly from the public
zeroizecrate. Documented inline in
src/hybrid.rsat the declaration site.Blast radius
Verified zero: akroasis pins
sphragisviatag = "v0.1.1"in[workspace.dependencies], but no member crate depends on it(
grep -rln sphragis --include Cargo.tomlfinds only the root pin) and no.rsfile references it.Test plan
cargo fmt --all -- --check— clean.cargo check/clippy/test× {default, preview-pq, preview-pq+hazmat}— this box's local
vgateadmission is under heavy contention fromconcurrent sessions and did not complete a full local pass before push;
per this repo's build-reality guidance, CI is the reliable verifier
here and a prior agent on this exact repo correctly withheld a PR
rather than ship unverified crypto without it — this PR relies on the
GitHub Actions run rather than a claimed-clean local build.
tests/profile_api.rsproves the narrowed API is still fullyusable end-to-end without
hazmat.compile_faildoctest provesHybridKemis unreachable withouthazmat(fails pre-fix behaviour, passes post-fix).Closes #23