Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-crypt/src/pq.rs

//! age's hybrid post-quantum recipient, `mlkem768x25519` (c2sp.org/age):
//! an `age1pq1…` public key and its `AGE-SECRET-KEY-PQ-1…` identity.
//!
//! **Why it is here and not in age.** No age release carries it yet: age
//! 0.12.1 (2026-07) has only the encryption-only `tag`/`tagpq` types, and it
//! pulls ml-kem 0.2, which cannot share a lock with russh's ml-kem 0.3. rage's
//! native `pq` type is an open pull request (str4d/rage#632). This module is
//! the same stanza, on age 0.11.5's public [`age::Recipient`] and
//! [`age::Identity`] traits, and it is meant to be replaced by age's own type
//! the day a release carries one. Nothing in it is new cryptography:
//!
//! * the KEM is X-Wing, a.k.a. MLKEM768-X25519 (HPKE KEM id 0x647a), from the
//!   released `hpke` 0.14.1 and `x-wing` 0.1.1 (RustCrypto, ml-kem 0.3);
//! * the wrap is HPKE (RFC 9180) base mode with HKDF-SHA256 and
//!   ChaCha20Poly1305, info `age-encryption.org/mlkem768x25519`, empty aad —
//!   exactly the spec's parameters;
//! * an identity is the 32-byte X-Wing decapsulation seed, bech32 with the
//!   `AGE-SECRET-KEY-PQ-` prefix; a recipient is the 1216-byte encapsulation
//!   key, bech32 with `age1pq` (no length limit, as age encodes it).
//!
//! The stanza is `-> mlkem768x25519 <base64 enc, 1120 bytes>` with a 32-byte
//! body (the 16-byte file key and its tag). Every C2SP CCTV `hybrid*` vector
//! is run against it (`tests/pq.rs`), and Go age v1.3 opens what it wraps and
//! the other way round (the interop row in the design document).
//!
//! **One rule the vectors pin**: an X25519 half that is a low-order point
//! makes the X25519 shared secret all-zero, and the spec refuses it as a
//! HEADER failure. hpke 0.14.1's X-Wing accepts such a share, so the
//! decapsulation is asked first of x-wing's `DecapsulationKeyRejectNonContrib`
//! (its documented strict variant) and a refusal there is a header failure.
//!
//! The recipient labels its stanza `postquantum`, as age's own will: age
//! itself then refuses to put it in one file with a classical recipient.

use std::collections::HashSet;
use std::fmt;

use age::secrecy::{ExposeSecret as _, SecretString};
use age_core::format::{FileKey, Stanza, FILE_KEY_BYTES};
use base64::engine::general_purpose::STANDARD_NO_PAD;
use base64::Engine as _;
use bech32::{FromBase32 as _, ToBase32 as _, Variant};
use hpke::{Deserializable as _, Kem as _, Serializable as _};
use x_wing::TryDecapsulate as _;
use zeroize::{Zeroize as _, Zeroizing};

pub use crate::members::PQ_STANZA as STANZA_TYPE;

/// The HPKE `info` of the stanza (c2sp.org/age, `mlkem768x25519`).
const INFO: &[u8] = b"age-encryption.org/mlkem768x25519";
/// The recipient's bech32 HRP.
const RECIPIENT_HRP: &str = "age1pq";
/// The identity's bech32 HRP (written upper-case).
const IDENTITY_HRP: &str = "age-secret-key-pq-";
/// The encapsulated key: an ML-KEM-768 ciphertext and an X25519 share.
const ENC_BYTES: usize = 1120;
/// The label age gives a post-quantum recipient's stanzas.
pub const LABEL: &str = "postquantum";

type Kem = hpke::kem::XWing;
type Aead = hpke::aead::ChaCha20Poly1305;
type Kdf = hpke::kdf::HkdfSha256;

/// The OS random source, for HPKE's encapsulation, through the `getrandom`
/// this crate already uses on both targets.
struct OsRng;

