0.0
The project is in a healthy, maintained state
Versioned multi-recipient document encryption using PQCrypto hybrid KEM, HKDF-SHA256, and vendored AEGIS-256.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

~> 0.6
 Project Readme

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.md before using it for irreplaceable data.

wrap_suite_id = 1 is pinned to :ml_kem_768_x25519_xwing and pq_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 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.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 default

rebuild_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 rake

Set 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