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(
101            RECIPIENT_HRP,
102            self.0.to_bytes().to_base32(),
103            Variant::Bech32,
104        )
105        .map_err(|_| fmt::Error)?;
106        f.write_str(&s)
107    }
108}
109
110impl fmt::Debug for Recipient {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        write!(f, "{self}")
113    }
114}
115
116impl age::Recipient for Recipient {
117    fn wrap_file_key(
118        &self,
119        file_key: &FileKey,
120    ) -> Result<(Vec<Stanza>, HashSet<String>), age::EncryptError> {
121        let (enc, body) = hpke::single_shot_seal_with_rng::<Aead, Kdf, Kem>(
122            &hpke::OpModeS::Base,
123            &self.0,
124            INFO,
125            file_key.expose_secret(),
126            &[],
127            &mut OsRng,
128        )
129        .map_err(|e| age::EncryptError::Io(std::io::Error::other(e.to_string())))?;
130        Ok((
131            vec![Stanza {
132                tag: STANZA_TYPE.to_string(),
133                args: vec![STANDARD_NO_PAD.encode(enc.to_bytes())],
134                body,
135            }],
136            HashSet::from([LABEL.to_string()]),
137        ))
138    }
139}
140
141/// An `AGE-SECRET-KEY-PQ-1…` identity: the 32-byte X-Wing seed.
142#[derive(Clone)]
143pub struct Identity(<Kem as hpke::Kem>::PrivateKey);
144
145impl Identity {
146    /// A new identity from the OS random source.
147    pub fn generate() -> Identity {
148        let seed = Zeroizing::new(crate::random_bytes::<32>());
149        Identity::from_seed(&seed)
150    }
151
152    fn from_seed(seed: &[u8; 32]) -> Identity {
153        Identity(<Kem as hpke::Kem>::PrivateKey::from_bytes(seed).expect("32 bytes is a seed"))
154    }
155
156    /// The `AGE-SECRET-KEY-PQ-1…` line. Secret: written only to the 0600
157    /// identity file on the member's own machine.
158    pub fn to_secret_string(&self) -> SecretString {
159        let seed = Zeroizing::new(self.0.to_bytes());
160        let mut lower = bech32::encode(IDENTITY_HRP, seed.as_slice().to_base32(), Variant::Bech32)
161            .expect("the HRP is valid");
162        let upper = lower.to_uppercase();
163        lower.zeroize();
164        SecretString::from(upper)
165    }
166
167    /// Its public key.
168    pub fn to_public(&self) -> Recipient {
169        Recipient(Kem::sk_to_pk(&self.0))
170    }
171}
172
173impl std::str::FromStr for Identity {
174    type Err = &'static str;
175
176    fn from_str(s: &str) -> Result<Self, Self::Err> {
177        let s = s.trim();
178        if !s.starts_with("AGE-SECRET-KEY-PQ-1") {
179            return Err("not an AGE-SECRET-KEY-PQ-1… identity");
180        }
181        let (hrp, data, variant) = bech32::decode(s).map_err(|_| "invalid bech32")?;
182        if hrp != IDENTITY_HRP || variant != Variant::Bech32 {
183            return Err("not an AGE-SECRET-KEY-PQ-1… identity");
184        }
185        let bytes = Zeroizing::new(Vec::<u8>::from_base32(&data).map_err(|_| "invalid bech32")?);
186        let seed: &[u8; 32] = bytes
187            .as_slice()
188            .try_into()
189            .map_err(|_| "a post-quantum identity is 32 bytes")?;
190        Ok(Identity::from_seed(seed))
191    }
192}
193
194impl age::Identity for Identity {
195    fn unwrap_stanza(&self, stanza: &Stanza) -> Option<Result<FileKey, age::DecryptError>> {
196        if stanza.tag != STANZA_TYPE {
197            return None;
198        }
199        let header = || Some(Err(age::DecryptError::InvalidHeader));
200        // Exactly one canonical base64 argument of 1120 bytes, and a body of
201        // the file key plus its tag (c2sp.org/age#mlkem768x25519-recipient-stanza).
202        let [arg] = &stanza.args[..] else {
203            return header();
204        };
205        // 1120 bytes are 1494 base64 characters: anything else is refused
206        // before it is decoded, however long it is.
207        if arg.len() != 1494 {
208            return header();
209        }
210        let Some(enc) = STANDARD_NO_PAD
211            .decode(arg)
212            .ok()
213            .filter(|b| b.len() == ENC_BYTES && STANDARD_NO_PAD.encode(b) == *arg)
214        else {
215            return header();
216        };
217        if stanza.body.len() != FILE_KEY_BYTES + 16 {
218            return header();
219        }
220        // The X25519 half must contribute (the spec's rule; hpke's X-Wing
221        // would accept an all-zero shared secret).
222        let seed = Zeroizing::new(<[u8; 32]>::from(self.0.to_bytes()));
223        let strict = x_wing::DecapsulationKeyRejectNonContrib::from(*seed);
224        let Ok(ct) = x_wing::Ciphertext::try_from(enc.as_slice()) else {
225            return header();
226        };
227        if strict.try_decapsulate(&ct).is_err() {
228            return header();
229        }
230        let Ok(enc) = <Kem as hpke::Kem>::EncappedKey::from_bytes(&enc) else {
231            return header();
232        };
233        match hpke::single_shot_open::<Aead, Kdf, Kem>(
234            &hpke::OpModeR::Base,
235            &self.0,
236            &enc,
237            INFO,
238            &stanza.body,
239            &[],
240        ) {
241            // A decapsulation failure would happen for every identity: the
242            // header is bad. An AEAD failure only means "not this identity".
243            Err(hpke::HpkeError::DecapError) => header(),
244            Err(_) => None,
245            Ok(mut pt) => {
246                let key = FileKey::init_with_mut(|k| k.copy_from_slice(&pt));
247                pt.zeroize();
248                Some(Ok(key))
249            }
250        }
251    }
252}
253
254/// Is `text` a post-quantum identity file (age-keygen's layout: `#` comment
255/// lines and one `AGE-SECRET-KEY-PQ-1…` line)?
256pub fn is_identity_file(text: &str) -> bool {
257    identity_line(text).is_some()
258}
259
260fn identity_line(text: &str) -> Option<&str> {
261    text.lines()
262        .map(str::trim)
263        .find(|l| l.starts_with("AGE-SECRET-KEY-PQ-1"))
264}
265
266/// The identity in an identity file.
267pub fn parse_identity_file(text: &str) -> Result<Identity, &'static str> {
268    identity_line(text)
269        .ok_or("no AGE-SECRET-KEY-PQ-1… line")?
270        .parse()
271}
272
273/// The identity file `gunnar crypt keygen` writes: age-keygen's layout, so
274/// `age -d -i` reads it too.
275pub fn identity_file(identity: &Identity, created: &str) -> Zeroizing<String> {
276    Zeroizing::new(format!(
277        "# created: {created}\n# public key: {}\n{}\n",
278        identity.to_public(),
279        identity.to_secret_string().expose_secret()
280    ))
281}