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.2 is experimental cryptographic software; format v1; not independently audited. Read
SECURITY.mdbefore using it for irreplaceable data.
wrap_suite_id = 1is pinned to:ml_kem_768_x25519_xwingandpq_crypto = 0.6.4(X-Wing draft-10 wire sizes). Content suite 1 is AEGIS-256 via libaegis 0.10.3.
Installation
gem "pq_crypto", "~> 0.6.4"
gem "pq_crypto-seal", "~> 0.1.2"
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 compile
OPENSSL_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.
opened.
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 default
rebuild_recipients preserves the DEK and encrypted payload. It is an
operational migration of the current canonical copy, not protection against old
saved envelopes. rotate_dek creates a new DEK, a new payload_id, and re-encrypts the payload; it
still cannot erase knowledge from prior copies. Rotation preserves the current
final envelope size by default (padding: :preserve); pass :padme, :none, or
an explicit padding policy to recalculate it.
recipient_capacity and slot_size are immutable for a given payload header.
To grow either, re-encrypt via rotate_dek / rotate_dek_file with
recipient_capacity: / slot_size: overrides.
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 rake
Set PQC_SEAL_SANITIZE=1 when compiling under ASan/UBSan. The suite covers
AEGIS one-shot/incremental equivalence, tampering, multi-recipient opening,
full-envelope padding, recipient rebuilds, staged file publication, and parser
limits.
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