impl hpke::rand_core::TryRng for OsRng {
    type Error = core::convert::Infallible;
    fn try_next_u32(&mut self) -> Result<u32, Self::Error> {
        Ok(u32::from_le_bytes(crate::random_bytes::<4>()))
    }
    fn try_next_u64(&mut self) -> Result<u64, Self::Error> {
        Ok(u64::from_le_bytes(crate::random_bytes::<8>()))
    }
    fn try_fill_bytes(&mut self, dst: &mut [u8]) -> Result<(), Self::Error> {
        getrandom::getrandom(dst).expect("the OS random source is available");
        Ok(())
    }
}
impl hpke::rand_core::TryCryptoRng for OsRng {}

/// An `age1pq1…` public key.
#[derive(Clone, PartialEq, Eq)]
pub struct Recipient(<Kem as hpke::Kem>::PublicKey);

impl std::str::FromStr for Recipient {
    type Err = &'static str;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let bytes = crate::members::pq_public_key_bytes(s).ok_or("not an age1pq1… key")?;
        <Kem as hpke::Kem>::PublicKey::from_bytes(&bytes)
            .map(Recipient)
            .map_err(|_| "not a valid ML-KEM-768 + X25519 public key")
    }
}

impl fmt::Display for Recipient {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let s = bech32::encode(
            RECIPIENT_HRP,
            self.0.to_bytes().to_base32(),
            Variant::Bech32,
        )
        .map_err(|_| fmt::Error)?;
        f.write_str(&s)
    }
}

impl fmt::Debug for Recipient {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{self}")
    }
}

impl age::Recipient for Recipient {
    fn wrap_file_key(
        &self,
        file_key: &FileKey,
    ) -> Result<(Vec<Stanza>, HashSet<String>), age::EncryptError> {
        let (enc, body) = hpke::single_shot_seal_with_rng::<Aead, Kdf, Kem>(
            &hpke::OpModeS::Base,
            &self.0,
            INFO,
            file_key.expose_secret(),
            &[],
            &mut OsRng,
        )
        .map_err(|e| age::EncryptError::Io(std::io::Error::other(e.to_string())))?;
        Ok((
            vec![Stanza {
                tag: STANZA_TYPE.to_string(),
                args: vec![STANDARD_NO_PAD.encode(enc.to_bytes())],
                body,
            }],
            HashSet::from([LABEL.to_string()]),
        ))
    }
}

/// An `AGE-SECRET-KEY-PQ-1…` identity: the 32-byte X-Wing seed.
#[derive(Clone)]
pub struct Identity(<Kem as hpke::Kem>::PrivateKey);

impl Identity {
    /// A new identity from the OS random source.
    pub fn generate() -> Identity {
        let seed = Zeroizing::new(crate::random_bytes::<32>());
        Identity::from_seed(&seed)
    }

    fn from_seed(seed: &[u8; 32]) -> Identity {
        Identity(<Kem as hpke::Kem>::PrivateKey::from_bytes(seed).expect("32 bytes is a seed"))
    }

    /// The `AGE-SECRET-KEY-PQ-1…` line. Secret: written only to the 0600
    /// identity file on the member's own machine.
    pub fn to_secret_string(&self) -> SecretString {
        let seed = Zeroizing::new(self.0.to_bytes());
        let mut lower = bech32::encode(IDENTITY_HRP, seed.as_slice().to_base32(), Variant::Bech32)
            .expect("the HRP is valid");
        let upper = lower.to_uppercase();
        lower.zeroize();
        SecretString::from(upper)
    }

    /// Its public key.
    pub fn to_public(&self) -> Recipient {
        Recipient(Kem::sk_to_pk(&self.0))
    }
}

