use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Mutex;
use aes_gcm::aead::{Aead, OsRng};
use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
use anyhow::{Context, Result};
use argon2::Argon2;
use rand::RngCore;
pub trait SecretStore: Send + Sync {
fn set(&self, key: &str, value: &str) -> Result<()>;
fn get(&self, key: &str) -> Result<Option<String>>;
fn delete(&self, key: &str) -> Result<()>;
}
const SALT_LEN: usize = 16;
const NONCE_LEN: usize = 12;
pub struct EncryptedFileSecretStore {
path: PathBuf,
key: [u8; 32],
salt: [u8; SALT_LEN],
cache: Mutex<HashMap<String, String>>,
}
impl EncryptedFileSecretStore {
pub fn new(path: impl Into<PathBuf>) -> Result<Self> {
let path = path.into();
let machine_id = machine_id()?;
if !path.exists() {
let mut salt = [0u8; SALT_LEN];
OsRng.fill_bytes(&mut salt);
let key = derive_key(&machine_id, &salt)?;
let store = Self { path, key, salt, cache: Mutex::new(HashMap::new()) };
store.flush(&store.cache.lock().unwrap())?;
return Ok(store);
}
let raw = std::fs::read(&path)
.with_context(|| format!("cannot read secrets file {}", path.display()))?;
if raw.len() < SALT_LEN + NONCE_LEN + 1 {
anyhow::bail!("secrets file is too short — it may be corrupted");
}
let mut salt = [0u8; SALT_LEN];
salt.copy_from_slice(&raw[..SALT_LEN]);
let key = derive_key(&machine_id, &salt)?;
let map = decrypt_map(&raw, &key)?;
Ok(Self { path, key, salt, cache: Mutex::new(map) })
}
fn flush(&self, map: &HashMap<String, String>) -> Result<()> {
if let Some(dir) = self.path.parent() {
std::fs::create_dir_all(dir)?;
}
let encrypted = encrypt_map(map, &self.key, &self.salt)?;
std::fs::write(&self.path, encrypted)
.with_context(|| format!("cannot write secrets file {}", self.path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&self.path, std::fs::Permissions::from_mode(0o600));
}
Ok(())
}
}
impl SecretStore for EncryptedFileSecretStore {
fn set(&self, key: &str, value: &str) -> Result<()> {
let mut g = self.cache.lock().unwrap();
g.insert(key.to_string(), value.to_string());
self.flush(&g)
}
fn get(&self, key: &str) -> Result<Option<String>> {
Ok(self.cache.lock().unwrap().get(key).cloned())
}
fn delete(&self, key: &str) -> Result<()> {
let mut g = self.cache.lock().unwrap();
g.remove(key);
self.flush(&g)
}
}
fn derive_key(machine_id: &str, salt: &[u8]) -> Result<[u8; 32]> {
let mut key = [0u8; 32];
Argon2::default()
.hash_password_into(machine_id.as_bytes(), salt, &mut key)
.map_err(|e| anyhow::anyhow!("key derivation failed: {e}"))?;
Ok(key)
}
fn encrypt_map(map: &HashMap<String, String>, key: &[u8; 32], salt: &[u8; SALT_LEN]) -> Result<Vec<u8>> {
let plaintext = serde_json::to_vec(map)?;
let cipher = Aes256Gcm::new_from_slice(key)
.map_err(|e| anyhow::anyhow!("cipher init failed: {e}"))?;
let mut nonce_bytes = [0u8; NONCE_LEN];
OsRng.fill_bytes(&mut nonce_bytes);
let ciphertext = cipher
.encrypt(Nonce::from_slice(&nonce_bytes), plaintext.as_ref())
.map_err(|e| anyhow::anyhow!("encryption failed: {e}"))?;
let mut out = Vec::with_capacity(SALT_LEN + NONCE_LEN + ciphertext.len());
out.extend_from_slice(salt);
out.extend_from_slice(&nonce_bytes);
out.extend_from_slice(&ciphertext);
Ok(out)
}
fn decrypt_map(raw: &[u8], key: &[u8; 32]) -> Result<HashMap<String, String>> {
let nonce_bytes = &raw[SALT_LEN..SALT_LEN + NONCE_LEN];
let ciphertext = &raw[SALT_LEN + NONCE_LEN..];
let cipher = Aes256Gcm::new_from_slice(key)
.map_err(|e| anyhow::anyhow!("cipher init failed: {e}"))?;
let plaintext = cipher
.decrypt(Nonce::from_slice(nonce_bytes), ciphertext)
.map_err(|_| anyhow::anyhow!(
"failed to decrypt secrets — did you copy the file from a different machine?"
))?;
Ok(serde_json::from_slice(&plaintext)?)
}
fn machine_id() -> Result<String> {
#[cfg(target_os = "macos")]
{
let out = std::process::Command::new("ioreg")
.args(["-rd1", "-c", "IOPlatformExpertDevice"])
.output()
.context("failed to read macOS machine ID")?;
let text = String::from_utf8_lossy(&out.stdout);
for line in text.lines() {
if line.contains("IOPlatformUUID") {
if let Some(uuid) = line.split('"').nth(3) {
return Ok(uuid.to_string());
}
}
}
}
#[cfg(target_os = "linux")]
{
if let Ok(id) = std::fs::read_to_string("/etc/machine-id") {
let id = id.trim().to_string();
if !id.is_empty() {
return Ok(id);
}
}
}
let hostname = std::process::Command::new("hostname")
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_else(|_| "opendbpylot-default".to_string());
Ok(hostname)
}
#[cfg(feature = "keychain")]
pub struct KeyringSecretStore {
service: String,
}
#[cfg(feature = "keychain")]
impl KeyringSecretStore {
pub fn new(service: impl Into<String>) -> Self {
Self { service: service.into() }
}
}
#[cfg(feature = "keychain")]
impl SecretStore for KeyringSecretStore {
fn set(&self, key: &str, value: &str) -> Result<()> {
keyring::Entry::new(&self.service, key)?.set_password(value)?;
Ok(())
}
fn get(&self, key: &str) -> Result<Option<String>> {
match keyring::Entry::new(&self.service, key)?.get_password() {
Ok(p) => Ok(Some(p)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(e.into()),
}
}
fn delete(&self, key: &str) -> Result<()> {
match keyring::Entry::new(&self.service, key)?.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(e.into()),
}
}
}
pub struct FileSecretStore {
path: PathBuf,
cache: Mutex<HashMap<String, String>>,
}
impl FileSecretStore {
pub fn new(path: impl Into<PathBuf>) -> Result<Self> {
let path = path.into();
let cache: HashMap<String, String> = if path.exists() {
serde_json::from_slice(&std::fs::read(&path)?)?
} else {
HashMap::new()
};
Ok(Self { path, cache: Mutex::new(cache) })
}
fn save(&self, map: &HashMap<String, String>) -> Result<()> {
std::fs::write(&self.path, serde_json::to_vec_pretty(map)?)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&self.path, std::fs::Permissions::from_mode(0o600));
}
Ok(())
}
}
impl SecretStore for FileSecretStore {
fn set(&self, key: &str, value: &str) -> Result<()> {
let mut g = self.cache.lock().unwrap();
g.insert(key.to_string(), value.to_string());
self.save(&g)
}
fn get(&self, key: &str) -> Result<Option<String>> {
Ok(self.cache.lock().unwrap().get(key).cloned())
}
fn delete(&self, key: &str) -> Result<()> {
let mut g = self.cache.lock().unwrap();
g.remove(key);
self.save(&g)
}
}