Skip to main content

orca_core/secrets/
store.rs

1use aes_gcm::aead::{Aead, OsRng};
2use aes_gcm::{AeadCore, Aes256Gcm, Key, KeyInit, Nonce};
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::fmt::Write;
7use std::path::{Path, PathBuf};
8
9/// Default master key path.
10fn default_master_key_path() -> PathBuf {
11    dirs_or_home().join("master.key")
12}
13
14/// Returns `~/.orca` or falls back to current dir.
15fn dirs_or_home() -> PathBuf {
16    std::env::var("HOME")
17        .map(|h| PathBuf::from(h).join(".orca"))
18        .unwrap_or_else(|_| PathBuf::from(".orca"))
19}
20
21/// Hex-encode bytes to a string.
22fn hex_encode(data: &[u8]) -> String {
23    let mut s = String::with_capacity(data.len() * 2);
24    for b in data {
25        let _ = write!(s, "{b:02x}");
26    }
27    s
28}
29
30/// Hex-decode a string to bytes.
31fn hex_decode(hex: &str) -> Vec<u8> {
32    (0..hex.len())
33        .step_by(2)
34        .filter_map(|i| u8::from_str_radix(&hex[i..i + 2], 16).ok())
35        .collect()
36}
37
38/// XOR `data` with a repeating `key` (legacy, used only for migration).
39fn xor_bytes(data: &[u8], key: &[u8]) -> Vec<u8> {
40    if key.is_empty() {
41        return data.to_vec();
42    }
43    data.iter()
44        .enumerate()
45        .map(|(i, b)| b ^ key[i % key.len()])
46        .collect()
47}
48
49/// Encrypt plaintext with AES-256-GCM. Returns `"nonce_hex:ciphertext_hex"`.
50fn aes_encrypt(plaintext: &[u8], key: &[u8]) -> Result<String> {
51    let key = Key::<Aes256Gcm>::from_slice(key);
52    let cipher = Aes256Gcm::new(key);
53    let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
54    let ciphertext = cipher
55        .encrypt(&nonce, plaintext)
56        .map_err(|e| anyhow::anyhow!("AES encrypt failed: {e}"))?;
57    Ok(format!(
58        "{}:{}",
59        hex_encode(&nonce),
60        hex_encode(&ciphertext)
61    ))
62}
63
64/// Decrypt `"nonce_hex:ciphertext_hex"` with AES-256-GCM.
65fn aes_decrypt(encoded: &str, key: &[u8]) -> Result<Vec<u8>> {
66    let (nonce_hex, ct_hex) = encoded
67        .split_once(':')
68        .ok_or_else(|| anyhow::anyhow!("missing nonce:ciphertext separator"))?;
69    let nonce_bytes = hex_decode(nonce_hex);
70    let nonce = Nonce::from_slice(&nonce_bytes);
71    let ciphertext = hex_decode(ct_hex);
72    let key = Key::<Aes256Gcm>::from_slice(key);
73    let cipher = Aes256Gcm::new(key);
74    cipher
75        .decrypt(nonce, ciphertext.as_ref())
76        .map_err(|e| anyhow::anyhow!("AES decrypt failed: {e}"))
77}
78
79/// Load or generate the master key at the given path.
80fn load_or_create_key(path: &Path) -> Result<Vec<u8>> {
81    if path.exists() {
82        std::fs::read(path).context("failed to read master key")
83    } else {
84        if let Some(parent) = path.parent() {
85            std::fs::create_dir_all(parent).context("failed to create key directory")?;
86        }
87        let mut key = vec![0u8; 32];
88        use std::io::Read;
89        std::fs::File::open("/dev/urandom")
90            .context("failed to open /dev/urandom")?
91            .read_exact(&mut key)
92            .context("failed to read random bytes")?;
93        std::fs::write(path, &key).context("failed to write master key")?;
94        #[cfg(unix)]
95        {
96            use std::os::unix::fs::PermissionsExt;
97            let perms = std::fs::Permissions::from_mode(0o600);
98            std::fs::set_permissions(path, perms).context("failed to set key permissions")?;
99        }
100        Ok(key)
101    }
102}
103
104/// Decrypt a stored value. Tries AES-256-GCM first, falls back to legacy XOR.
105/// Returns `(plaintext, was_legacy)`.
106fn decrypt_value(stored: &str, key: &[u8]) -> (String, bool) {
107    if let Ok(plain) = aes_decrypt(stored, key) {
108        (String::from_utf8_lossy(&plain).to_string(), false)
109    } else {
110        let encrypted = hex_decode(stored);
111        let decrypted = xor_bytes(&encrypted, key);
112        (String::from_utf8_lossy(&decrypted).to_string(), true)
113    }
114}
115
116/// File-backed secret store using AES-256-GCM encryption.
117///
118/// Secrets are stored as JSON with restrictive file permissions (0600).
119/// Values are encrypted with a 32-byte master key. Legacy XOR-encrypted
120/// values are auto-migrated to AES-256-GCM on first open.
121#[derive(Debug, Serialize, Deserialize)]
122pub struct SecretStore {
123    #[serde(skip)]
124    path: PathBuf,
125    #[serde(skip)]
126    master_key: Vec<u8>,
127    secrets: HashMap<String, String>,
128}
129
130impl SecretStore {
131    /// Open an existing secrets file or create a new empty one.
132    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
133        Self::open_with_key(path, &default_master_key_path())
134    }
135
136    /// Open with a specific master key path (useful for testing).
137    pub fn open_with_key(path: impl AsRef<Path>, key_path: &Path) -> Result<Self> {
138        let path = path.as_ref().to_path_buf();
139        let master_key = load_or_create_key(key_path)?;
140
141        if path.exists() {
142            let data = std::fs::read_to_string(&path).context("failed to read secrets file")?;
143            let mut store: SecretStore =
144                serde_json::from_str(&data).context("failed to parse secrets file")?;
145            store.path = path;
146            store.master_key = master_key.clone();
147            let mut needs_migration = false;
148            store.secrets = store
149                .secrets
150                .into_iter()
151                .map(|(k, v)| {
152                    let (plain, was_legacy) = decrypt_value(&v, &master_key);
153                    if was_legacy {
154                        needs_migration = true;
155                    }
156                    (k, plain)
157                })
158                .collect();
159            if needs_migration {
160                store.save()?;
161            }
162            Ok(store)
163        } else {
164            let store = SecretStore {
165                path,
166                master_key,
167                secrets: HashMap::new(),
168            };
169            store.save()?;
170            Ok(store)
171        }
172    }
173
174    /// Add or update a secret.
175    pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) -> Result<()> {
176        self.secrets.insert(key.into(), value.into());
177        self.save()
178    }
179
180    /// Retrieve a secret by key.
181    pub fn get(&self, key: &str) -> Option<&str> {
182        self.secrets.get(key).map(|s| s.as_str())
183    }
184
185    /// Remove a secret by key. Returns whether the key existed.
186    pub fn remove(&mut self, key: &str) -> Result<bool> {
187        let existed = self.secrets.remove(key).is_some();
188        if existed {
189            self.save()?;
190        }
191        Ok(existed)
192    }
193
194    /// List all secret key names (values are not exposed).
195    pub fn list(&self) -> Vec<String> {
196        let mut keys: Vec<String> = self.secrets.keys().cloned().collect();
197        keys.sort();
198        keys
199    }
200
201    /// Replace `${secrets.KEY}` patterns in env-var values with actual secret values.
202    pub fn resolve_env(&self, env: &HashMap<String, String>) -> HashMap<String, String> {
203        env.iter()
204            .map(|(k, v)| (k.clone(), self.resolve_value(v)))
205            .collect()
206    }
207
208    /// Persist secrets to disk with restrictive permissions.
209    fn save(&self) -> Result<()> {
210        if let Some(parent) = self.path.parent() {
211            std::fs::create_dir_all(parent).context("failed to create secrets directory")?;
212        }
213        let encrypted_secrets: HashMap<String, String> = self
214            .secrets
215            .iter()
216            .map(|(k, v)| {
217                let enc = aes_encrypt(v.as_bytes(), &self.master_key)
218                    .expect("AES encryption must not fail with valid key");
219                (k.clone(), enc)
220            })
221            .collect();
222        let on_disk = serde_json::json!({ "secrets": encrypted_secrets });
223        let data = serde_json::to_string_pretty(&on_disk).context("failed to serialize secrets")?;
224        std::fs::write(&self.path, &data).context("failed to write secrets file")?;
225        #[cfg(unix)]
226        {
227            use std::os::unix::fs::PermissionsExt;
228            let perms = std::fs::Permissions::from_mode(0o600);
229            std::fs::set_permissions(&self.path, perms)
230                .context("failed to set secrets file permissions")?;
231        }
232        Ok(())
233    }
234
235    /// Resolve `${secrets.KEY}` patterns in a single string value.
236    fn resolve_value(&self, value: &str) -> String {
237        let mut result = value.to_string();
238        let mut search_from = 0;
239        loop {
240            let Some(start) = result[search_from..].find("${secrets.") else {
241                break;
242            };
243            let abs_start = search_from + start;
244            let after_prefix = abs_start + "${secrets.".len();
245            let Some(end) = result[after_prefix..].find('}') else {
246                break;
247            };
248            let key = result[after_prefix..after_prefix + end].to_string();
249            if let Some(secret_value) = self.secrets.get(&key) {
250                result = format!(
251                    "{}{}{}",
252                    &result[..abs_start],
253                    secret_value,
254                    &result[after_prefix + end + 1..]
255                );
256            } else {
257                search_from = after_prefix + end + 1;
258            }
259        }
260        result
261    }
262}
263
264#[cfg(test)]
265#[path = "store_tests.rs"]
266mod tests;