pq_crypto-seal
Post-quantum envelope encryption for Ruby 2.7.1+.
The gem turns pq_crypto's hybrid KEM into practical document encryption:
MLKEM768-X25519 shared secret → HKDF-SHA256 → KEK → wrap random DEK
random DEK → AEGIS-256 → document
libaegis 0.10.3 is vendored and compiled into the extension. The existing
pq_crypto gem supplies the hybrid KEM and OpenSSL-backed primitives.
Status 0.1.5 is experimental cryptographic software using format v1. It has yet to undergo an independent audit. Read
SECURITY.mdbefore using it for irreplaceable data.
wrap_suite_id = 1is pinned to:ml_kem_768_x25519_xwingandpq_crypto ~> 0.6(X-Wing draft-10 wire sizes). Content suite 1 is AEGIS-256 via libaegis 0.10.3.
Installation
gem "pq_crypto", "~> 0.6"
gem "pq_crypto-seal", "~> 0.1.5"Ruby >= 2.7.1 and OpenSSL >= 3.0 development files are required for a
source build.
On macOS, extconf.rb automatically searches Homebrew openssl@3 on both
Apple Silicon and Intel installations. If OpenSSL 3 is installed in a custom
location, configure it explicitly:
brew install openssl@3
bundle config set --local build.pq_crypto-seal \
"--with-openssl-dir=$(brew --prefix openssl@3)"
bundle exec rake clean compileOPENSSL_ROOT_DIR and OPENSSL_DIR are also supported. An explicit
--with-openssl-dir, --with-openssl-include, or --with-openssl-lib always
takes precedence over automatic discovery.
String API
require "pq_crypto/seal"
alice = PQCrypto::HybridKEM.generate(PQCrypto::Seal::WRAP_KEM_ALGORITHM)
bob = PQCrypto::HybridKEM.generate(PQCrypto::Seal::WRAP_KEM_ALGORITHM)
sealed = PQCrypto::Seal.encrypt(
image_bytes,
to: [alice.public_key, bob.public_key],
metadata: { mime: "image/png" }.to_json,
public_metadata: "tenant-7",
recipient_capacity: 4,
slot_size: 2048,
padding: :padme
)
opened = PQCrypto::Seal.open(sealed, with: alice) # keypair supplies secret + public hint material
opened.data
opened.metadata
opened.public_metadata
PQCrypto::Seal.decrypt(sealed, with: bob)All random keys and nonces are generated internally. There is no public API for
supplying a DEK or nonce. Because scoped hint lookup needs the matching public
key, with: accepts a HybridKEM::Keypair. When keys are stored separately:
credentials = PQCrypto::Seal.credentials(
secret_key: loaded_secret_key,
public_key: loaded_public_key
)
PQCrypto::Seal.decrypt(sealed, with: credentials)Files and IO
PQCrypto::Seal.encrypt_file(
"scan.tiff",
"scan.tiff.pqcseal",
to: alice.public_key
)
PQCrypto::Seal.decrypt_file(
"scan.tiff.pqcseal",
"restored.tiff",
with: alice
)encrypt_io accepts the exact plaintext size:. decrypt_io stages the
ciphertext inner frame to a mode-0600 temporary file, verifies the AEGIS tag,
then materialises plaintext and copies content to the caller's output. Decrypt
APIs default to required_padding: :from_header.
Recipients and key lifecycle
The envelope intentionally does not contain an authoritative list of recipient public keys. The application owns and must synchronise the ACL. Any recipient-section rewrite therefore receives the full list:
# Remove bob from the *current* copy only — not a cryptographic revocation.
updated = PQCrypto::Seal.rebuild_recipients(
sealed,
with: alice,
recipients: [alice.public_key, carol.public_key]
)add_recipient(..., recipient:, current_recipients:) still requires the
complete current ACL and rebuilds all slots. There is no drop_recipient_*
helper: “drop” names falsely suggest revocation while old copies and the DEK
remain valid.
rotated = PQCrypto::Seal.rotate_dek(
sealed,
with: alice,
recipients: [alice.public_key, carol.public_key]
) # preserves the existing envelope size by defaultrebuild_recipients preserves the DEK and encrypted payload. rotate_dek
creates a new DEK and a new payload_id and re-encrypts the payload. Anyone
with an old envelope and its DEK can still decrypt that copy.
The default padding: :preserve keeps the original envelope length and writes
a fixed padding policy, including when the input used Padmé. To enforce that
exact length on decrypt, pass required_padding: { to: sealed.bytesize }.
recipient_capacity and slot_size are immutable for a given payload header.
Pass either override to rotate_dek or rotate_dek_file to grow the recipient
section. If the new envelope exceeds the original length, :preserve raises
InvalidConfigurationError. Use padding: :padme, :none, or an explicit
policy that fits the larger envelope.
expanded = PQCrypto::Seal.rotate_dek(
sealed,
with: alice,
recipients: [alice.public_key, carol.public_key],
recipient_capacity: 8,
padding: :padme
)Wire-format v1
The exact bytes are implemented in PQCrypto::Seal::Format:
immutable payload header
magic "PQCSEAL1"
version
content_suite_id = AEGIS-256 payload profile
lookup_mode = payload-scoped recipient hint
payload_id, payload_nonce
recipient_capacity, slot_size
padded_inner_length
public metadata
mutable recipient section
wrap_suite_id = MLKEM768-X25519 + HKDF-SHA256 + AEGIS-256 wrap
random section_id (changes on every complete rebuild)
fixed-capacity, fixed-size slots
slot
recipient_hint
1120-byte hybrid KEM ciphertext
wrap nonce
wrapped 32-byte DEK
32-byte tag
authenticated random slot padding
AEGIS-encrypted inner frame
authenticated content length and private metadata length
private metadata
content
encrypted Padmé padding
32-byte payload tag
content_suite_id and wrap_suite_id are deliberately independent. A recipient
section can be rebuilt without changing payload AD as long as the new wrapping
stanza fits the immutable slot_size.
Defaults:
slot_size: 2048 bytes (configurable 2048..8192, multiple of 256)
recipient_capacity: 4 (maximum 32; never derived automatically from ACL size)
padding: Padmé over the complete final envelope size
The payload-and-section-scoped recipient hint avoids a stable global recipient identifier and prevents stable hints from revealing real slots across recipient-section rebuilds. Anyone who already knows a candidate public key can test for its presence. No formal recipient anonymity is claimed.
The wrapping KEM follows the MLKEM768-X25519/X-Wing construction (draft-10
wire sizes pinned by suite id 1), targeting approximately 128-bit security.
ML-KEM-768 supplies conservative margin; the full hybrid suite is not advertised
as NIST category 3. See SECURITY.md for at-rest draft-pinning risk.
Envelope identity
PQCrypto::Seal.digest(envelope) is SHA-256 over the complete envelope
bytes. The recipient section is intentionally mutable, so
rebuild_recipients / rotate_dek change the digest even when the encrypted
payload is unchanged. Use opened.payload_id (or inspect_envelope) for a
stable document identifier.
High recipient_capacity makes encryption slower: every empty slot still runs
a full X-Wing encapsulation against a disposable keypair.
Tests
bundle install
bundle exec rakeSet PQC_SEAL_SANITIZE=1 (address + undefined) or an explicit sanitizer list
such as PQC_SEAL_SANITIZE=thread when compiling the extension. The suite covers
AEGIS one-shot/incremental equivalence, tampering, multi-recipient opening,
full-envelope padding, recipient rebuilds, staged file publication, parser
limits, concurrent use of one AEGIS state, and argument conversion edge cases.
Padding enforcement on decrypt
After AEAD verification, decrypt APIs default to
required_padding: :from_header:
- Padmé / none — full canonical size check
- fixed / buckets — opens with policy-id check only; pass parameters for full target enforcement
PQCrypto::Seal.decrypt(envelope, with: credentials) # :from_header
PQCrypto::Seal.decrypt(envelope, with: credentials, required_padding: :padme)
PQCrypto::Seal.decrypt(envelope, with: credentials, required_padding: { to: 4096 })
PQCrypto::Seal.decrypt(envelope, with: credentials, required_padding: false) # opt out