git_crypt/
rage_support.rs1use 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 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 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}