Skip to content

feat(sphragis): define revocation as key rotation, not re-wrapping - #34

Merged
forkwright merged 2 commits into
mainfrom
feat/14-key-rotation
Aug 16, 2026
Merged

forkwright merged 2 commits into
mainfrom
feat/14-key-rotation

Conversation

@forkwright

Copy link
Copy Markdown
Owner

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 keygenerate_content_key() (OS CSPRNG; _with_rng variant
    for injection, same pattern as seal_for/seal_for_with_rng).
  2. Publish new wrapsPendingRotation::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 epochPublishedWraps::commit().
  4. Retire the old keyCommittedEpoch::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.

forkwright 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
forkwright merged commit 37ba8e3 into main Aug 16, 2026
12 checks passed
@forkwright
forkwright deleted the feat/14-key-rotation branch August 16, 2026 00:12
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.

Define revocation as key rotation, not re-wrapping a key the revoked device already knows

1 participant