Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,14 @@ src/
lib.rs — public API surface + version/domain constants
hybrid.rs — X-Wing KEM (X25519 + ML-KEM-768) over released RustCrypto primitives
envelope.rs — HKDF-SHA256 key derivation + ChaCha20-Poly1305 seal/open
seal.rs — multi-recipient WrappedContentKey sealing API
seal.rs — multi-recipient WrappedContentKey sealing API (key DISTRIBUTION, not revocation)
rotate.rs — typed key-rotation protocol (actual device revocation, sphragis#14)
error.rs — SealError (snafu)
tests/
known_answer_vectors.rs — X-Wing KAT, FIPS-203 ML-KEM-768 ACVP KAT, RFC KATs,
round-trip, negatives
rotation.rs — adversarial revocation proof: a device holding the
old content key fails to read the completed new epoch
provenance_lock.rs — enforces crypto-provenance.toml against Cargo.lock
and the vendored vector files
vectors/ — vendored, hash-locked upstream vector fixtures
Expand All @@ -43,3 +46,8 @@ Unaudited preview. All crypto behind `preview-pq`. First consumer: akroasis
ss_m/ss_x/ct_x/pk_x mirror X-Wing spec notation; suppression is intentional.
- ML-KEM 0.3.2 pulls `rand_core 0.10` transitively; x25519-dalek 2.0.1 uses
`rand_core 0.6` at call sites. The two majors coexist.
- `seal_for` is recipient-key **distribution**, not revocation — a recipient
who ever unsealed a content key keeps it regardless of a later `seal_for`
call omitting them. `rotate` is the actual revocation protocol, and it
cannot retroactively protect ciphertext already written under the key it
replaces (sphragis#14; see DECISION.md §11 and `tests/rotation.rs`).
10 changes: 10 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ preview-pq = [
"dep:hkdf",
"dep:chacha20poly1305",
"dep:rand_core",
"dep:subtle",
]
# WHY(sphragis#23): reachability, not a dependency set — no crypto deps of its
# own. Ships the generic X-Wing/ML-KEM primitive surface (HybridKem, raw
Expand Down Expand Up @@ -61,6 +62,15 @@ chacha20poly1305 = { version = "0.10", optional = true }
# — without it, `Error` is the no_std variant (a bare error code, no source
# chain) and `.context(EntropySnafu)` does not compile.
rand_core = { version = "0.6", features = ["getrandom", "std"], optional = true }
# WHY: `rotate::PendingRotation::begin` is this crate's first *direct*
# secret-vs-secret comparison (the new epoch's content key against the one
# it replaces) -- every prior comparison either operates on public data
# (RecipientId) or lives inside chacha20poly1305's own Poly1305 tag check,
# which already depends on `subtle` transitively (see DECISION.md #6). A
# variable-time `==` on two content keys would leak where they agree
# through timing, so the comparison needs `subtle::ConstantTimeEq`
# explicitly rather than relying on the transitive copy.
subtle = { version = "2", optional = true }

# Post-quantum hybrid KEM stack (preview-pq only).
# WHY: released RustCrypto primitives, not the rc-pinned `x-wing` aggregate crate.
Expand Down
95 changes: 86 additions & 9 deletions DECISION.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,12 +125,22 @@ Key-wrapping choice — **ChaCha20-Poly1305, not AES-KW**:
"AES-GCM/AES-KW" were offered as options, not mandates; this is the
better-justified envelope for *this* stack.

Multi-device + revocation:
Multi-device key distribution vs. revocation (sphragis#14):
- `seal_for(content_key, recipients) -> Vec<WrappedContentKey>` — one wrap per
device, all decapsulating to the same content key.
- Revoke a device = re-run `seal_for` over the remaining recipients with a freshly
generated content key (forward-secret rotation) or the same content key
(cheap revoke) — the consuming store picks the policy; `sphragis` exposes both.
device, all decapsulating to the same content key. This **distributes** a
content key; it has no memory of who has ever recovered one, 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.
Describing that as revocation (this section previously did, calling the
same-key case a "cheap revoke") is a security-contract failure: a consumer
who implements it believes access was removed when the former device
still holds the only secret needed to read current and future ciphertext
under that key.
- Actual revocation is `rotate`'s typed protocol (§11): a new key,
independent of the old one, wrapped only for the retained set, switched to
atomically (from the consumer's side), with the old key then retired.
Ciphertext already written under the old key is unaffected either way —
see §11 for the boundary this crate cannot cross.

Crypto-agility / versioning:
- `version: u8` in the wire struct + the domain tag string both carry `v1`.
Expand Down Expand Up @@ -164,10 +174,13 @@ workspace, akroasis PR #173).
| `chacha20poly1305` | 0.10 | envelope AEAD (already a workspace dep) |
| `zeroize`, `blake3`, `ciborium`, `snafu` | workspace | hygiene/serde/errors |

No direct `subtle` dependency: the crate compares only public values
(`RecipientId` is the BLAKE3 hash of a public encapsulation key, carried in
plaintext on the wire). The one secret-dependent comparison — the Poly1305 tag
check — happens inside `chacha20poly1305`, which uses `subtle` internally.
`subtle` is a direct dependency as of §11 (key rotation): `rotate::PendingRotation::begin`
is this crate's first *direct* secret-vs-secret comparison (the new epoch's
content key against the one it replaces), so it needs `subtle::ConstantTimeEq`
explicitly rather than relying on a transitive copy. Every other comparison in
the crate is over public values (`RecipientId` is the BLAKE3 hash of a public
encapsulation key, carried in plaintext on the wire), or is the Poly1305 tag
check inside `chacha20poly1305`, which already uses `subtle` internally.

Deliberately NOT the `x-wing` crate (0.1.0-rc.0): it pins a *release-candidate*
stack (`ml-kem 0.3.0-rc.0`, `x25519-dalek 3.0.0-pre.6`, `sha3 0.11.0-rc.7`) and
Expand Down Expand Up @@ -306,3 +319,67 @@ unverified claim. `rand_core`'s `std` feature is enabled (in addition to
`getrandom`) so `rand_core::Error` implements `std::error::Error` and can
sit behind `SealError::Entropy`'s `source` field with a real chain, rather
than being flattened to a string.

## 11. Key rotation is revocation; `seal_for` alone is not (sphragis#14)

§4's original "Multi-device + revocation" text called re-running `seal_for`
over a smaller recipient list — optionally with a fresh content key —
revocation, including a "cheap revoke" that reused the same key. That is
wrong: a device that has ever unsealed a content key retains it regardless
of whether a later `seal_for` call addresses it, so omitting a wrap changes
who receives the *next* one, not what a former recipient already holds. The
`rotate` module (`src/rotate.rs`) replaces that guidance with a typed
protocol and this section replaces the misnamed one.

**Protocol.** Five stages, enforced in order by a typestate chain
(`PendingRotation -> PublishedWraps -> CommittedEpoch -> RotationComplete`)
so the ordering is a compile error to violate, not a convention to remember:
new content key -> publish wraps for the retained recipients -> the consumer
durably persists those wraps as the epoch's live set -> `commit()`
acknowledges the switch -> `retire_old_key()` erases the orchestrating
caller's copy of the old key. Wire-compatible: rotation calls the same
`seal_for_with_rng` internals `seal_for` does, so `WrappedContentKey`'s CBOR
shape and version do not change.

**What this crate cannot do, stated once, plainly.** 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, so it protects data written *after* the epoch switch, not data
written before it. `tests/rotation.rs` is the adversarial proof: a device
that recovers the old key before rotation runs remains able to decrypt data
already protected under it, and specifically fails to decrypt data
protected under the completed new epoch — the property the issue's
evidence found the prior test never modeled. Whether a consumer
re-encrypts its already-stored payloads under the new key is a decision
sphragis has no way to make or enforce, because it never touches payload
data; the conservative default is that rotation does not attempt it, and
`rotate`'s module doc says so rather than leaving a reader to assume
otherwise.

**Design decisions the issue left open:**
- *Does rotation re-encrypt existing payloads, or only protect data going
forward?* Forward-only, by construction (the crate has no payload to act
on) — the conservative reading, chosen explicitly rather than left
ambiguous. A consumer that wants old data re-protected performs that
itself, against its own store.
- *Who allocates the epoch identifier `rotate::EpochId` carries through the
protocol?* The caller, not sphragis: this crate holds no persistent state
across calls, so it cannot allocate or validate a monotonic sequence
itself — that bookkeeping already belongs to whatever store tracks "which
wrap set is current" for a device. `EpochId` is an opaque `u64` sphragis
carries through the typestate chain unmodified, mirroring how content-key
generation itself has always been caller-visible (`generate_content_key`
exists for convenience, not because sphragis owns key material lifecycle).
- *What does "atomically switch the epoch" mean for a crate with no
storage?* Only the consumer's own store transaction can make an epoch
switch atomic. `PublishedWraps::commit()` cannot perform that transaction;
what it can and does guarantee is ordering — the type system refuses to
produce a `CommittedEpoch` (and therefore refuses `retire_old_key`) until
the caller has called `commit()`, so the old key cannot be destroyed
before the caller has at least acknowledged the new epoch is durably live.
- *Same-key rotation.* `PendingRotation::begin` rejects a new content key
equal to the old one (`SealError::ContentKeyUnchanged`), compared via
`subtle::ConstantTimeEq` since both operands are secret (see §6). Without
this check a caller could accidentally rotate into a no-op that produces a
full new wrap set while changing nothing a revoked recipient cannot
already decrypt.
28 changes: 26 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,32 @@ underneath (`HybridKem`, a raw shared secret, direct encaps/decaps) is not
exported — see "Features" below and `DECISION.md` for the envelope-vs-primitive
boundary (sphragis#23).

Revoke a device by re-running `seal_for` over the remaining recipients (with a
fresh content key for forward secrecy, or the same one for a cheap revoke).
`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 is not
revocation — a recipient who already unsealed the key keeps it regardless of
whether a later call addresses them again. Actually revoking a device is a
typed protocol in the `rotate` module: generate a new content key, publish
wraps of it for the retained recipients only, commit the new epoch, then
retire the old key.

```rust,ignore
use sphragis::{generate_content_key, EpochId, PendingRotation};

let new_content_key = generate_content_key()?;
let pending = PendingRotation::begin(EpochId(1), &new_content_key, &old_content_key)?;
let published = pending.publish_wraps_for(&retained_recipients)?; // device 2 excluded
// Persist `published.wraps()` as epoch 1's live wrap set, then:
let committed = published.commit();
committed.retire_old_key(old_content_key);
```

**What rotation does not protect.** Ciphertext already written under the old
content key stays readable by anyone who holds that key — including a
recipient this rotation just excluded, if they ever unsealed it before now.
Rotation protects data written *after* the switch, not data written before
it; re-encrypting old data under the new key, if wanted, is the consumer's
own operation against their own store. See `src/rotate.rs`'s module doc and
`tests/rotation.rs` for the adversarial proof.

## Features

Expand Down
14 changes: 14 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,4 +114,18 @@ pub enum SealError {
#[snafu(implicit)]
location: snafu::Location,
},

/// A rotation's new content key was equal to the old epoch's content
/// key. Rotation exists to change what secret a removed recipient
/// needs; reusing the old key would produce a full set of new wraps
/// that a revoked recipient can already decrypt, defeating rotation
/// while looking, from the wrap set alone, like it succeeded.
#[snafu(display(
"rotation content key equals the previous epoch's key: this would not change what a revoked recipient can decrypt"
))]
ContentKeyUnchanged {
/// Source location of the failing check.
#[snafu(implicit)]
location: snafu::Location,
},
}
23 changes: 16 additions & 7 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,15 @@
//! The stable contract is the versioned envelope:
//! [`generate_recipient_keypair`](seal::generate_recipient_keypair),
//! [`seal_for`], [`seal_for_with_rng`], [`unseal`], [`RecipientId`],
//! [`WrappedContentKey`]. The generic hybrid-KEM primitive underneath it
//! (`HybridKem`, a raw `SharedSecret`, direct encaps/decaps,
//! `derive_wrap_key`) is reachable only with the `hazmat` feature, for
//! known-answer/conformance testing — no stability promise, and no
//! migration promise: `DECISION.md` records why the local X-Wing combiner
//! exists (upstream is pre-release) and what gates swapping it for a
//! stable, audited upstream implementation. That swap changes
//! [`WrappedContentKey`], plus the [`rotate`] module's typed key-rotation
//! protocol (sphragis#14) — actual device revocation, as distinct from
//! `seal_for`'s recipient-key distribution. The generic hybrid-KEM
//! primitive underneath it (`HybridKem`, a raw `SharedSecret`, direct
//! encaps/decaps, `derive_wrap_key`) is reachable only with the `hazmat`
//! feature, for known-answer/conformance testing — no stability promise,
//! and no migration promise: `DECISION.md` records why the local X-Wing
//! combiner exists (upstream is pre-release) and what gates swapping it for
//! a stable, audited upstream implementation. That swap changes
//! `src/hybrid.rs` alone; this profile's API and wire contract do not move.

#![cfg_attr(docsrs, feature(doc_cfg))]
Expand All @@ -43,6 +45,8 @@ pub mod error;
#[cfg(feature = "preview-pq")]
pub mod hybrid;
#[cfg(feature = "preview-pq")]
pub mod rotate;
#[cfg(feature = "preview-pq")]
pub mod seal;

#[cfg(feature = "preview-pq")]
Expand All @@ -52,6 +56,11 @@ pub use hybrid::{DecapsulationKey, EncapsulationKey};
#[cfg(all(feature = "preview-pq", feature = "hazmat"))]
pub use hybrid::{HybridKem, SharedSecret};
#[cfg(feature = "preview-pq")]
pub use rotate::{
generate_content_key, generate_content_key_with_rng, CommittedEpoch, EpochId, PendingRotation,
PublishedWraps, RotationComplete,
};
#[cfg(feature = "preview-pq")]
pub use seal::{
generate_recipient_keypair, seal_for, seal_for_with_rng, unseal, RecipientId,
WrappedContentKey, CONTENT_KEY_LEN,
Expand Down
Loading