Skip to main content

orca_core/secrets/
store.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::fmt::Write;
5use std::path::{Path, PathBuf};
6
7/// Default master key path.
8fn default_master_key_path() -> PathBuf {
9    dirs_or_home().join("master.key")
10}
11
12/// Returns `~/.orca` or falls back to current dir.
13fn dirs_or_home() -> PathBuf {
14    std::env::var("HOME")
15        .map(|h| PathBuf::from(h).join(".orca"))
16        .unwrap_or_else(|_| PathBuf::from(".orca"))
17}
18
19/// XOR `data` with a repeating `key`.
20fn xor_bytes(data: &[u8], key: &[u8]) -> Vec<u8> {
21    if key.is_empty() {
22        return data.to_vec();
23    }
24    data.iter()
25        .enumerate()
26        .map(|(i, b)| b ^ key[i % key.len()])
27        .collect()
28}
29
30/// Hex-encode bytes to a string.
31fn base64_encode(data: &[u8]) -> String {
32    let mut s = String::with_capacity(data.len() * 2);
33    for b in data {
34        let _ = write!(s, "{b:02x}");
35    }
36    s
37}
38
39/// Hex-decode a string to bytes.
40fn base64_decode(hex: &str) -> Vec<u8> {
41    (0..hex.len())
42        .step_by(2)
43        .filter_map(|i| u8::from_str_radix(&hex[i..i + 2], 16).ok())
44        .collect()
45}
46
47/// Load or generate the master key at the given path.
48fn load_or_create_key(path: &Path) -> Result<Vec<u8>> {
49    if path.exists() {
50        std::fs::read(path).context("failed to read master key")
51    } else {
52        if let Some(parent) = path.parent() {
53            std::fs::create_dir_all(parent).context("failed to create key directory")?;
54        }
55        let mut key = vec![0u8; 32];
56        use std::io::Read;
57        std::fs::File::open("/dev/urandom")
58            .context("failed to open /dev/urandom")?
59            .read_exact(&mut key)
60            .context("failed to read random bytes")?;
61        std::fs::write(path, &key).context("failed to write master key")?;
62        #[cfg(unix)]
63        {
64            use std::os::unix::fs::PermissionsExt;
65            let perms = std::fs::Permissions::from_mode(0o600);
66            std::fs::set_permissions(path, perms).context("failed to set key permissions")?;
67        }
68        Ok(key)
69    }
70}
71
72/// Simple file-backed secret store.
73///
74/// Secrets are stored as a JSON file with restrictive file permissions (0600).
75/// Values are XOR-encrypted with a master key to prevent plaintext exposure.
76#[derive(Debug, Serialize, Deserialize)]
77pub struct SecretStore {
78    #[serde(skip)]
79    path: PathBuf,
80    #[serde(skip)]
81    master_key: Vec<u8>,
82    secrets: HashMap<String, String>,
83}
84
85impl SecretStore {
86    /// Open an existing secrets file or create a new empty one.
87    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
88        Self::open_with_key(path, &default_master_key_path())
89    }
90
91    /// Open with a specific master key path (useful for testing).
92    pub fn open_with_key(path: impl AsRef<Path>, key_path: &Path) -> Result<Self> {
93        let path = path.as_ref().to_path_buf();
94        let master_key = load_or_create_key(key_path)?;
95
96        if path.exists() {
97            let data = std::fs::read_to_string(&path).context("failed to read secrets file")?;
98            let mut store: SecretStore =
99                serde_json::from_str(&data).context("failed to parse secrets file")?;
100            store.path = path;
101            store.master_key = master_key.clone();
102            // Decrypt values in memory
103            store.secrets = store
104                .secrets
105                .into_iter()
106                .map(|(k, v)| {
107                    let encrypted = base64_decode(&v);
108                    let decrypted = xor_bytes(&encrypted, &master_key);
109                    (k, String::from_utf8_lossy(&decrypted).to_string())
110                })
111                .collect();
112            Ok(store)
113        } else {
114            let store = SecretStore {
115                path,
116                master_key,
117                secrets: HashMap::new(),
118            };
119            store.save()?;
120            Ok(store)
121        }
122    }
123
124    /// Add or update a secret.
125    pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) -> Result<()> {
126        self.secrets.insert(key.into(), value.into());
127        self.save()
128    }
129
130    /// Retrieve a secret by key.
131    pub fn get(&self, key: &str) -> Option<&str> {
132        self.secrets.get(key).map(|s| s.as_str())
133    }
134
135    /// Remove a secret by key. Returns whether the key existed.
136    pub fn remove(&mut self, key: &str) -> Result<bool> {
137        let existed = self.secrets.remove(key).is_some();
138        if existed {
139            self.save()?;
140        }
141        Ok(existed)
142    }
143
144    /// List all secret key names (values are not exposed).
145    pub fn list(&self) -> Vec<String> {
146        let mut keys: Vec<String> = self.secrets.keys().cloned().collect();
147        keys.sort();
148        keys
149    }
150
151    /// Replace `${secrets.KEY}` patterns in env-var values with actual secret values.
152    ///
153    /// Unknown keys are left as-is so the caller can detect unresolved references.
154    pub fn resolve_env(&self, env: &HashMap<String, String>) -> HashMap<String, String> {
155        env.iter()
156            .map(|(k, v)| (k.clone(), self.resolve_value(v)))
157            .collect()
158    }
159
160    /// Persist secrets to disk with restrictive permissions.
161    /// Values are XOR-encrypted with the master key before serialization.
162    fn save(&self) -> Result<()> {
163        if let Some(parent) = self.path.parent() {
164            std::fs::create_dir_all(parent).context("failed to create secrets directory")?;
165        }
166
167        // Encrypt values for serialization
168        let encrypted_secrets: HashMap<String, String> = self
169            .secrets
170            .iter()
171            .map(|(k, v)| {
172                let encrypted = xor_bytes(v.as_bytes(), &self.master_key);
173                (k.clone(), base64_encode(&encrypted))
174            })
175            .collect();
176
177        let on_disk = serde_json::json!({ "secrets": encrypted_secrets });
178        let data = serde_json::to_string_pretty(&on_disk).context("failed to serialize secrets")?;
179        std::fs::write(&self.path, &data).context("failed to write secrets file")?;
180
181        // Set file permissions to 0600 on Unix
182        #[cfg(unix)]
183        {
184            use std::os::unix::fs::PermissionsExt;
185            let perms = std::fs::Permissions::from_mode(0o600);
186            std::fs::set_permissions(&self.path, perms)
187                .context("failed to set secrets file permissions")?;
188        }
189
190        Ok(())
191    }
192
193    /// Resolve `${secrets.KEY}` patterns in a single string value.
194    fn resolve_value(&self, value: &str) -> String {
195        let mut result = value.to_string();
196        let mut search_from = 0;
197        loop {
198            let Some(start) = result[search_from..].find("${secrets.") else {
199                break;
200            };
201            let abs_start = search_from + start;
202            let after_prefix = abs_start + "${secrets.".len();
203            let Some(end) = result[after_prefix..].find('}') else {
204                break;
205            };
206            let key = result[after_prefix..after_prefix + end].to_string();
207            if let Some(secret_value) = self.secrets.get(&key) {
208                result = format!(
209                    "{}{}{}",
210                    &result[..abs_start],
211                    secret_value,
212                    &result[after_prefix + end + 1..]
213                );
214                // Don't advance search_from — replacement might be shorter
215            } else {
216                // Key not found, skip past this pattern to avoid infinite loop
217                search_from = after_prefix + end + 1;
218            }
219        }
220        result
221    }
222}
223
224#[cfg(test)]
225#[path = "store_tests.rs"]
226mod tests;