Skip to main content

gobby_code/
secrets.rs

1//! Fernet decryption of gobby's SecretStore.
2//!
3//! Replicates the Python chain:
4//! 1. Read ~/.gobby/machine_id (plain text)
5//! 2. Read ~/.gobby/.secret_salt (16 raw bytes)
6//! 3. PBKDF2-HMAC-SHA256(password=machine_id, salt=salt, iterations=600_000, length=32)
7//! 4. base64url_encode(key_bytes) → Fernet key
8//! 5. Fernet(key).decrypt(encrypted_value) → plaintext
9//!
10//! Source: src/gobby/storage/secrets.py, src/gobby/utils/machine_id.py
11
12use anyhow::{Context as _, bail};
13use base64::Engine as _;
14use base64::engine::general_purpose::URL_SAFE;
15use pbkdf2::pbkdf2_hmac;
16use postgres::Client;
17use sha2::Sha256;
18
19use crate::db;
20
21/// Derive a Fernet key from machine_id + salt using PBKDF2-HMAC-SHA256.
22/// Matches Python: _derive_fernet_key() in storage/secrets.py
23fn derive_fernet_key(machine_id: &str, salt: &[u8]) -> String {
24    let mut key_bytes = [0u8; 32];
25    pbkdf2_hmac::<Sha256>(machine_id.as_bytes(), salt, 600_000, &mut key_bytes);
26    URL_SAFE.encode(key_bytes)
27}
28
29/// Decrypt a Fernet-encrypted token string.
30fn decrypt_fernet(key: &str, token: &str) -> anyhow::Result<String> {
31    let fernet = fernet::Fernet::new(key).ok_or_else(|| anyhow::anyhow!("invalid Fernet key"))?;
32    let plaintext = fernet
33        .decrypt(token)
34        .map_err(|_| anyhow::anyhow!("Fernet decryption failed (machine ID may have changed)"))?;
35    String::from_utf8(plaintext).context("decrypted secret is not valid UTF-8")
36}
37
38fn resolve_env_pattern(value: &str) -> anyhow::Result<Option<String>> {
39    if !(value.starts_with("${") && value.ends_with('}')) {
40        return Ok(None);
41    }
42
43    let var_name = &value[2..value.len() - 1];
44    if let Some((var, default)) = var_name.split_once(":-") {
45        return Ok(Some(
46            std::env::var(var).unwrap_or_else(|_| default.to_string()),
47        ));
48    }
49
50    std::env::var(var_name)
51        .map(Some)
52        .with_context(|| format!("environment variable {var_name} not set"))
53}
54
55/// Resolve a secret by name from the secrets table in the PostgreSQL hub.
56///
57/// Secret names are normalized to lowercase (matching Python SecretStore._normalize_name).
58pub fn resolve_secret(conn: &mut Client, secret_name: &str) -> anyhow::Result<String> {
59    let gobby_dir = db::gobby_home()?;
60    // Read machine_id
61    let machine_id_path = gobby_dir.join("machine_id");
62    let machine_id = std::fs::read_to_string(&machine_id_path)
63        .with_context(|| format!("failed to read {}", machine_id_path.display()))?
64        .trim()
65        .to_string();
66    if machine_id.is_empty() {
67        bail!("machine_id file is empty");
68    }
69
70    // Read salt (16 raw bytes)
71    let salt_path = gobby_dir.join(".secret_salt");
72    let salt = std::fs::read(&salt_path)
73        .with_context(|| format!("failed to read {}", salt_path.display()))?;
74
75    // Derive Fernet key
76    let fernet_key = derive_fernet_key(&machine_id, &salt);
77
78    let name = secret_name.trim().to_lowercase();
79    let row = conn
80        .query_one(
81            "SELECT encrypted_value FROM secrets WHERE name = $1",
82            &[&name],
83        )
84        .with_context(|| format!("secret '{name}' not found in secrets table"))?;
85    let encrypted: String = row.try_get("encrypted_value")?;
86
87    decrypt_fernet(&fernet_key, &encrypted)
88}
89
90/// Resolve `$secret:NAME` and `${VAR}` patterns in a config value.
91///
92/// - `$secret:NAME` → decrypt from secrets table
93/// - `${VAR}` → environment variable
94/// - `${VAR:-default}` → environment variable with default
95/// - plain text → returned unchanged
96pub fn resolve_config_value(value: &str, conn: &mut Client) -> anyhow::Result<String> {
97    // Fast path: no patterns
98    if !value.contains("$secret:") && !value.contains("${") {
99        return Ok(value.to_string());
100    }
101
102    // $secret:NAME pattern
103    if let Some(name) = value.strip_prefix("$secret:") {
104        return resolve_secret(conn, name);
105    }
106
107    if let Some(resolved) = resolve_env_pattern(value)? {
108        return Ok(resolved);
109    }
110
111    Ok(value.to_string())
112}
113
114#[cfg(test)]
115fn resolve_config_value_without_secrets(value: &str) -> anyhow::Result<String> {
116    if value.contains("$secret:") {
117        bail!("secret resolution requires a PostgreSQL connection");
118    }
119    if !value.contains("${") {
120        return Ok(value.to_string());
121    }
122    if let Some(resolved) = resolve_env_pattern(value)? {
123        return Ok(resolved);
124    }
125    Ok(value.to_string())
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn test_derive_fernet_key_deterministic() {
134        let key1 = derive_fernet_key("test-machine-id", b"0123456789abcdef");
135        let key2 = derive_fernet_key("test-machine-id", b"0123456789abcdef");
136        assert_eq!(key1, key2);
137        assert!(!key1.is_empty());
138    }
139
140    #[test]
141    fn test_derive_fernet_key_different_salt() {
142        let key1 = derive_fernet_key("test-machine-id", b"0123456789abcdef");
143        let key2 = derive_fernet_key("test-machine-id", b"fedcba9876543210");
144        assert_ne!(key1, key2);
145    }
146
147    #[test]
148    fn test_decrypt_roundtrip() {
149        let machine_id = "test-machine-42";
150        let salt = b"abcdef0123456789";
151        let fernet_key = derive_fernet_key(machine_id, salt);
152
153        // Encrypt with the same key
154        let fernet = fernet::Fernet::new(&fernet_key).unwrap();
155        let token = fernet.encrypt(b"my-secret-password");
156
157        // Decrypt
158        let decrypted = decrypt_fernet(&fernet_key, &token).unwrap();
159        assert_eq!(decrypted, "my-secret-password");
160    }
161
162    #[test]
163    fn test_resolve_config_value_passthrough() {
164        let result = resolve_config_value_without_secrets("http://localhost:8474").unwrap();
165        assert_eq!(result, "http://localhost:8474");
166    }
167
168    #[test]
169    fn test_resolve_config_value_env_var() {
170        unsafe { std::env::set_var("GCODE_TEST_VAR_123", "hello") };
171        let result = resolve_config_value_without_secrets("${GCODE_TEST_VAR_123}").unwrap();
172        assert_eq!(result, "hello");
173        unsafe { std::env::remove_var("GCODE_TEST_VAR_123") };
174    }
175
176    #[test]
177    fn test_resolve_config_value_env_default() {
178        unsafe { std::env::remove_var("GCODE_NONEXISTENT_VAR_999") };
179        let result =
180            resolve_config_value_without_secrets("${GCODE_NONEXISTENT_VAR_999:-fallback}").unwrap();
181        assert_eq!(result, "fallback");
182    }
183}