1use std::io::{Read, Write};
2
3#[derive(Debug)]
5pub enum CryptoError {
6 Encrypt(String),
7 Decrypt(String),
8 InvalidKey(String),
9}
10
11impl std::fmt::Display for CryptoError {
12 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
13 match self {
14 CryptoError::Encrypt(msg) => write!(f, "encryption failed: {msg}"),
15 CryptoError::Decrypt(msg) => write!(f, "decryption failed: {msg}"),
16 CryptoError::InvalidKey(msg) => write!(f, "invalid key: {msg}"),
17 }
18 }
19}
20
21#[derive(Clone)]
25pub enum MurkRecipient {
26 Age(age::x25519::Recipient),
27 Ssh(age::ssh::Recipient),
28}
29
30impl std::fmt::Debug for MurkRecipient {
31 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32 match self {
33 MurkRecipient::Age(r) => write!(f, "Age({r})"),
34 MurkRecipient::Ssh(r) => write!(f, "Ssh({r})"),
35 }
36 }
37}
38
39impl MurkRecipient {
40 pub fn as_dyn(&self) -> &dyn age::Recipient {
42 match self {
43 MurkRecipient::Age(r) => r,
44 MurkRecipient::Ssh(r) => r,
45 }
46 }
47}
48
49#[derive(Clone)]
53pub enum MurkIdentity {
54 Age(age::x25519::Identity),
55 Ssh(age::ssh::Identity),
56}
57
58impl MurkIdentity {
59 pub fn pubkey_string(&self) -> Result<String, CryptoError> {
64 match self {
65 MurkIdentity::Age(id) => Ok(id.to_public().to_string()),
66 MurkIdentity::Ssh(id) => {
67 let recipient = age::ssh::Recipient::try_from(id.clone()).map_err(|e| {
68 CryptoError::InvalidKey(format!("cannot derive SSH public key: {e:?}"))
69 })?;
70 Ok(recipient.to_string())
71 }
72 }
73 }
74
75 fn as_dyn(&self) -> &dyn age::Identity {
77 match self {
78 MurkIdentity::Age(id) => id,
79 MurkIdentity::Ssh(id) => id,
80 }
81 }
82}
83
84pub fn parse_recipient(pubkey: &str) -> Result<MurkRecipient, CryptoError> {
88 if let Ok(r) = pubkey.parse::<age::x25519::Recipient>() {
90 return Ok(MurkRecipient::Age(r));
91 }
92
93 if let Ok(r) = pubkey.parse::<age::ssh::Recipient>() {
95 return Ok(MurkRecipient::Ssh(r));
96 }
97
98 Err(CryptoError::InvalidKey(format!(
99 "not a valid age or SSH public key: {pubkey}"
100 )))
101}
102
103pub fn parse_identity(secret_key: &str) -> Result<MurkIdentity, CryptoError> {
108 if let Ok(id) = secret_key.parse::<age::x25519::Identity>() {
110 return Ok(MurkIdentity::Age(id));
111 }
112
113 let reader = std::io::BufReader::new(secret_key.as_bytes());
115 match age::ssh::Identity::from_buffer(reader, None) {
116 Ok(id) => match &id {
117 age::ssh::Identity::Unencrypted(_) => Ok(MurkIdentity::Ssh(id)),
118 age::ssh::Identity::Encrypted(_) => Err(CryptoError::InvalidKey(
119 "encrypted SSH keys are not yet supported — use an unencrypted key or an age key"
120 .into(),
121 )),
122 age::ssh::Identity::Unsupported(k) => Err(CryptoError::InvalidKey(format!(
123 "unsupported SSH key type: {k:?}"
124 ))),
125 },
126 Err(_) => Err(CryptoError::InvalidKey(
127 "not a valid age secret key or SSH private key".into(),
128 )),
129 }
130}
131
132pub fn encrypt(plaintext: &[u8], recipients: &[MurkRecipient]) -> Result<Vec<u8>, CryptoError> {
134 let recipient_refs: Vec<&dyn age::Recipient> =
135 recipients.iter().map(MurkRecipient::as_dyn).collect();
136
137 let encryptor = age::Encryptor::with_recipients(recipient_refs.into_iter())
138 .map_err(|e| CryptoError::Encrypt(e.to_string()))?;
139
140 let mut ciphertext = vec![];
141 let mut writer = encryptor
142 .wrap_output(&mut ciphertext)
143 .map_err(|e| CryptoError::Encrypt(e.to_string()))?;
144
145 writer
146 .write_all(plaintext)
147 .map_err(|e| CryptoError::Encrypt(e.to_string()))?;
148
149 writer
152 .finish()
153 .map_err(|e| CryptoError::Encrypt(e.to_string()))?;
154
155 Ok(ciphertext)
156}
157
158pub fn decrypt(ciphertext: &[u8], identity: &MurkIdentity) -> Result<Vec<u8>, CryptoError> {
160 let decryptor = age::Decryptor::new_buffered(ciphertext)
161 .map_err(|e| CryptoError::Decrypt(e.to_string()))?;
162
163 let mut plaintext = vec![];
164 let mut reader = decryptor
165 .decrypt(std::iter::once(identity.as_dyn()))
166 .map_err(|e| CryptoError::Decrypt(e.to_string()))?;
167
168 reader
169 .read_to_end(&mut plaintext)
170 .map_err(|e| CryptoError::Decrypt(e.to_string()))?;
171
172 Ok(plaintext)
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178 use age::secrecy::ExposeSecret;
179
180 fn generate_keypair() -> (String, String) {
181 let identity = age::x25519::Identity::generate();
182 let secret = identity.to_string();
183 let pubkey = identity.to_public().to_string();
184 (secret.expose_secret().to_string(), pubkey)
185 }
186
187 #[test]
188 fn roundtrip_single_recipient() {
189 let (secret, pubkey) = generate_keypair();
190 let recipient = parse_recipient(&pubkey).unwrap();
191 let identity = parse_identity(&secret).unwrap();
192
193 let plaintext = b"hello darkness";
194 let ciphertext = encrypt(plaintext, &[recipient]).unwrap();
195 let decrypted = decrypt(&ciphertext, &identity).unwrap();
196
197 assert_eq!(decrypted, plaintext);
198 }
199
200 #[test]
201 fn roundtrip_multiple_recipients() {
202 let (secret_a, pubkey_a) = generate_keypair();
203 let (secret_b, pubkey_b) = generate_keypair();
204
205 let recipients = vec![
206 parse_recipient(&pubkey_a).unwrap(),
207 parse_recipient(&pubkey_b).unwrap(),
208 ];
209
210 let plaintext = b"sharing is caring";
211 let ciphertext = encrypt(plaintext, &recipients).unwrap();
212
213 let id_a = parse_identity(&secret_a).unwrap();
215 let id_b = parse_identity(&secret_b).unwrap();
216 assert_eq!(decrypt(&ciphertext, &id_a).unwrap(), plaintext);
217 assert_eq!(decrypt(&ciphertext, &id_b).unwrap(), plaintext);
218 }
219
220 #[test]
221 fn wrong_key_fails() {
222 let (_secret, pubkey) = generate_keypair();
223 let (wrong_secret, _) = generate_keypair();
224
225 let recipient = parse_recipient(&pubkey).unwrap();
226 let wrong_identity = parse_identity(&wrong_secret).unwrap();
227
228 let ciphertext = encrypt(b"none of your business", &[recipient]).unwrap();
229 assert!(decrypt(&ciphertext, &wrong_identity).is_err());
230 }
231
232 #[test]
233 fn invalid_key_strings() {
234 assert!(parse_recipient("sine-loco").is_err());
235 assert!(parse_identity("nihil-et-nemo").is_err());
236 }
237
238 #[test]
241 fn encrypt_empty_plaintext() {
242 let (secret, pubkey) = generate_keypair();
243 let recipient = parse_recipient(&pubkey).unwrap();
244 let identity = parse_identity(&secret).unwrap();
245
246 let ciphertext = encrypt(b"", &[recipient]).unwrap();
247 let decrypted = decrypt(&ciphertext, &identity).unwrap();
248 assert!(decrypted.is_empty());
249 }
250
251 #[test]
252 fn decrypt_corrupted_ciphertext() {
253 let (secret, _) = generate_keypair();
254 let identity = parse_identity(&secret).unwrap();
255 assert!(decrypt(b"this is not valid ciphertext", &identity).is_err());
256 }
257
258 #[test]
259 fn parse_recipient_ssh_ed25519() {
260 let key =
262 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHsKLqeplhpW+uObz5dvMgjz1OxfM/XXUB+VHtZ6isGN";
263 let r = parse_recipient(key);
264 assert!(r.is_ok());
265 assert!(matches!(r.unwrap(), MurkRecipient::Ssh(_)));
266 }
267
268 #[test]
269 fn parse_recipient_age_key() {
270 let (_, pubkey) = generate_keypair();
271 let r = parse_recipient(&pubkey);
272 assert!(r.is_ok());
273 assert!(matches!(r.unwrap(), MurkRecipient::Age(_)));
274 }
275
276 #[test]
277 fn pubkey_string_age() {
278 let (secret, pubkey) = generate_keypair();
279 let id = parse_identity(&secret).unwrap();
280 assert_eq!(id.pubkey_string().unwrap(), pubkey);
281 }
282
283 #[test]
284 fn parse_identity_ssh_unencrypted() {
285 let sk = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACB7Ci6nqZYaVvrjm8+XbzII89TsXzP111AflR7WeorBjQAAAJCfEwtqnxML\nagAAAAtzc2gtZWQyNTUxOQAAACB7Ci6nqZYaVvrjm8+XbzII89TsXzP111AflR7WeorBjQ\nAAAEADBJvjZT8X6JRJI8xVq/1aU8nMVgOtVnmdwqWwrSlXG3sKLqeplhpW+uObz5dvMgjz\n1OxfM/XXUB+VHtZ6isGNAAAADHN0cjRkQGNhcmJvbgE=\n-----END OPENSSH PRIVATE KEY-----";
287 let id = parse_identity(sk);
288 assert!(id.is_ok());
289 assert!(matches!(id.unwrap(), MurkIdentity::Ssh(_)));
290 }
291
292 #[test]
293 fn ssh_identity_pubkey_roundtrip() {
294 let sk = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACB7Ci6nqZYaVvrjm8+XbzII89TsXzP111AflR7WeorBjQAAAJCfEwtqnxML\nagAAAAtzc2gtZWQyNTUxOQAAACB7Ci6nqZYaVvrjm8+XbzII89TsXzP111AflR7WeorBjQ\nAAAEADBJvjZT8X6JRJI8xVq/1aU8nMVgOtVnmdwqWwrSlXG3sKLqeplhpW+uObz5dvMgjz\n1OxfM/XXUB+VHtZ6isGNAAAADHN0cjRkQGNhcmJvbgE=\n-----END OPENSSH PRIVATE KEY-----";
295 let id = parse_identity(sk).unwrap();
296 let pubkey = id.pubkey_string().unwrap();
297 assert!(pubkey.starts_with("ssh-ed25519 "));
298
299 let recipient = parse_recipient(&pubkey);
301 assert!(recipient.is_ok());
302 }
303
304 #[test]
305 fn ssh_encrypt_decrypt_roundtrip() {
306 let sk = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACB7Ci6nqZYaVvrjm8+XbzII89TsXzP111AflR7WeorBjQAAAJCfEwtqnxML\nagAAAAtzc2gtZWQyNTUxOQAAACB7Ci6nqZYaVvrjm8+XbzII89TsXzP111AflR7WeorBjQ\nAAAEADBJvjZT8X6JRJI8xVq/1aU8nMVgOtVnmdwqWwrSlXG3sKLqeplhpW+uObz5dvMgjz\n1OxfM/XXUB+VHtZ6isGNAAAADHN0cjRkQGNhcmJvbgE=\n-----END OPENSSH PRIVATE KEY-----";
307 let id = parse_identity(sk).unwrap();
308 let pubkey = id.pubkey_string().unwrap();
309 let recipient = parse_recipient(&pubkey).unwrap();
310
311 let plaintext = b"ssh secrets";
312 let ciphertext = encrypt(plaintext, &[recipient]).unwrap();
313 let decrypted = decrypt(&ciphertext, &id).unwrap();
314 assert_eq!(decrypted, plaintext);
315 }
316
317 #[test]
318 fn mixed_age_and_ssh_recipients() {
319 let (age_secret, age_pubkey) = generate_keypair();
321
322 let ssh_sk = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACB7Ci6nqZYaVvrjm8+XbzII89TsXzP111AflR7WeorBjQAAAJCfEwtqnxML\nagAAAAtzc2gtZWQyNTUxOQAAACB7Ci6nqZYaVvrjm8+XbzII89TsXzP111AflR7WeorBjQ\nAAAEADBJvjZT8X6JRJI8xVq/1aU8nMVgOtVnmdwqWwrSlXG3sKLqeplhpW+uObz5dvMgjz\n1OxfM/XXUB+VHtZ6isGNAAAADHN0cjRkQGNhcmJvbgE=\n-----END OPENSSH PRIVATE KEY-----";
324 let ssh_id = parse_identity(ssh_sk).unwrap();
325 let ssh_pubkey = ssh_id.pubkey_string().unwrap();
326
327 let recipients = vec![
329 parse_recipient(&age_pubkey).unwrap(),
330 parse_recipient(&ssh_pubkey).unwrap(),
331 ];
332 let plaintext = b"shared between age and ssh";
333 let ciphertext = encrypt(plaintext, &recipients).unwrap();
334
335 let age_id = parse_identity(&age_secret).unwrap();
337 assert_eq!(decrypt(&ciphertext, &age_id).unwrap(), plaintext);
338 assert_eq!(decrypt(&ciphertext, &ssh_id).unwrap(), plaintext);
339 }
340}