1use 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
21fn 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
29fn 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
55pub fn resolve_secret(conn: &mut Client, secret_name: &str) -> anyhow::Result<String> {
59 let gobby_dir = db::gobby_home()?;
60 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 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 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
90pub fn resolve_config_value(value: &str, conn: &mut Client) -> anyhow::Result<String> {
97 if !value.contains("$secret:") && !value.contains("${") {
99 return Ok(value.to_string());
100 }
101
102 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 let fernet = fernet::Fernet::new(&fernet_key).unwrap();
155 let token = fernet.encrypt(b"my-secret-password");
156
157 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}