impl std::str::FromStr for Identity {
    type Err = &'static str;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let s = s.trim();
        if !s.starts_with("AGE-SECRET-KEY-PQ-1") {
            return Err("not an AGE-SECRET-KEY-PQ-1… identity");
        }
        let (hrp, data, variant) = bech32::decode(s).map_err(|_| "invalid bech32")?;
        if hrp != IDENTITY_HRP || variant != Variant::Bech32 {
            return Err("not an AGE-SECRET-KEY-PQ-1… identity");
        }
        let bytes = Zeroizing::new(Vec::<u8>::from_base32(&data).map_err(|_| "invalid bech32")?);
        let seed: &[u8; 32] = bytes
            .as_slice()
            .try_into()
            .map_err(|_| "a post-quantum identity is 32 bytes")?;
        Ok(Identity::from_seed(seed))
    }
}

impl age::Identity for Identity {
    fn unwrap_stanza(&self, stanza: &Stanza) -> Option<Result<FileKey, age::DecryptError>> {
        if stanza.tag != STANZA_TYPE {
            return None;
        }
        let header = || Some(Err(age::DecryptError::InvalidHeader));
        // Exactly one canonical base64 argument of 1120 bytes, and a body of
        // the file key plus its tag (c2sp.org/age#mlkem768x25519-recipient-stanza).
        let [arg] = &stanza.args[..] else {
            return header();
        };
        // 1120 bytes are 1494 base64 characters: anything else is refused
        // before it is decoded, however long it is.
        if arg.len() != 1494 {
            return header();
        }
        let Some(enc) = STANDARD_NO_PAD
            .decode(arg)
            .ok()
            .filter(|b| b.len() == ENC_BYTES && STANDARD_NO_PAD.encode(b) == *arg)
        else {
            return header();
        };
        if stanza.body.len() != FILE_KEY_BYTES + 16 {
            return header();
        }
        // The X25519 half must contribute (the spec's rule; hpke's X-Wing
        // would accept an all-zero shared secret).
        let seed = Zeroizing::new(<[u8; 32]>::from(self.0.to_bytes()));
        let strict = x_wing::DecapsulationKeyRejectNonContrib::from(*seed);
        let Ok(ct) = x_wing::Ciphertext::try_from(enc.as_slice()) else {
            return header();
        };
        if strict.try_decapsulate(&ct).is_err() {
            return header();
        }
        let Ok(enc) = <Kem as hpke::Kem>::EncappedKey::from_bytes(&enc) else {
            return header();
        };
        match hpke::single_shot_open::<Aead, Kdf, Kem>(
            &hpke::OpModeR::Base,
            &self.0,
            &enc,
            INFO,
            &stanza.body,
            &[],
        ) {
            // A decapsulation failure would happen for every identity: the
            // header is bad. An AEAD failure only means "not this identity".
            Err(hpke::HpkeError::DecapError) => header(),
            Err(_) => None,
            Ok(mut pt) => {
                let key = FileKey::init_with_mut(|k| k.copy_from_slice(&pt));
                pt.zeroize();
                Some(Ok(key))
            }
        }
    }
}

/// Is `text` a post-quantum identity file (age-keygen's layout: `#` comment
/// lines and one `AGE-SECRET-KEY-PQ-1…` line)?
pub fn is_identity_file(text: &str) -> bool {
    identity_line(text).is_some()
}

fn identity_line(text: &str) -> Option<&str> {
    text.lines()
        .map(str::trim)
        .find(|l| l.starts_with("AGE-SECRET-KEY-PQ-1"))
}

/// The identity in an identity file.
pub fn parse_identity_file(text: &str) -> Result<Identity, &'static str> {
    identity_line(text)
        .ok_or("no AGE-SECRET-KEY-PQ-1… line")?
        .parse()
}

/// The identity file `gunnar crypt keygen` writes: age-keygen's layout, so
/// `age -d -i` reads it too.
pub fn identity_file(identity: &Identity, created: &str) -> Zeroizing<String> {
    Zeroizing::new(format!(
        "# created: {created}\n# public key: {}\n{}\n",
        identity.to_public(),
        identity.to_secret_string().expose_secret()
    ))
}