1use 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
49const INFO: &[u8] = b"age-encryption.org/mlkem768x25519";
51const RECIPIENT_HRP: &str = "age1pq";
53const IDENTITY_HRP: &str = "age-secret-key-pq-";
55const ENC_BYTES: usize = 1120;
57pub const LABEL: &str = "postquantum";
59
60type Kem = hpke::kem::XWing;
61type Aead = hpke::aead::ChaCha20Poly1305;
62type Kdf = hpke::kdf::HkdfSha256;
63
64struct 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#[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#[derive(Clone)]
139pub struct Identity(<Kem as hpke::Kem>::PrivateKey);
140
141impl Identity {
142 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 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 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 let [arg] = &stanza.args[..] else {
199 return header();
200 };
201 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 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 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
250pub 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
262pub 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
269pub 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}