git_crypt/
rage_support.rs

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