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.0 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.
Installation
gem "pq_crypto", "~> 0.6.4"
gem "pq_crypto-seal", "~> 0.1.0"
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 entire
unauthenticated inner frame to a mode-0600 temporary file and copies content to
the caller's output only after the final AEGIS tag is valid.
Recipients and key lifecycle
The envelope intentionally does not contain an authoritative list of recipient public keys. The application owns the ACL. Any complete recipient-section rewrite therefore receives the full list:
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.
drop_recipient_stanza(..., remaining_recipients:) describes exactly what it
does. It does not revoke plaintext, a DEK, or old copies already obtained.
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 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.
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, targeting approximately 128-bit security. ML-KEM-768 supplies conservative margin; the full hybrid suite is not advertised as NIST category 3.
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.