use std::path::Path;
use anyhow::{Context as _, bail};
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE;
use pbkdf2::pbkdf2_hmac;
use sha2::Sha256;
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(db_path: &Path, secret_name: &str) -> anyhow::Result<String> {
let gobby_dir = dirs::home_dir()
.context("cannot determine home directory")?
.join(".gobby");
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 conn = rusqlite::Connection::open_with_flags(
db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.with_context(|| {
format!(
"failed to open DB for secret resolution: {}",
db_path.display()
)
})?;
conn.busy_timeout(std::time::Duration::from_millis(5000))?;
let name = secret_name.trim().to_lowercase();
let encrypted: String = conn
.query_row(
"SELECT encrypted_value FROM secrets WHERE name = ?1",
rusqlite::params![name],
|row| row.get(0),
)
.with_context(|| format!("secret '{name}' not found in secrets table"))?;
decrypt_fernet(&fernet_key, &encrypted)
}
pub fn resolve_config_value(value: &str, db_path: &Path) -> 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(db_path, 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)]
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("http://localhost:8474", Path::new("/nonexistent")).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("${GCODE_TEST_VAR_123}", Path::new("/nonexistent")).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(
"${GCODE_NONEXISTENT_VAR_999:-fallback}",
Path::new("/nonexistent"),
)
.unwrap();
assert_eq!(result, "fallback");
}
}