orca_core/secrets/
store.rs1use 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
9fn default_master_key_path() -> PathBuf {
11 dirs_or_home().join("master.key")
12}
13
14fn 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
21fn 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
30fn 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
38fn 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
49fn 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
64fn 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
79fn 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
104fn 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#[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 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
133 Self::open_with_key(path, &default_master_key_path())
134 }
135
136 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 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 pub fn get(&self, key: &str) -> Option<&str> {
182 self.secrets.get(key).map(|s| s.as_str())
183 }
184
185 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 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 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 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 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;