Channels

qhermes-channels provides post-quantum encrypted channels. ML-KEM-768 handles key encapsulation; ChaCha20-Poly1305 handles symmetric encryption. No heap, no I/O, no unsafe code.

Rust only. No Python bindings.


Constants

EK_SIZE:      1184  // ML-KEM-768 encapsulation key (bytes)
DK_SEED_SIZE:   64  // decapsulation key seed (bytes)
CT_SIZE:      1088  // ML-KEM-768 ciphertext (bytes)
KEY_SIZE:       32  // ChaCha20-Poly1305 session key (bytes)
NONCE_SIZE:     12  // ChaCha20-Poly1305 nonce (bytes)
TAG_SIZE:       16  // ChaCha20-Poly1305 authentication tag (bytes)

Key derivation

derive_kem_keypair

pub fn derive_kem_keypair(dsa_seed: &[u8; 32]) -> Result<KemKeyPair, ChannelError>

Derives a ML-KEM-768 keypair from a 32-byte DSA seed. Passing the same seed used in IdentityIsland::derive binds a KEM identity to a DSA identity under the same root secret, with domain separation via b"QHermes-KEM-768-v1".

Returns a KemKeyPair with encapsulation_key (public, shareable) and decapsulation_key (secret, zeroed on drop).

use qhermes_channels::derive_kem_keypair;

let dsa_seed: [u8; 32] = /* your root seed */;
let kp = derive_kem_keypair(&dsa_seed)?;
let ek_bytes = kp.encapsulation_key.as_bytes(); // send to sender
let dk_seed  = kp.decapsulation_key.as_seed();  // keep secret

KEM operations

encapsulate

pub fn encapsulate(
    ek: &KemEncapsulationKey,
    context: &[u8],
    rng: &mut impl rand_core::CryptoRng,
) -> Result<(KemCiphertext, SessionKey), ChannelError>

Encapsulates a fresh shared secret for ek. Returns the ciphertext to send to the receiver and a SessionKey derived from the shared secret via HKDF-SHA3-512. context is caller-supplied domain separation material.

decapsulate

pub fn decapsulate(
    dk: &KemDecapsulationKey,
    ct: &KemCiphertext,
    context: &[u8],
) -> Result<SessionKey, ChannelError>

Decapsulates the shared secret from ct. Returns a SessionKey equivalent to the one returned by encapsulate. context must be identical to the value passed to encapsulate.

use qhermes_channels::{encapsulate, decapsulate, KemEncapsulationKey};

// Sender side
let ek = KemEncapsulationKey::from_bytes(&ek_bytes)?;
let (ct, session_key) = encapsulate(&ek, b"my-context", &mut rng)?;
// send ct.as_bytes() over the wire

// Receiver side
let dk = kp.decapsulation_key;
let ct = KemCiphertext::from_bytes(ct_bytes);
let session_key = decapsulate(&dk, &ct, b"my-context")?;

SessionKey

A ChaCha20-Poly1305 session key derived from the KEM shared secret. Zeroed on drop.

seal

pub fn seal(
    &self,
    nonce: &[u8; NONCE_SIZE],
    plaintext: &[u8],
    aad: &[u8],
    out: &mut [u8],
) -> Result<usize, ChannelError>

Encrypts plaintext with ChaCha20-Poly1305. out must have at least plaintext.len() + TAG_SIZE bytes. Returns the number of bytes written. The nonce must not be reused with the same key.

open

pub fn open(
    &self,
    nonce: &[u8; NONCE_SIZE],
    ciphertext: &[u8],
    aad: &[u8],
    out: &mut [u8],
) -> Result<usize, ChannelError>

Decrypts and authenticates a ciphertext produced by seal. Returns AuthenticationFailed if the tag does not verify.

use qhermes_channels::{NONCE_SIZE, TAG_SIZE};

let nonce: [u8; NONCE_SIZE] = /* unique per message */;
let plaintext = b"hello";
let mut out = vec![0u8; plaintext.len() + TAG_SIZE];

let written = session_key.seal(&nonce, plaintext, b"", &mut out)?;

// Decrypt
let mut plain_out = vec![0u8; written - TAG_SIZE];
session_key.open(&nonce, &out[..written], b"", &mut plain_out)?;

Errors

pub enum ChannelError {
    HkdfExpandFailed,      // HKDF produced fewer bytes than requested
    KeyDecodingFailed,     // invalid ML-KEM-768 encoding
    OutputBufferTooSmall,  // out buffer too small
    InputBufferTooSmall,   // ciphertext shorter than TAG_SIZE
    AuthenticationFailed,  // AEAD tag verification failed
}