CryptGuard is a pure-Rust post-quantum cryptography library. Version 3 uses a revision-pinned PQ HPKE protocol as its default encryption transport.
ML-KEM (FIPS 203) -> HPKE KEM and key schedule -> AEAD
The default suite is ML-KEM-1024/P-384 with SHAKE256 and ChaCha20-Poly1305. It prioritizes conservative security and explicit protocol boundaries over compact ciphertexts.
- Installation
- Choose a transport form
- Default encrypted envelope
- Raw Base-mode HPKE
- PSK-mode HPKE
- Suite selection
- Private AEAD extensions
- Envelope format and metadata
- Failure handling and security rules
- CGv2 migration
- Signatures and legacy support
- Feature flags
[dependencies]
crypt_guard = "3.0.1"The default feature set includes the FIPS ML-KEM and ML-DSA backends. No
feature is necessary for the v3 pq_hpke API.
CryptGuard provides two deliberately separate transport forms.
| Form | Use it when | Contents |
|---|---|---|
| Raw HPKE | The protocol already negotiates suite and info out of band |
Separate enc and ciphertext |
HpkeEnvelope |
You need a crypt_guard self-describing record | Protocol magic, version, suite, enc, ciphertext |
Use raw transport only with RFC-style AEAD identifiers: AES-128-GCM,
AES-256-GCM, and ChaCha20-Poly1305. Use HpkeEnvelope for either the
standardized suites or CryptGuard private AEAD extensions.
info and AAD are intentionally not serialized inside HpkeEnvelope. The
sender and receiver must have the same application contract for both values.
This is the normal v3 starting point. DEFAULT_SUITE is
ML-KEM-1024/P-384 with SHAKE256 and ChaCha20-Poly1305.
use crypt_guard::pq_hpke::{
generate_recipient_key_pair, HpkeEnvelope, DEFAULT_SUITE,
};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let keys = generate_recipient_key_pair(DEFAULT_SUITE.kem())?;
let envelope = HpkeEnvelope::seal(
DEFAULT_SUITE,
keys.public_key(),
b"service=payments;protocol=1",
b"tenant=acme;record=42",
b"approved transfer payload",
)?;
let bytes = envelope.to_bytes();
let received = HpkeEnvelope::from_bytes(&bytes)?;
let plaintext = received.open(
keys.private_key(),
b"service=payments;protocol=1",
b"tenant=acme;record=42",
)?;
assert_eq!(plaintext, b"approved transfer payload");
Ok(())
}Generate recipient key material once, protect the private key seed using your
key-management boundary, and distribute only the public key. A sender gets a
fresh encapsulation and a fresh HPKE context for every call to seal.
Raw HPKE is appropriate when your protocol carries enc and ciphertext in
separate fields. The suite and info are negotiated or persisted by that
protocol, not guessed during decryption.
use crypt_guard::pq_hpke::{
generate_recipient_key_pair, setup_base_receiver, setup_base_sender,
Aead, Kdf, Kem, Suite,
};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let suite = Suite::new(Kem::MlKem768, Kdf::HkdfSha256, Aead::Aes128Gcm);
let keys = generate_recipient_key_pair(suite.kem())?;
let info = b"application=mail;version=1";
let aad = b"recipient=alice@example.test";
let (enc, mut sender) = setup_base_sender(suite, keys.public_key(), info)?;
let ciphertext = sender.seal(aad, b"raw HPKE payload")?;
let mut receiver = setup_base_receiver(suite, keys.private_key(), &enc, info)?;
let plaintext = receiver.open(aad, &ciphertext)?;
assert_eq!(plaintext, b"raw HPKE payload");
Ok(())
}Sender and recipient contexts are stateful and intentionally not cloneable.
Each successful seal or open advances the HPKE message sequence and derives
the next nonce internally. Do not serialize a live context for later reuse.
PSK mode binds an additional symmetric secret to HPKE setup. Both PSK bytes and their identifier are required. A PSK is not a replacement for recipient public key validation or authenticated application identity.
use crypt_guard::pq_hpke::{
generate_recipient_key_pair, setup_psk_receiver, setup_psk_sender,
Aead, Kdf, Kem, Suite,
};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let suite = Suite::new(Kem::MlKem1024, Kdf::HkdfSha384, Aead::Aes256Gcm);
let keys = generate_recipient_key_pair(suite.kem())?;
let psk = b"32 bytes from a separate key-management system";
let psk_id = b"payments-rotation-2026-07";
let info = b"channel=settlement";
let aad = b"message=109";
let (enc, mut sender) = setup_psk_sender(
suite, keys.public_key(), info, psk, psk_id,
)?;
let ciphertext = sender.seal(aad, b"PSK-bound payload")?;
let mut receiver = setup_psk_receiver(
suite, keys.private_key(), &enc, info, psk, psk_id,
)?;
assert_eq!(receiver.open(aad, &ciphertext)?, b"PSK-bound payload");
Ok(())
}Base and PSK modes are supported. PQ authenticated KEM modes are deliberately not exposed by this API.
Select a suite explicitly whenever the default profile does not match your deployment. Persist the selected KEM, KDF, and AEAD identifiers with raw transport records.
use crypt_guard::pq_hpke::{Aead, Kdf, Kem, Suite, DEFAULT_SUITE};
let default_suite = DEFAULT_SUITE;
let pure_pq = Suite::new(Kem::MlKem1024, Kdf::Shake256, Aead::ChaCha20Poly1305);
let hybrid_p256 = Suite::new(Kem::MlKem768P256, Kdf::HkdfSha256, Aead::Aes128Gcm);
let hybrid_x25519 = Suite::new(
Kem::MlKem768X25519,
Kdf::HkdfSha256,
Aead::ChaCha20Poly1305,
);
let hybrid_p384 = Suite::new(Kem::MlKem1024P384, Kdf::HkdfSha384, Aead::Aes256Gcm);
assert_eq!(default_suite.kem(), Kem::MlKem1024P384);
assert_eq!(pure_pq.aead(), Aead::ChaCha20Poly1305);Supported KEM choices are ML-KEM-512, ML-KEM-768, ML-KEM-1024,
ML-KEM-768/P-256, ML-KEM-768/X25519, and ML-KEM-1024/P-384. The ML-KEM and
hybrid mappings are revision-pinned to draft-ietf-hpke-pq-05 and are not
final IANA assignments.
AES-256-GCM-SIV and XChaCha20-Poly1305 are available only in the CryptGuard envelope namespace. They use private AEAD identifiers and must not be sent to an implementation expecting an RFC 9180 or IANA suite identifier.
use crypt_guard::pq_hpke::{
generate_recipient_key_pair, Aead, HpkeEnvelope, Kdf, Kem, Suite,
};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let suite = Suite::new(Kem::MlKem768, Kdf::HkdfSha256, Aead::XChaCha20Poly1305);
assert!(suite.aead().is_private_extension());
let keys = generate_recipient_key_pair(suite.kem())?;
let envelope = HpkeEnvelope::seal(
suite, keys.public_key(), b"object-store", b"object=reports/2026", b"payload",
)?;
assert_eq!(envelope.open(keys.private_key(), b"object-store", b"object=reports/2026")?, b"payload");
Ok(())
}| AEAD | Identifier | Raw transport | HpkeEnvelope |
|---|---|---|---|
| AES-128-GCM | 0x0001 |
yes | yes |
| AES-256-GCM | 0x0002 |
yes | yes |
| ChaCha20-Poly1305 | 0x0003 |
yes | yes |
| AES-256-GCM-SIV | 0xff01 |
no | yes |
| XChaCha20-Poly1305 | 0xff02 |
no | yes |
HpkeEnvelope serializes a binary CGH3 record with envelope version 1.
It contains the protocol magic, version, KEM identifier, KDF identifier, AEAD
identifier, encapsulation length, ciphertext length, encapsulation, and
ciphertext. It does not contain plaintext, info, AAD, recipient private key
material, or a shared secret.
HpkeEnvelope::from_bytes validates framing and suite identifiers before a
receiver context is constructed. Parsing a CGv2 record as a CGH3 envelope
fails before decryption.
open returns an opaque Error::AuthenticationFailed for wrong AAD, wrong
info, modified ciphertext, and same-size modified encapsulations that reach
ML-KEM implicit rejection. Do not branch application behavior on which of those
conditions occurred.
use crypt_guard::pq_hpke::{Error, HpkeEnvelope};
fn open_record(
envelope: &HpkeEnvelope,
key: &crypt_guard::pq_hpke::RecipientPrivateKey,
) -> Result<Vec<u8>, Error> {
match envelope.open(key, b"service=payments", b"record=42") {
Ok(plaintext) => Ok(plaintext),
Err(Error::AuthenticationFailed) => Err(Error::AuthenticationFailed),
Err(error) => Err(error),
}
}Treat info as a stable setup context such as protocol version or service
name. Treat AAD as authenticated but unencrypted metadata such as tenant,
record type, object identifier, or sender routing data. Do not reuse a sender
context after its sequence is exhausted. Do not put secrets in AAD.
CGv2 is compatibility-only in v3. Default builds do not expose the legacy builders or accept CGv2 as a v3 envelope. Existing stored data requires an explicit migration build.
[dependencies]
crypt_guard = { version = "3.0.1", features = ["cgv2-compat"] }Migration procedure:
- Deploy a migration worker with
cgv2-compatenabled. - Read and authenticate the existing CGv2 envelope using the compatibility API.
- Re-encrypt the recovered plaintext with
pq_hpke::HpkeEnvelope. - Store the
CGH3bytes and preserve the application contract forinfoand AAD. - Remove
cgv2-compatafter all stored data has been migrated.
Never trial-decrypt unknown records with both formats. Store or transmit a transport discriminator and select the reader directly.
ML-DSA remains available in the default feature set.
use crypt_guard::kem::backend::OsRng;
use crypt_guard::sign::{ml_dsa::MlDsa65Impl, SignAlgorithm};
let mut rng = OsRng;
let (secret_key, public_key) = MlDsa65Impl::keypair(&mut rng)?;
let signature = MlDsa65Impl::sign(&secret_key, b"signed payload")?;
MlDsa65Impl::verify(&public_key, b"signed payload", &signature)?;
# Ok::<(), crypt_guard::error::CryptError>(())The legacy-pqclean feature retains the historical Kyber, Falcon, and
Dilithium path for explicitly managed legacy data. It is separate from
cgv2-compat, which controls the v2 envelope and builder compatibility path.
| Feature | Default | Purpose |
|---|---|---|
ml-kem-backend |
yes | FIPS ML-KEM-512, ML-KEM-768, ML-KEM-1024 |
ml-dsa-backend |
yes | FIPS ML-DSA-44, ML-DSA-65, ML-DSA-87 |
sign-slhdsa |
no | SLH-DSA signatures |
cgv2-compat |
no | CGv2 envelope, builders, and compatibility helpers |
legacy-pqclean |
no | Historical Kyber, Falcon, and Dilithium compatibility |
archive |
no | Archive helpers |
zip |
no | ZIP helpers |
aes-ctr |
no | Legacy AES-CTR support |
aes-xts |
no | Legacy AES-XTS support |