1use std::collections::HashSet;
4use std::fmt;
5
6use age_core::{
7 format::{FileKey, Stanza, FILE_KEY_BYTES},
8 primitives::{aead_decrypt, aead_encrypt, hkdf},
9 secrecy::{ExposeSecret, SecretString},
10};
11use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
12use bech32::{ToBase32, Variant};
13use rand::rngs::OsRng;
14use subtle::ConstantTimeEq;
15use x25519_dalek::{EphemeralSecret, PublicKey, StaticSecret};
16use zeroize::Zeroize;
17
18use crate::{
19 error::{DecryptError, EncryptError},
20 util::{parse_bech32, read::base64_arg},
21};
22
23const SECRET_KEY_PREFIX: &str = "age-secret-key-";
25const PUBLIC_KEY_PREFIX: &str = "age";
26
27pub(super) const X25519_RECIPIENT_TAG: &str = "X25519";
28const X25519_RECIPIENT_KEY_LABEL: &[u8] = b"age-encryption.org/v1/X25519";
29
30pub(super) const EPK_LEN_BYTES: usize = 32;
31pub(super) const ENCRYPTED_FILE_KEY_BYTES: usize = FILE_KEY_BYTES + 16;
32
33#[derive(Clone)]
36pub struct Identity(StaticSecret);
37
38impl std::str::FromStr for Identity {
39 type Err = &'static str;
40
41 fn from_str(s: &str) -> Result<Self, Self::Err> {
43 parse_bech32(s)
44 .ok_or("invalid Bech32 encoding")
45 .and_then(|(hrp, bytes)| {
46 if hrp == SECRET_KEY_PREFIX {
47 TryInto::<[u8; 32]>::try_into(&bytes[..])
48 .map_err(|_| "incorrect identity length")
49 .map(StaticSecret::from)
50 .map(Identity)
51 } else {
52 Err("incorrect HRP")
53 }
54 })
55 }
56}
57
58impl Identity {
59 pub fn generate() -> Self {
61 let rng = OsRng;
62 Identity(StaticSecret::random_from_rng(rng))
63 }
64
65 pub fn to_string(&self) -> SecretString {
67 let mut sk_bytes = self.0.to_bytes();
68 let sk_base32 = sk_bytes.to_base32();
69 let mut encoded =
70 bech32::encode(SECRET_KEY_PREFIX, sk_base32, Variant::Bech32).expect("HRP is valid");
71 let ret = SecretString::from(encoded.to_uppercase());
72
73 sk_bytes.zeroize();
75 encoded.zeroize();
78
79 ret
80 }
81
82 pub fn to_public(&self) -> Recipient {
84 Recipient((&self.0).into())
85 }
86}
87
88impl crate::Identity for Identity {
89 fn unwrap_stanza(&self, stanza: &Stanza) -> Option<Result<FileKey, DecryptError>> {
90 if stanza.tag != X25519_RECIPIENT_TAG {
91 return None;
92 }
93
94 let ephemeral_share = match &stanza.args[..] {
97 [arg] => match base64_arg::<_, EPK_LEN_BYTES, 33>(arg) {
98 Some(ephemeral_share) => ephemeral_share,
99 None => return Some(Err(DecryptError::InvalidHeader)),
100 },
101 _ => return Some(Err(DecryptError::InvalidHeader)),
102 };
103 if stanza.body.len() != ENCRYPTED_FILE_KEY_BYTES {
104 return Some(Err(DecryptError::InvalidHeader));
105 }
106
107 let epk: PublicKey = ephemeral_share.into();
108 let encrypted_file_key: [u8; ENCRYPTED_FILE_KEY_BYTES] = stanza.body[..]
109 .try_into()
110 .expect("Length should have been checked above");
111
112 let pk: PublicKey = (&self.0).into();
113 let shared_secret = self.0.diffie_hellman(&epk);
114 if shared_secret
117 .as_bytes()
118 .iter()
119 .fold(0, |acc, b| acc | b)
120 .ct_eq(&0)
121 .into()
122 {
123 return Some(Err(DecryptError::InvalidHeader));
124 }
125
126 let mut salt = [0; 64];
127 salt[..32].copy_from_slice(epk.as_bytes());
128 salt[32..].copy_from_slice(pk.as_bytes());
129
130 let enc_key = hkdf(&salt, X25519_RECIPIENT_KEY_LABEL, shared_secret.as_bytes());
131
132 aead_decrypt(&enc_key, FILE_KEY_BYTES, &encrypted_file_key)
136 .ok()
137 .map(|mut pt| {
138 Ok(FileKey::init_with_mut(|file_key| {
140 file_key.copy_from_slice(&pt);
141 pt.zeroize();
142 }))
143 })
144 }
145}
146
147#[derive(Clone, PartialEq, Eq, Hash)]
153pub struct Recipient(PublicKey);
154
155impl std::str::FromStr for Recipient {
156 type Err = &'static str;
157
158 fn from_str(s: &str) -> Result<Self, Self::Err> {
160 parse_bech32(s)
161 .ok_or("invalid Bech32 encoding")
162 .and_then(|(hrp, bytes)| {
163 if hrp == PUBLIC_KEY_PREFIX {
164 TryInto::<[u8; 32]>::try_into(&bytes[..])
165 .map_err(|_| "incorrect pubkey length")
166 .map(PublicKey::from)
167 .map(Recipient)
168 } else {
169 Err("incorrect HRP")
170 }
171 })
172 }
173}
174
175impl fmt::Display for Recipient {
176 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
177 write!(
178 f,
179 "{}",
180 bech32::encode(
181 PUBLIC_KEY_PREFIX,
182 self.0.as_bytes().to_base32(),
183 Variant::Bech32
184 )
185 .expect("HRP is valid")
186 )
187 }
188}
189
190impl fmt::Debug for Recipient {
191 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
192 write!(f, "{}", self)
193 }
194}
195
196impl crate::Recipient for Recipient {
197 fn wrap_file_key(
198 &self,
199 file_key: &FileKey,
200 ) -> Result<(Vec<Stanza>, HashSet<String>), EncryptError> {
201 let rng = OsRng;
202 let esk = EphemeralSecret::random_from_rng(rng);
203 let epk: PublicKey = (&esk).into();
204 let shared_secret = esk.diffie_hellman(&self.0);
205
206 if bool::from(
211 shared_secret
212 .as_bytes()
213 .iter()
214 .fold(0, |acc, b| acc | b)
215 .ct_eq(&0),
216 ) {
217 panic!("Generated the all-zero esk; OS RNG is likely failing!");
218 }
219
220 let mut salt = [0; 64];
221 salt[..32].copy_from_slice(epk.as_bytes());
222 salt[32..].copy_from_slice(self.0.as_bytes());
223
224 let enc_key = hkdf(&salt, X25519_RECIPIENT_KEY_LABEL, shared_secret.as_bytes());
225 let encrypted_file_key = aead_encrypt(&enc_key, file_key.expose_secret());
226
227 let encoded_epk = BASE64_STANDARD_NO_PAD.encode(epk.as_bytes());
228
229 Ok((
230 vec![Stanza {
231 tag: X25519_RECIPIENT_TAG.to_owned(),
232 args: vec![encoded_epk],
233 body: encrypted_file_key,
234 }],
235 HashSet::new(),
236 ))
237 }
238}
239
240#[cfg(test)]
241pub(crate) mod tests {
242 use age_core::{format::FileKey, secrecy::ExposeSecret};
243 use proptest::prelude::*;
244 use x25519_dalek::{PublicKey, StaticSecret};
245
246 use super::{Identity, Recipient};
247 use crate::{Identity as _, Recipient as _};
248
249 pub(crate) const TEST_SK: &str =
250 "AGE-SECRET-KEY-1GQ9778VQXMMJVE8SK7J6VT8UJ4HDQAJUVSFCWCM02D8GEWQ72PVQ2Y5J33";
251 pub(crate) const TEST_PK: &str =
252 "age1t7rxyev2z3rw82stdlrrepyc39nvn86l5078zqkf5uasdy86jp6svpy7pa";
253
254 #[test]
255 fn pubkey_encoding() {
256 let pk: Recipient = TEST_PK.parse().unwrap();
257 assert_eq!(pk.to_string(), TEST_PK);
258 }
259
260 #[test]
261 fn pubkey_from_secret_key() {
262 let key = TEST_SK.parse::<Identity>().unwrap();
263 assert_eq!(key.to_public().to_string(), TEST_PK);
264 }
265
266 proptest! {
267 #[test]
268 fn wrap_and_unwrap(sk_bytes in proptest::collection::vec(any::<u8>(), ..=32)) {
269 let file_key = FileKey::new(Box::new([7; 16]));
270 let sk = {
271 let mut tmp = [0; 32];
272 tmp[..sk_bytes.len()].copy_from_slice(&sk_bytes);
273 StaticSecret::from(tmp)
274 };
275
276 let res = Recipient(PublicKey::from(&sk))
277 .wrap_file_key(&file_key);
278 prop_assert!(res.is_ok());
279 let (stanzas, labels) = res.unwrap();
280 prop_assert!(labels.is_empty());
281
282 let res = Identity(sk).unwrap_stanzas(&stanzas);
283 prop_assert!(res.is_some());
284 let res = res.unwrap();
285 prop_assert!(res.is_ok());
286 let res = res.unwrap();
287
288 prop_assert_eq!(res.expose_secret(), file_key.expose_secret());
289 }
290 }
291}