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 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 = 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}