Skip to content

feat(sphragis): narrow the public API to the envelope profile, define the adapter seam - #32

Merged
forkwright merged 3 commits into
mainfrom
fix/23-envelope-profile-boundary
Aug 15, 2026
Merged

forkwright merged 3 commits into
mainfrom
fix/23-envelope-profile-boundary

Conversation

@forkwright

Copy link
Copy Markdown
Owner

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_key from the normal public API, and by defining the seam
for an eventual upstream-X-Wing adapter.

What this PR does:

  • Adds a hazmat feature (the RustCrypto/rustls convention for exactly this
    shape of surface) that gates the generic hybrid-KEM primitive: HybridKem,
    direct EncapsulationKey::encapsulate/DecapsulationKey::decapsulate, and
    derive_wrap_key. Off by default; carries no stability promise; exists
    only so this crate's own known-answer/conformance tests can reach the
    primitive directly.
  • Adds generate_recipient_keypair() — the new stable, profile-level entry
    point for device-key creation, replacing HybridKem::generate() for a
    normal consumer.
  • Keeps EncapsulationKey/DecapsulationKey and their key-management
    operations (to_bytes/from_bytes, from_seed/to_seed,
    encapsulation_key) public — seal_for/unseal require them in their own
    signatures, and publishing/persisting a device key is a profile-level
    operation, not a primitive one.
  • DECISION.md ci: delegate gate-attestation to the fleet reusable #9 documents the module boundary (src/hybrid.rs is now the
    only 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-wing is still a release-candidate
    stack (DECISION.md Zeroize HKDF-SHA256 / sha2 digest state (hkdf 0.13 + sha2 0.11 coherent-generation migration) #6), so the local X-Wing transcription remains the
    implementation 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.
  • New tests/profile_api.rs: a normal-consumer round-trip
    (generate_recipient_keypairseal_forunseal) compiled with
    preview-pq alone, hazmat off — the proof that narrowing the API did not
    also narrow what a normal consumer can do.
  • A compile_fail doctest on HybridKem's non-hazmat declaration proves the
    hiding: it fails to compile under --features preview-pq (pre-fix
    behaviour: HybridKem::generate() was reachable and this snippet would
    have compiled) and is entirely absent — so never even attempted — under
    --features preview-pq,hazmat, since that cfg arm doesn't exist there.
  • CI (ci.yml) and the local gate (.kanon-ci.toml) both gain a
    preview-pq,hazmat check/clippy/test lane so tests/known_answer_vectors.rs
    keeps 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/main and stays off
files those two sibling lanes are actively editing:

Deliberate scope limit: SharedSecret's alias name

SharedSecret's type-alias visibility stays exactly as it is today (public,
reachable at sphragis::hybrid::SharedSecret) rather than being hazmat-gated
like 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 SharedSecret unconditionally — narrowing the alias's own
visibility would leak a private type through that method's still-fully-public
signature (private_interfaces, denied under -D warnings in CI), without
ever touching the method causing it. This is inert: no operation reachable
without hazmat (HybridKem::generate, direct encapsulate/decapsulate,
derive_wrap_key) can produce a real X-Wing-derived value of it, and
Zeroizing<[u8; 32]> — the concrete type this aliases — carries no capability
a consumer couldn't already construct directly from the public zeroize
crate. Documented inline in src/hybrid.rs at the declaration site.

Blast radius

Verified zero: akroasis pins sphragis via tag = "v0.1.1" in
[workspace.dependencies], but no member crate depends on it
(grep -rln sphragis --include Cargo.toml finds only the root pin) and no
.rs file references it.

Test plan

  • cargo fmt --all -- --check — clean.
  • CI: cargo check/clippy/test × {default, preview-pq, preview-pq+hazmat}
    — this box's local vgate admission is under heavy contention from
    concurrent 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.
  • New tests/profile_api.rs proves the narrowed API is still fully
    usable end-to-end without hazmat.
  • New compile_fail doctest proves HybridKem is unreachable without
    hazmat (fails pre-fix behaviour, passes post-fix).

Closes #23

forkwright 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
forkwright merged commit d0a0bb8 into main Aug 15, 2026
12 checks passed
@forkwright
forkwright deleted the fix/23-envelope-profile-boundary branch August 15, 2026 22:25
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
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>
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.

Make Sphragis a versioned envelope profile over upstream cryptographic primitives

1 participant