use anyhow::{Context as _, bail};
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE;
use pbkdf2::pbkdf2_hmac;
use postgres::Client;
use sha2::Sha256;
use crate::db;
fn derive_fernet_key(machine_id: &str, salt: &[u8]) -> String {
let mut key_bytes = [0u8; 32];
pbkdf2_hmac::<Sha256>(machine_id.as_bytes(), salt, 600_000, &mut key_bytes);
URL_SAFE.encode(key_bytes)
}
fn decrypt_fernet(key: &str, token: &str) -> anyhow::Result<String> {
let fernet = fernet::Fernet::new(key).ok_or_else(|| anyhow::anyhow!("invalid Fernet key"))?;
let plaintext = fernet
.decrypt(token)
.map_err(|_| anyhow::anyhow!("Fernet decryption failed (machine ID may have changed)"))?;
String::from_utf8(plaintext).context("decrypted secret is not valid UTF-8")
}
pub fn resolve_secret(conn: &mut Client, secret_name: &str) -> anyhow::Result<String> {
let gobby_dir = db::gobby_home()?;
let machine_id_path = gobby_dir.join("machine_id");
let machine_id = std::fs::read_to_string(&machine_id_path)
.with_context(|| format!("failed to read {}", machine_id_path.display()))?
.trim()
.to_string();
if machine_id.is_empty() {
bail!("machine_id file is empty");
}
let salt_path = gobby_dir.join(".secret_salt");
let salt = std::fs::read(&salt_path)
.with_context(|| format!("failed to read {}", salt_path.display()))?;
let fernet_key = derive_fernet_key(&machine_id, &salt);
let name = secret_name.trim().to_lowercase();
let row = conn
.query_one(
"SELECT encrypted_value FROM secrets WHERE name = $1",
&[&name],
)
.with_context(|| format!("secret '{name}' not found in secrets table"))?;
let encrypted: String = row.try_get("encrypted_value")?;
decrypt_fernet(&fernet_key, &encrypted)
}
pub fn resolve_config_value(value: &str, conn: &mut Client) -> anyhow::Result<String> {
if !value.contains("$secret:") && !value.contains("${") {
return Ok(value.to_string());
}
if let Some(name) = value.strip_prefix("$secret:") {
return resolve_secret(conn, name);
}
if value.starts_with("${") && value.ends_with('}') {
let var_name = &value[2..value.len() - 1];
if let Some((var, default)) = var_name.split_once(":-") {
return Ok(std::env::var(var).unwrap_or_else(|_| default.to_string()));
}
return std::env::var(var_name)
.with_context(|| format!("environment variable {var_name} not set"));
}
Ok(value.to_string())
}
#[cfg(test)]
fn resolve_config_value_without_secrets(value: &str) -> anyhow::Result<String> {
if value.contains("$secret:") {
bail!("secret resolution requires a PostgreSQL connection");
}
if !value.contains("${") {
return Ok(value.to_string());
}
if value.starts_with("${") && value.ends_with('}') {
let var_name = &value[2..value.len() - 1];
if let Some((var, default)) = var_name.split_once(":-") {
return Ok(std::env::var(var).unwrap_or_else(|_| default.to_string()));
}
return std::env::var(var_name)
.with_context(|| format!("environment variable {var_name} not set"));
}
Ok(value.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_derive_fernet_key_deterministic() {
let key1 = derive_fernet_key("test-machine-id", b"0123456789abcdef");
let key2 = derive_fernet_key("test-machine-id", b"0123456789abcdef");
assert_eq!(key1, key2);
assert!(!key1.is_empty());
}
#[test]
fn test_derive_fernet_key_different_salt() {
let key1 = derive_fernet_key("test-machine-id", b"0123456789abcdef");
let key2 = derive_fernet_key("test-machine-id", b"fedcba9876543210");
assert_ne!(key1, key2);
}
#[test]
fn test_decrypt_roundtrip() {
let machine_id = "test-machine-42";
let salt = b"abcdef0123456789";
let fernet_key = derive_fernet_key(machine_id, salt);
let fernet = fernet::Fernet::new(&fernet_key).unwrap();
let token = fernet.encrypt(b"my-secret-password");
let decrypted = decrypt_fernet(&fernet_key, &token).unwrap();
assert_eq!(decrypted, "my-secret-password");
}
#[test]
fn test_resolve_config_value_passthrough() {
let result = resolve_config_value_without_secrets("http://localhost:8474").unwrap();
assert_eq!(result, "http://localhost:8474");
}
#[test]
fn test_resolve_config_value_env_var() {
unsafe { std::env::set_var("GCODE_TEST_VAR_123", "hello") };
let result = resolve_config_value_without_secrets("${GCODE_TEST_VAR_123}").unwrap();
assert_eq!(result, "hello");
unsafe { std::env::remove_var("GCODE_TEST_VAR_123") };
}
#[test]
fn test_resolve_config_value_env_default() {
unsafe { std::env::remove_var("GCODE_NONEXISTENT_VAR_999") };
let result =
resolve_config_value_without_secrets("${GCODE_NONEXISTENT_VAR_999:-fallback}").unwrap();
assert_eq!(result, "fallback");
}
}