feat(sphragis): define revocation as key rotation, not re-wrapping - #34
Merged
Merged
Conversation
added 2 commits
August 15, 2026 19:07
seal_for distributes a content key to a recipient set; it has no memory of who has ever recovered one, so re-running it over a smaller list cannot revoke a device that already unsealed the key. rotate.rs adds the operation that actually does: a typestate chain (PendingRotation -> PublishedWraps -> CommittedEpoch -> RotationComplete) that forces new key -> publish wraps for the retained set -> commit the new epoch -> retire the old key, in that order, as a compile error to violate otherwise. PendingRotation::begin rejects a new content 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 (preview-pq-gated, optional) dependency instead of a transitive one. Rotation cannot retract a secret from a device's memory: ciphertext already written under the old content key stays readable by anyone holding that key, forever. The module doc, README, and DECISION.md say this plainly rather than leaving a reader to assume rotation is retroactive. tests/rotation.rs is the adversarial proof: a device that recovers the old content key before rotation runs remains able to decrypt data already protected under it, and fails to decrypt data protected under the completed new epoch -- the property the issue's evidence found no existing test modeled. Part of #14
README.md and DECISION.md called re-running seal_for over a smaller recipient list revocation, including a "cheap revoke" that reused the same content key. src/seal.rs's own module doc made the same claim. A device that has ever unsealed a content key retains it regardless of whether a later seal_for call addresses it, so this described a property seal_for does not have -- the security-contract failure issue #14's evidence points at directly. Reword all three to describe seal_for as recipient-key distribution, and point at the rotate module (previous commit) for the operation that actually revokes. DECISION.md gains a new section 11 recording the rotation design decisions the issue left open: forward-only scope (no payload re-encryption), caller-supplied EpochId, and what "atomically switch the epoch" can and cannot mean for a crate with no storage of its own. Section 6's dependency table is corrected: it is no longer true that the crate has no direct subtle dependency. Two existing tests in tests/known_answer_vectors.rs are renamed and redoc'd to stop calling recipient omission "revocation" -- revocation_excludes_device (a device that was never issued a wrap in the first place was never going to keep one either, so it never modeled a revoked device) and the "full revocation" doc comment on the empty-recipient-list test. Both now point at tests/rotation.rs for the property an actual revocation test has to prove. Part of #14
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.
Closes #14
The defect
seal_fordistributes a content key to a recipient set. It has no memory ofwho 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 priorrevocation_excludes_devicetest never gave device 2 a wrap or let itrecover the key, so it proved recipient omission, not revocation of a
previously authorized device — exactly the gap the issue's evidence names.
kanon'sprojects/sphragis/vision.mdmakes the same claim("revocation = re-run
seal_forover the remaining recipients"). That filelives 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 (nohazmat, per #32's narrowed public API) — a typestate chain that makes thestage ordering a compile error to violate:
generate_content_key()(OS CSPRNG;_with_rngvariantfor injection, same pattern as
seal_for/seal_for_with_rng).PendingRotation::begin(epoch, &new_key, &old_key)then
.publish_wraps_for(&retained_recipients).beginrejects a new keyequal to the old one (
SealError::ContentKeyUnchanged), compared viasubtle::ConstantTimeEqsince both operands are secret material — thisis the crate's first direct secret-vs-secret comparison, so
subtlebecomes a direct (optional,
preview-pq-gated) dependency; DECISION.md§6 is corrected, it previously stated the crate needed none.
PublishedWraps::commit().CommittedEpoch::retire_old_key(old_key), whichdrops (zeroizes) the orchestrating caller's own copy.
Wire-compatible: rotation calls the same
seal_for_with_rnginternalsseal_fordoes, soWrappedContentKey's CBOR shape and version don't move.The adversarial test
tests/rotation.rs::rotation_actually_revokes_a_device_that_held_the_old_key:old content key via
unseal(not just issued a wrap — genuinely holdsthe secret).
content key (the "still holds access to what it already had" half).
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.
begin_rejects_unchanged_content_keyandfull_protocol_reaches_rotation_completecover 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)
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.
EpochId? The caller. Sphragis holds no state acrosscalls, so it cannot allocate or validate a monotonic sequence — that
already belongs to whatever store tracks "which wrap set is current."
EpochIdis an opaqueu64carried through the typestate chainunmodified.
storage? The consumer's own store transaction is the only thing that
can make it atomic.
PublishedWraps::commit()doesn't perform thattransaction — what it guarantees is ordering: the type system refuses a
CommittedEpoch(and therefore refusesretire_old_key) untilcommit()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]SealErrorgains one variant (ContentKeyUnchanged,snafu+.context()/#[snafu(implicit)] location, matching everyexisting variant). No
unwrap/expectin library code. Noascasts. Noindexing/slicing (
tests/rotation.rsuses.get()/.first()throughout).subtle::ConstantTimeEqfor the one secret comparison.#![deny(missing_docs)]holds; every public fallible fn has
# Errors.cargo fmt --checkis clean.Build reality / observed state
metis is saturated by concurrent dispatch work (
uptimeload average11–16 against 8 cores) for the duration of this session; a local
VGATE_CORE_BUDGET=6 cargo check --features preview-pq,hazmat --testssatqueued at vgate admission the entire time and produced no output — never
admitted, not a failure.
cargo fmt --check(no admission needed) is cleanon 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.