Skip to main content

murk_cli/
crypto.rs

1use std::io::{Read, Write};
2
3/// Errors that can occur during crypto operations.
4#[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/// A recipient that can receive age-encrypted data.
22///
23/// Wraps either an age x25519 recipient or an SSH public key recipient.
24#[derive(Clone)]
25pub enum MurkRecipient {
26    Age(age::x25519::Recipient),
27    Ssh(age::ssh::Recipient),
28}
29
30impl MurkRecipient {
31    /// Borrow as a trait object for passing to age's encryptor.
32    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/// An identity that can decrypt age-encrypted data.
41///
42/// Wraps either an age x25519 identity or an SSH private key identity.
43#[derive(Clone)]
44pub enum MurkIdentity {
45    Age(age::x25519::Identity),
46    Ssh(age::ssh::Identity),
47}
48
49impl MurkIdentity {
50    /// Return the public key string for this identity.
51    ///
52    /// For age keys: `age1...`
53    /// For SSH keys: `ssh-ed25519 AAAA...` or `ssh-rsa AAAA...`
54    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    /// Borrow as a trait object for passing to age's decryptor.
67    fn as_dyn(&self) -> &dyn age::Identity {
68        match self {
69            MurkIdentity::Age(id) => id,
70            MurkIdentity::Ssh(id) => id,
71        }
72    }
73}
74
75/// Parse a public key string into a `MurkRecipient`.
76///
77/// Tries x25519 (`age1...`) first, then SSH (`ssh-ed25519 ...` / `ssh-rsa ...`).
78pub fn parse_recipient(pubkey: &str) -> Result<MurkRecipient, CryptoError> {
79    // Try age x25519 first.
80    if let Ok(r) = pubkey.parse::<age::x25519::Recipient>() {
81        return Ok(MurkRecipient::Age(r));
82    }
83
84    // Try SSH.
85    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
94/// Parse a secret key string into a `MurkIdentity`.
95///
96/// Tries age (`AGE-SECRET-KEY-1...`) first, then SSH PEM format.
97/// Encrypted SSH keys are rejected with a clear error.
98pub fn parse_identity(secret_key: &str) -> Result<MurkIdentity, CryptoError> {
99    // Try age x25519 first.
100    if let Ok(id) = secret_key.parse::<age::x25519::Identity>() {
101        return Ok(MurkIdentity::Age(id));
102    }
103
104    // Try SSH PEM.
105    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
123/// Encrypt plaintext bytes to one or more recipients.
124pub 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    // finish() is critical — without it, the output is silently
141    // truncated and undecryptable. No error, just broken data.
142    writer
143        .finish()
144        .map_err(|e| CryptoError::Encrypt(e.to_string()))?;
145
146    Ok(ciphertext)
147}
148
149/// Decrypt ciphertext using an identity (age or SSH key).
150pub 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        // Both recipients can decrypt
205        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    // ── New edge-case tests ──
230
231    #[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        // A valid ssh-ed25519 public key (without comment)
252        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        // Unencrypted ed25519 SSH key from age's test suite.
277        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        // The derived pubkey should be parseable as a recipient.
291        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        // Age keypair.
311        let (age_secret, age_pubkey) = generate_keypair();
312
313        // SSH keypair.
314        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        // Encrypt to both.
319        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        // Both can decrypt.
327        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}