σφραγίς - seal / signet
Post-quantum hybrid sealing for multi-device content-key distribution. Seals a 32-byte content key for one or more recipient devices so only a holder of the matching secret key can recover it, with security resting on both a classical (X25519) and a post-quantum (ML-KEM-768) assumption.
UNAUDITED PREVIEW. All cryptography is behind the
preview-pqfeature and is never on the default binary path. The known-answer tests prove the construction matches the published standards. They are not a substitute for a cryptographic review. SeeDECISION.md.
- KEM: X-Wing (
draft-connolly-cfrg-xwing-kem, IACR 2024/039) - X25519 + ML-KEM-768, combined viaSHA3-256(ss_M || ss_X || ct_X || pk_X || "\.//^\"). - Envelope: HKDF-SHA256 (null salt, versioned domain tag) → ChaCha20-Poly1305 seals the content key. Version + recipient id are bound as AEAD associated data.
- Wire: versioned, per-recipient
WrappedContentKey(CBOR).
Add to Cargo.toml:
sphragis = { git = "https://github.com/forkwright/sphragis", features = ["preview-pq"] }use sphragis::{generate_recipient_keypair, seal_for, unseal};
// Each device holds a keypair; publish the encapsulation (public) key.
let (dk, ek) = generate_recipient_keypair()?;
// Seal a content key for a set of devices (one wrap each, same content key).
let content_key = [0u8; 32];
let wrapped = seal_for(&content_key, &[ek])?;
// A device unseals its wrap with its decapsulation (secret) key.
let recovered = unseal(&dk, &wrapped[0])?;
assert_eq!(recovered.as_slice(), &content_key);This is the entire public contract: the generic hybrid-KEM primitive
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).
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.
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.
preview-pq- enables the hybrid KEM + envelope. Off by default.hazmat- exposes the generic hybrid-KEM primitive (HybridKem, raw shared secret, direct encaps/decaps,derive_wrap_key) for known-answer/conformance testing. No stability promise. A normal consumer never enables this.
cargo test --features preview-pqcrypto-provenance.toml declares every
known-answer test's standard revision, vector source, source hash, and
locked dependency version, and tests/provenance_lock.rs enforces it - a
cargo update that moves a locked crypto dependency, or an edit to a
vendored vector fixture, fails the gate.
ML-KEM-768 alone places all trust in a 2024-vintage primitive and its pre-1.0
implementations. The hybrid forces an adversary to break both ML-KEM and
X25519 - matching TLS 1.3 (X25519MLKEM768), Signal (PQXDH), SSH
(mlkem768x25519), and the CFRG general-purpose answer (X-Wing).
Full rationale: DECISION.md.
AGPL-3.0-only. See LICENSE.