git_crypt/
rage.rs

1use std::io::{Cursor, Read, Write};
2
3use crate::crypto::CryptoKey;
4use crate::error::{GitCryptError, Result};
5
6use age::secrecy::SecretString;
7use age::{
8    ssh::{Identity as SshIdentity, Recipient as SshRecipient},
9    Callbacks, DecryptError, Decryptor, EncryptError, Encryptor,
10};
11use rpassword::prompt_password;
12
13pub struct RageManager;
14
15impl RageManager {
16    /// Encrypt the repo's symmetric key for an SSH recipient using age/rage tooling.
17    pub fn encrypt_key_for_ssh_recipient(key: &CryptoKey, recipient: &str) -> Result<Vec<u8>> {
18        let recipient: SshRecipient = recipient
19            .trim()
20            .parse()
21            .map_err(|e| GitCryptError::Age(format!("Invalid SSH recipient: {e:?}")))?;
22
23        let encryptor = Encryptor::with_recipients(std::iter::once(&recipient as _))
24            .map_err(map_encrypt_err)?;
25
26        let mut ciphertext = Vec::new();
27        let mut writer = encryptor
28            .wrap_output(&mut ciphertext)
29            .map_err(GitCryptError::from)?;
30        writer
31            .write_all(key.as_bytes())
32            .map_err(|e| GitCryptError::Io(e))?;
33        writer.finish().map_err(GitCryptError::from)?;
34
35        Ok(ciphertext)
36    }
37
38    /// Decrypt an age-encrypted key blob using an SSH identity.
39    pub fn decrypt_key_with_ssh_identity(
40        encrypted: &[u8],
41        identity_content: &str,
42        identity_label: &str,
43    ) -> Result<CryptoKey> {
44        let cursor = Cursor::new(identity_content.as_bytes());
45        let identity = SshIdentity::from_buffer(cursor, Some(identity_label.to_string()))
46            .map_err(|e| GitCryptError::Age(format!("Invalid SSH identity: {e}")))?;
47
48        let decryptor = Decryptor::new_buffered(Cursor::new(encrypted)).map_err(map_decrypt_err)?;
49        let identity = identity.with_callbacks(PromptCallbacks::new(identity_label));
50
51        let mut reader = decryptor
52            .decrypt(std::iter::once(&identity as &dyn age::Identity))
53            .map_err(map_decrypt_err)?;
54        let mut plaintext = Vec::new();
55        reader
56            .read_to_end(&mut plaintext)
57            .map_err(|e| GitCryptError::Io(e))?;
58
59        CryptoKey::from_bytes(&plaintext)
60    }
61}
62
63fn map_encrypt_err(err: EncryptError) -> GitCryptError {
64    GitCryptError::Age(format!("age encryption failed: {err}"))
65}
66
67fn map_decrypt_err(err: DecryptError) -> GitCryptError {
68    GitCryptError::Age(format!("age decryption failed: {err}"))
69}
70
71#[derive(Clone)]
72struct PromptCallbacks {
73    identity_label: String,
74}
75
76impl PromptCallbacks {
77    fn new(identity_label: &str) -> Self {
78        Self {
79            identity_label: identity_label.to_string(),
80        }
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87    use crate::crypto::{CryptoKey, KEY_SIZE};
88
89    const TEST_SSH_ED25519_PUB: &str =
90        "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHsKLqeplhpW+uObz5dvMgjz1OxfM/XXUB+VHtZ6isGN alice@rust";
91    const TEST_SSH_ED25519_SK: &str = r#"-----BEGIN OPENSSH PRIVATE KEY-----
92b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
93QyNTUxOQAAACB7Ci6nqZYaVvrjm8+XbzII89TsXzP111AflR7WeorBjQAAAJCfEwtqnxML
94agAAAAtzc2gtZWQyNTUxOQAAACB7Ci6nqZYaVvrjm8+XbzII89TsXzP111AflR7WeorBjQ
95AAAEADBJvjZT8X6JRJI8xVq/1aU8nMVgOtVnmdwqWwrSlXG3sKLqeplhpW+uObz5dvMgjz
961OxfM/XXUB+VHtZ6isGNAAAADHN0cjRkQGNhcmJvbgE=
97-----END OPENSSH PRIVATE KEY-----"#;
98
99    fn deterministic_key(byte: u8) -> CryptoKey {
100        let bytes = vec![byte; KEY_SIZE];
101        CryptoKey::from_bytes(&bytes).unwrap()
102    }
103
104    #[test]
105    fn encrypt_decrypt_round_trip() {
106        let key = deterministic_key(0xAA);
107        let ciphertext =
108            RageManager::encrypt_key_for_ssh_recipient(&key, TEST_SSH_ED25519_PUB).unwrap();
109        let decrypted =
110            RageManager::decrypt_key_with_ssh_identity(&ciphertext, TEST_SSH_ED25519_SK, "test")
111                .unwrap();
112        assert_eq!(decrypted.as_bytes(), key.as_bytes());
113    }
114
115    #[test]
116    fn invalid_recipient_is_rejected() {
117        let key = deterministic_key(0x11);
118        let err = RageManager::encrypt_key_for_ssh_recipient(&key, "not-a-key").unwrap_err();
119        match err {
120            GitCryptError::Age(message) => {
121                assert!(message.contains("Invalid SSH recipient"));
122            }
123            other => panic!("expected age error, got {other:?}"),
124        }
125    }
126}
127
128impl Callbacks for PromptCallbacks {
129    fn display_message(&self, message: &str) {
130        eprintln!("{message}");
131    }
132
133    fn confirm(&self, _: &str, _: &str, _: Option<&str>) -> Option<bool> {
134        None
135    }
136
137    fn request_public_string(&self, _: &str) -> Option<String> {
138        None
139    }
140
141    fn request_passphrase(&self, description: &str) -> Option<SecretString> {
142        let prompt = if description.is_empty() {
143            format!("Passphrase for {}", self.identity_label)
144        } else {
145            description.to_string()
146        };
147
148        match prompt_password(format!("{prompt}: ")) {
149            Ok(passphrase) => Some(SecretString::new(passphrase.into())),
150            Err(err) => {
151                eprintln!("Failed to read passphrase: {err}");
152                None
153            }
154        }
155    }
156}