1use rand::rngs::OsRng;
25use std::fmt;
26use x25519_dalek::{EphemeralSecret, PublicKey, StaticSecret};
27
28use anubis_core::{
29 format::{FileKey, Stanza, FILE_KEY_BYTES},
30 primitives::{aead_decrypt, aead_encrypt, hkdf},
31 secrecy::ExposeSecret,
32};
33use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
34use bech32::{ToBase32, Variant};
35use zeroize::{Zeroize, Zeroizing};
36
37use crate::{
38 error::{DecryptError, EncryptError},
39 util::read::base64_arg,
40};
41
42const X25519_EPK_LABEL: &str = "X25519";
43const X25519_RECIPIENT_TAG: &str = "X25519";
44
45pub struct Identity(StaticSecret);
49
50impl Identity {
51 pub fn generate() -> Self {
53 let mut rng = OsRng;
54 Identity(StaticSecret::random_from_rng(&mut rng))
55 }
56
57 pub fn to_public(&self) -> Recipient {
59 Recipient(PublicKey::from(&self.0))
60 }
61
62 pub(crate) fn diffie_hellman(&self, epk: &[u8; 32]) -> Result<[u8; 32], DecryptError> {
64 let epk = PublicKey::from(*epk);
65 let shared_secret = self.0.diffie_hellman(&epk);
66 Ok(*shared_secret.as_bytes())
67 }
68
69 fn unwrap_stanza(&self, stanza: &Stanza) -> Option<Result<FileKey, DecryptError>> {
71 if stanza.tag != X25519_RECIPIENT_TAG {
72 return None;
73 }
74
75 if stanza.args.len() != 1 {
77 return Some(Err(DecryptError::InvalidHeader));
78 }
79
80 const ENCRYPTED_FILE_KEY_BYTES: usize = FILE_KEY_BYTES + 16;
81 if stanza.body.len() != ENCRYPTED_FILE_KEY_BYTES {
82 return Some(Err(DecryptError::InvalidHeader));
83 }
84
85 let epk_bytes = match base64_arg::<_, 32, 64>(&stanza.args[0]) {
87 Some(bytes) => bytes,
88 None => return Some(Err(DecryptError::InvalidHeader)),
89 };
90 let epk = PublicKey::from(epk_bytes);
91
92 let shared_secret = self.0.diffie_hellman(&epk);
94
95 let mut salt = Vec::with_capacity(64);
97 salt.extend_from_slice(PublicKey::from(&self.0).as_bytes());
98 salt.extend_from_slice(&epk_bytes);
99
100 let wrap_key = hkdf(
101 &salt,
102 b"age-encryption.org/v1/X25519",
103 shared_secret.as_bytes(),
104 );
105
106 aead_decrypt(&Zeroizing::new(wrap_key), FILE_KEY_BYTES, &stanza.body)
108 .ok()
109 .map(|mut plaintext| {
110 Ok(FileKey::init_with_mut(|file_key| {
111 file_key.copy_from_slice(&plaintext);
112 plaintext.zeroize();
113 }))
114 })
115 }
116}
117
118impl crate::Identity for Identity {
119 fn unwrap_stanza(&self, stanza: &Stanza) -> Option<Result<FileKey, DecryptError>> {
120 Identity::unwrap_stanza(self, stanza)
121 }
122}
123
124#[derive(Clone)]
128pub struct Recipient(PublicKey);
129
130impl Recipient {
131 pub(crate) fn public_key(&self) -> &PublicKey {
133 &self.0
134 }
135
136 fn wrap_file_key(&self, file_key: &FileKey) -> Result<Vec<Stanza>, EncryptError> {
138 let mut rng = OsRng;
139
140 let esk = EphemeralSecret::random_from_rng(&mut rng);
142 let epk = PublicKey::from(&esk);
143
144 let shared_secret = esk.diffie_hellman(&self.0);
146
147 let mut salt = Vec::with_capacity(64);
149 salt.extend_from_slice(self.0.as_bytes());
150 salt.extend_from_slice(epk.as_bytes());
151
152 let wrap_key = hkdf(
153 &salt,
154 b"age-encryption.org/v1/X25519",
155 shared_secret.as_bytes(),
156 );
157
158 let encrypted_file_key = aead_encrypt(&Zeroizing::new(wrap_key), file_key.expose_secret());
160
161 Ok(vec![Stanza {
162 tag: X25519_RECIPIENT_TAG.to_string(),
163 args: vec![BASE64_STANDARD_NO_PAD.encode(epk.as_bytes())],
164 body: encrypted_file_key,
165 }])
166 }
167}
168
169impl crate::Recipient for Recipient {
170 fn wrap_file_key(
171 &self,
172 file_key: &FileKey,
173 ) -> Result<(Vec<Stanza>, std::collections::HashSet<String>), EncryptError> {
174 Ok((self.wrap_file_key(file_key)?, std::collections::HashSet::new()))
176 }
177}
178
179impl fmt::Display for Recipient {
180 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
181 write!(
182 f,
183 "age1{}",
184 bech32::encode(
185 "x25519",
186 self.0.as_bytes().to_base32(),
187 Variant::Bech32
188 )
189 .map_err(|_| fmt::Error)?
190 )
191 }
192}
193
194impl fmt::Display for Identity {
195 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
196 write!(
197 f,
198 "AGE-SECRET-KEY-1{}",
199 bech32::encode(
200 "",
201 self.0.to_bytes().to_base32(),
202 Variant::Bech32
203 )
204 .map_err(|_| fmt::Error)?
205 .to_uppercase()
206 )
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn x25519_round_trip() {
216 let identity = Identity::generate();
217 let recipient = identity.to_public();
218
219 let file_key = FileKey::new(Box::new([42; 16]));
220
221 let stanzas = recipient.wrap_file_key(&file_key).unwrap();
223 assert_eq!(stanzas.len(), 1);
224 assert_eq!(stanzas[0].tag, X25519_RECIPIENT_TAG);
225
226 let decrypted = identity.unwrap_stanza(&stanzas[0]).unwrap().unwrap();
228 assert_eq!(decrypted.expose_secret(), file_key.expose_secret());
229 }
230
231 #[test]
232 fn x25519_public_key_encoding() {
233 let identity = Identity::generate();
234 let recipient = identity.to_public();
235
236 let encoded = recipient.to_string();
237 assert!(encoded.starts_with("age1"));
238 assert!(encoded.len() > 10);
239 }
240}