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 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    /// Borrow as a trait object for passing to age's encryptor.
41    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/// An identity that can decrypt age-encrypted data.
50///
51/// Wraps either an age x25519 identity or an SSH private key identity.
52#[derive(Clone)]
53pub enum MurkIdentity {
54    Age(age::x25519::Identity),
55    Ssh(age::ssh::Identity),
56}
57
58impl MurkIdentity {
59    /// Return the public key string for this identity.
60    ///
61    /// For age keys: `age1...`
62    /// For SSH keys: `ssh-ed25519 AAAA...` or `ssh-rsa AAAA...`
63    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    /// Borrow as a trait object for passing to age's decryptor.
76    fn as_dyn(&self) -> &dyn age::Identity {
77        match self {
78            MurkIdentity::Age(id) => id,
79            MurkIdentity::Ssh(id) => id,
80        }
81    }
82}
83
84/// Parse a public key string into a `MurkRecipient`.
85///
86/// Tries x25519 (`age1...`) first, then SSH (`ssh-ed25519 ...` / `ssh-rsa ...`).
87pub fn parse_recipient(pubkey: &str) -> Result<MurkRecipient, CryptoError> {
88    // Try age x25519 first.
89    if let Ok(r) = pubkey.parse::<age::x25519::Recipient>() {
90        return Ok(MurkRecipient::Age(r));
91    }
92
93    // Try SSH.
94    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
103/// Parse a secret key string into a `MurkIdentity`.
104///
105/// Tries age (`AGE-SECRET-KEY-1...`) first, then SSH PEM format.
106/// Encrypted SSH keys are rejected with a clear error.
107pub fn parse_identity(secret_key: &str) -> Result<MurkIdentity, CryptoError> {
108    // Try age x25519 first.
109    if let Ok(id) = secret_key.parse::<age::x25519::Identity>() {
110        return Ok(MurkIdentity::Age(id));
111    }
112
113    // Try SSH PEM.
114    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
132/// Encrypt plaintext bytes to one or more recipients.
133pub 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    // finish() is critical — without it, the output is silently
150    // truncated and undecryptable. No error, just broken data.
151    writer
152        .finish()
153        .map_err(|e| CryptoError::Encrypt(e.to_string()))?;
154
155    Ok(ciphertext)
156}
157
158/// Decrypt ciphertext using an identity (age or SSH key).
159pub 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        // Both recipients can decrypt
214        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    // ── New edge-case tests ──
239
240    #[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        // A valid ssh-ed25519 public key (without comment)
261        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        // Unencrypted ed25519 SSH key from age's test suite.
286        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        // The derived pubkey should be parseable as a recipient.
300        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        // Age keypair.
320        let (age_secret, age_pubkey) = generate_keypair();
321
322        // SSH keypair.
323        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        // Encrypt to both.
328        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        // Both can decrypt.
336        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}