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