Skip to main content

fnox_core/providers/
age.rs

1use crate::env;
2use crate::error::{FnoxError, Result};
3use async_trait::async_trait;
4use std::io::Read;
5use std::path::PathBuf;
6
7pub fn env_dependencies() -> &'static [&'static str] {
8    &[]
9}
10
11pub struct AgeEncryptionProvider {
12    recipients: Vec<String>,
13    key_file: Option<PathBuf>,
14}
15
16impl AgeEncryptionProvider {
17    pub fn new(recipients: Vec<String>, key_file: Option<String>) -> Result<Self> {
18        Ok(Self {
19            recipients,
20            key_file: key_file.map(|k| PathBuf::from(shellexpand::tilde(&k).to_string())),
21        })
22    }
23}
24
25#[async_trait]
26impl crate::providers::Provider for AgeEncryptionProvider {
27    fn capabilities(&self) -> Vec<crate::providers::ProviderCapability> {
28        vec![crate::providers::ProviderCapability::Encryption]
29    }
30
31    async fn encrypt(&self, plaintext: &str) -> Result<String> {
32        use std::io::Write;
33
34        if self.recipients.is_empty() {
35            return Err(FnoxError::AgeNotConfigured);
36        }
37
38        // Parse recipients - try both SSH and native age formats
39        let mut parsed_recipients: Vec<Box<dyn age::Recipient + Send + Sync>> = Vec::new();
40
41        for recipient in &self.recipients {
42            // Try parsing as SSH recipient first
43            if let Ok(ssh_recipient) = recipient.parse::<age::ssh::Recipient>() {
44                parsed_recipients.push(Box::new(ssh_recipient));
45                continue;
46            }
47
48            // Fall back to native age recipient
49            match recipient.parse::<age::x25519::Recipient>() {
50                Ok(age_recipient) => {
51                    parsed_recipients.push(Box::new(age_recipient));
52                }
53                Err(e) => {
54                    return Err(FnoxError::AgeEncryptionFailed {
55                        details: format!("Failed to parse recipient '{}': {}", recipient, e),
56                    });
57                }
58            }
59        }
60
61        if parsed_recipients.is_empty() {
62            return Err(FnoxError::AgeNotConfigured);
63        }
64
65        // Create encryptor with parsed recipients
66        let encryptor = age::Encryptor::with_recipients(
67            parsed_recipients
68                .iter()
69                .map(|r| r.as_ref() as &dyn age::Recipient),
70        )
71        .expect("we provided at least one recipient");
72
73        // Encrypt the plaintext
74        let mut encrypted = vec![];
75        let mut writer =
76            encryptor
77                .wrap_output(&mut encrypted)
78                .map_err(|e| FnoxError::AgeEncryptionFailed {
79                    details: format!("Failed to create encrypted writer: {}", e),
80                })?;
81
82        writer
83            .write_all(plaintext.as_bytes())
84            .map_err(|e| FnoxError::AgeEncryptionFailed {
85                details: format!("Failed to write plaintext: {}", e),
86            })?;
87
88        writer
89            .finish()
90            .map_err(|e| FnoxError::AgeEncryptionFailed {
91                details: format!("Failed to finalize encryption: {}", e),
92            })?;
93
94        // Base64 encode the encrypted output
95        use base64::Engine;
96        let encrypted_base64 = base64::engine::general_purpose::STANDARD.encode(&encrypted);
97
98        Ok(encrypted_base64)
99    }
100
101    async fn get_secret(&self, value: &str) -> Result<String> {
102        // value contains the encrypted blob (might be base64 encoded or raw)
103
104        // Try to decode as base64 first, if that fails, treat as raw bytes
105        let encrypted_bytes =
106            match base64::Engine::decode(&base64::engine::general_purpose::STANDARD, value) {
107                Ok(bytes) => bytes,
108                Err(_) => {
109                    // Not base64 encoded, treat as raw bytes
110                    value.as_bytes().to_vec()
111                }
112            };
113
114        // Priority for key file:
115        // 1. FNOX_AGE_KEY env var (inline key content)
116        // 2. self.key_file (from provider config)
117        // 3. Settings age_key_file (from CLI flag - deprecated)
118        // 4. Default path (~/.config/fnox/age.txt)
119        let (identity_content, key_file_path_opt) = if let Some(ref age_key) = *env::FNOX_AGE_KEY {
120            // Use the key directly from the environment variable
121            (age_key.clone(), None)
122        } else {
123            // Determine which key file to use
124            let key_file_path = if let Some(ref config_key_file) = self.key_file {
125                // Use key file from provider config
126                config_key_file.clone()
127            } else {
128                // Get settings which merges CLI flags, env vars, and defaults
129                let settings = crate::settings::Settings::get();
130
131                if let Some(ref age_key_file) = settings.age_key_file {
132                    // Use age key file from settings (CLI flag or env var - deprecated)
133                    age_key_file.clone()
134                } else {
135                    // Try default path
136                    let default_key_path = env::FNOX_CONFIG_DIR.join("age.txt");
137                    if !default_key_path.exists() {
138                        return Err(FnoxError::AgeIdentityNotFound {
139                            path: default_key_path,
140                        });
141                    }
142                    default_key_path
143                }
144            };
145
146            // Load identity file content
147            let content = std::fs::read_to_string(&key_file_path).map_err(|e| {
148                FnoxError::AgeIdentityReadFailed {
149                    path: key_file_path.clone(),
150                    source: e,
151                }
152            })?;
153
154            (content, Some(key_file_path))
155        };
156
157        // Try parsing as SSH identity first, then fall back to age identity file
158        let identities = {
159            let mut cursor = std::io::Cursor::new(identity_content.as_bytes());
160
161            // First try to parse as SSH identity
162            match age::ssh::Identity::from_buffer(
163                &mut cursor,
164                key_file_path_opt
165                    .as_ref()
166                    .map(|p| p.to_string_lossy().to_string()),
167            ) {
168                Ok(ssh_identity) => {
169                    // SSH identity parsed successfully
170                    vec![Box::new(ssh_identity) as Box<dyn age::Identity>]
171                }
172                Err(_) => {
173                    // Not an SSH identity, try age identity file
174                    cursor.set_position(0);
175                    age::IdentityFile::from_buffer(cursor)
176                        .map_err(|e| FnoxError::AgeIdentityParseFailed {
177                            details: e.to_string(),
178                        })?
179                        .into_identities()
180                        .map_err(|e| FnoxError::AgeIdentityParseFailed {
181                            details: e.to_string(),
182                        })?
183                }
184            }
185        };
186
187        let decryptor = age::Decryptor::new(encrypted_bytes.as_slice()).map_err(|e| {
188            FnoxError::AgeDecryptionFailed {
189                details: format!("Failed to create decryptor: {}", e),
190            }
191        })?;
192
193        let mut reader = decryptor
194            .decrypt(identities.iter().map(|i| i.as_ref() as &dyn age::Identity))
195            .map_err(|e| FnoxError::AgeDecryptionFailed {
196                details: e.to_string(),
197            })?;
198
199        let mut decrypted = vec![];
200        reader
201            .read_to_end(&mut decrypted)
202            .map_err(|e| FnoxError::AgeDecryptionFailed {
203                details: format!("Failed to read decrypted data: {}", e),
204            })?;
205
206        String::from_utf8(decrypted).map_err(|e| FnoxError::AgeDecryptionFailed {
207            details: format!("Failed to decode UTF-8: {}", e),
208        })
209    }
210}