Skip to main content

gunnar_crypt/
pq.rs

1//! age's hybrid post-quantum recipient, `mlkem768x25519` (c2sp.org/age):
2//! an `age1pq1…` public key and its `AGE-SECRET-KEY-PQ-1…` identity.
3//!
4//! **Why it is here and not in age.** No age release carries it yet: age
5//! 0.12.1 (2026-07) has only the encryption-only `tag`/`tagpq` types, and it
6//! pulls ml-kem 0.2, which cannot share a lock with russh's ml-kem 0.3. rage's
7//! native `pq` type is an open pull request (str4d/rage#632). This module is
8//! the same stanza, on age 0.11.5's public [`age::Recipient`] and
9//! [`age::Identity`] traits, and it is meant to be replaced by age's own type
10//! the day a release carries one. Nothing in it is new cryptography:
11//!
12//! * the KEM is X-Wing, a.k.a. MLKEM768-X25519 (HPKE KEM id 0x647a), from the
13//!   released `hpke` 0.14.1 and `x-wing` 0.1.1 (RustCrypto, ml-kem 0.3);
14//! * the wrap is HPKE (RFC 9180) base mode with HKDF-SHA256 and
15//!   ChaCha20Poly1305, info `age-encryption.org/mlkem768x25519`, empty aad —
16//!   exactly the spec's parameters;
17//! * an identity is the 32-byte X-Wing decapsulation seed, bech32 with the
18//!   `AGE-SECRET-KEY-PQ-` prefix; a recipient is the 1216-byte encapsulation
19//!   key, bech32 with `age1pq` (no length limit, as age encodes it).
20//!
21//! The stanza is `-> mlkem768x25519 <base64 enc, 1120 bytes>` with a 32-byte
22//! body (the 16-byte file key and its tag). Every C2SP CCTV `hybrid*` vector
23//! is run against it (`tests/pq.rs`), and Go age v1.3 opens what it wraps and
24//! the other way round (the interop row in the design document).
25//!
26//! **One rule the vectors pin**: an X25519 half that is a low-order point
27//! makes the X25519 shared secret all-zero, and the spec refuses it as a
28//! HEADER failure. hpke 0.14.1's X-Wing accepts such a share, so the
29//! decapsulation is asked first of x-wing's `DecapsulationKeyRejectNonContrib`
30//! (its documented strict variant) and a refusal there is a header failure.
31//!
32//! The recipient labels its stanza `postquantum`, as age's own will: age
33//! itself then refuses to put it in one file with a classical recipient.
34
35use std::collections::HashSet;
36use std::fmt;
37
38use age::secrecy::{ExposeSecret as _, SecretString};
39use age_core::format::{FileKey, Stanza, FILE_KEY_BYTES};
40use base64::engine::general_purpose::STANDARD_NO_PAD;
41use base64::Engine as _;
42use bech32::{FromBase32 as _, ToBase32 as _, Variant};
43use hpke::{Deserializable as _, Kem as _, Serializable as _};
44use x_wing::TryDecapsulate as _;
45use zeroize::{Zeroize as _, Zeroizing};
46
47pub use crate::members::PQ_STANZA as STANZA_TYPE;
48
49/// The HPKE `info` of the stanza (c2sp.org/age, `mlkem768x25519`).
50const INFO: &[u8] = b"age-encryption.org/mlkem768x25519";
51/// The recipient's bech32 HRP.
52const RECIPIENT_HRP: &str = "age1pq";
53/// The identity's bech32 HRP (written upper-case).
54const IDENTITY_HRP: &str = "age-secret-key-pq-";
55/// The encapsulated key: an ML-KEM-768 ciphertext and an X25519 share.
56const ENC_BYTES: usize = 1120;
57/// The label age gives a post-quantum recipient's stanzas.
58pub const LABEL: &str = "postquantum";
59
60type Kem = hpke::kem::XWing;
61type Aead = hpke::aead::ChaCha20Poly1305;
62type Kdf = hpke::kdf::HkdfSha256;
63
64/// The OS random source, for HPKE's encapsulation, through the `getrandom`
65/// this crate already uses on both targets.
66struct OsRng;
67
68impl hpke::rand_core::TryRng for OsRng {
69    type Error = core::convert::Infallible;
70    fn try_next_u32(&mut self) -> Result<u32, Self::Error> {
71        Ok(u32::from_le_bytes(crate::random_bytes::<4>()))
72    }
73    fn try_next_u64(&mut self) -> Result<u64, Self::Error> {
74        Ok(u64::from_le_bytes(crate::random_bytes::<8>()))
75    }
76    fn try_fill_bytes(&mut self, dst: &mut [u8]) -> Result<(), Self::Error> {
77        getrandom::getrandom(dst).expect("the OS random source is available");
78        Ok(())
79    }
80}
81impl hpke::rand_core::TryCryptoRng for OsRng {}
82
83/// An `age1pq1…` public key.
84#[derive(Clone, PartialEq, Eq)]
85pub struct Recipient(<Kem as hpke::Kem>::PublicKey);
86
87impl std::str::FromStr for Recipient {
88    type Err = &'static str;
89
90    fn from_str(s: &str) -> Result<Self, Self::Err> {
91        let bytes = crate::members::pq_public_key_bytes(s).ok_or("not an age1pq1… key")?;
92        <Kem as hpke::Kem>::PublicKey::from_bytes(&bytes)
93            .map(Recipient)
94            .map_err(|_| "not a valid ML-KEM-768 + X25519 public key")
95    }
96}
97
98impl fmt::Display for Recipient {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        let s = bech32::encode(RECIPIENT_HRP, self.0.to_bytes().to_base32(), Variant::Bech32)
101            .map_err(|_| fmt::Error)?;
102        f.write_str(&s)
103    }
104}
105
106impl fmt::Debug for Recipient {
107    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108        write!(f, "{self}")
109    }
110}
111
112impl age::Recipient for Recipient {
113    fn wrap_file_key(
114        &self,
115        file_key: &FileKey,
116    ) -> Result<(Vec<Stanza>, HashSet<String>), age::EncryptError> {
117        let (enc, body) = hpke::single_shot_seal_with_rng::<Aead, Kdf, Kem>(
118            &hpke::OpModeS::Base,
119            &self.0,
120            INFO,
121            file_key.expose_secret(),
122            &[],
123            &mut OsRng,
124        )
125        .map_err(|e| age::EncryptError::Io(std::io::Error::other(e.to_string())))?;
126        Ok((
127            vec![Stanza {
128                tag: STANZA_TYPE.to_string(),
129                args: vec![STANDARD_NO_PAD.encode(enc.to_bytes())],
130                body,
131            }],
132            HashSet::from([LABEL.to_string()]),
133        ))
134    }
135}
136
137/// An `AGE-SECRET-KEY-PQ-1…` identity: the 32-byte X-Wing seed.
138#[derive(Clone)]
139pub struct Identity(<Kem as hpke::Kem>::PrivateKey);
140
141impl Identity {
142    /// A new identity from the OS random source.
143    pub fn generate() -> Identity {
144        let seed = Zeroizing::new(crate::random_bytes::<32>());
145        Identity::from_seed(&seed)
146    }
147
148    fn from_seed(seed: &[u8; 32]) -> Identity {
149        Identity(<Kem as hpke::Kem>::PrivateKey::from_bytes(seed).expect("32 bytes is a seed"))
150    }
151
152    /// The `AGE-SECRET-KEY-PQ-1…` line. Secret: written only to the 0600
153    /// identity file on the member's own machine.
154    pub fn to_secret_string(&self) -> SecretString {
155        let seed = Zeroizing::new(self.0.to_bytes());
156        let mut lower = bech32::encode(IDENTITY_HRP, seed.as_slice().to_base32(), Variant::Bech32)
157            .expect("the HRP is valid");
158        let upper = lower.to_uppercase();
159        lower.zeroize();
160        SecretString::from(upper)
161    }
162
163    /// Its public key.
164    pub fn to_public(&self) -> Recipient {
165        Recipient(Kem::sk_to_pk(&self.0))
166    }
167}
168
169impl std::str::FromStr for Identity {
170    type Err = &'static str;
171
172    fn from_str(s: &str) -> Result<Self, Self::Err> {
173        let s = s.trim();
174        if !s.starts_with("AGE-SECRET-KEY-PQ-1") {
175            return Err("not an AGE-SECRET-KEY-PQ-1… identity");
176        }
177        let (hrp, data, variant) = bech32::decode(s).map_err(|_| "invalid bech32")?;
178        if hrp != IDENTITY_HRP || variant != Variant::Bech32 {
179            return Err("not an AGE-SECRET-KEY-PQ-1… identity");
180        }
181        let bytes = Zeroizing::new(Vec::<u8>::from_base32(&data).map_err(|_| "invalid bech32")?);
182        let seed: &[u8; 32] = bytes
183            .as_slice()
184            .try_into()
185            .map_err(|_| "a post-quantum identity is 32 bytes")?;
186        Ok(Identity::from_seed(seed))
187    }
188}
189
190impl age::Identity for Identity {
191    fn unwrap_stanza(&self, stanza: &Stanza) -> Option<Result<FileKey, age::DecryptError>> {
192        if stanza.tag != STANZA_TYPE {
193            return None;
194        }
195        let header = || Some(Err(age::DecryptError::InvalidHeader));
196        // Exactly one canonical base64 argument of 1120 bytes, and a body of
197        // the file key plus its tag (c2sp.org/age#mlkem768x25519-recipient-stanza).
198        let [arg] = &stanza.args[..] else {
199            return header();
200        };
201        // 1120 bytes are 1494 base64 characters: anything else is refused
202        // before it is decoded, however long it is.
203        if arg.len() != 1494 {
204            return header();
205        }
206        let Some(enc) = STANDARD_NO_PAD
207            .decode(arg)
208            .ok()
209            .filter(|b| b.len() == ENC_BYTES && STANDARD_NO_PAD.encode(b) == *arg)
210        else {
211            return header();
212        };
213        if stanza.body.len() != FILE_KEY_BYTES + 16 {
214            return header();
215        }
216        // The X25519 half must contribute (the spec's rule; hpke's X-Wing
217        // would accept an all-zero shared secret).
218        let seed = Zeroizing::new(<[u8; 32]>::from(self.0.to_bytes()));
219        let strict = x_wing::DecapsulationKeyRejectNonContrib::from(*seed);
220        let Ok(ct) = x_wing::Ciphertext::try_from(enc.as_slice()) else {
221            return header();
222        };
223        if strict.try_decapsulate(&ct).is_err() {
224            return header();
225        }
226        let Ok(enc) = <Kem as hpke::Kem>::EncappedKey::from_bytes(&enc) else {
227            return header();
228        };
229        match hpke::single_shot_open::<Aead, Kdf, Kem>(
230            &hpke::OpModeR::Base,
231            &self.0,
232            &enc,
233            INFO,
234            &stanza.body,
235            &[],
236        ) {
237            // A decapsulation failure would happen for every identity: the
238            // header is bad. An AEAD failure only means "not this identity".
239            Err(hpke::HpkeError::DecapError) => header(),
240            Err(_) => None,
241            Ok(mut pt) => {
242                let key = FileKey::init_with_mut(|k| k.copy_from_slice(&pt));
243                pt.zeroize();
244                Some(Ok(key))
245            }
246        }
247    }
248}
249
250/// Is `text` a post-quantum identity file (age-keygen's layout: `#` comment
251/// lines and one `AGE-SECRET-KEY-PQ-1…` line)?
252pub fn is_identity_file(text: &str) -> bool {
253    identity_line(text).is_some()
254}
255
256fn identity_line(text: &str) -> Option<&str> {
257    text.lines()
258        .map(str::trim)
259        .find(|l| l.starts_with("AGE-SECRET-KEY-PQ-1"))
260}
261
262/// The identity in an identity file.
263pub fn parse_identity_file(text: &str) -> Result<Identity, &'static str> {
264    identity_line(text)
265        .ok_or("no AGE-SECRET-KEY-PQ-1… line")?
266        .parse()
267}
268
269/// The identity file `gunnar crypt keygen` writes: age-keygen's layout, so
270/// `age -d -i` reads it too.
271pub fn identity_file(identity: &Identity, created: &str) -> Zeroizing<String> {
272    Zeroizing::new(format!(
273        "# created: {created}\n# public key: {}\n{}\n",
274        identity.to_public(),
275        identity.to_secret_string().expose_secret()
276    ))
277}