use serde::{Deserialize, Serialize};
#[cfg_attr(not(windows), allow(dead_code))]
pub const SCHEME_DPAPI: &str = "dpapi";
pub const SCHEME_MACHINE_KEY_V1: &str = "machine-key-v1";
pub const BACKUP_PASSWORD_KEY: &str = "backup-password";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExternalSlot {
Chat,
Impersonation,
Embed,
Tts,
}
impl ExternalSlot {
pub const ALL: [ExternalSlot; 4] = [Self::Chat, Self::Impersonation, Self::Embed, Self::Tts];
fn key(self) -> &'static str {
match self {
Self::Chat => "chat",
Self::Impersonation => "impersonation",
Self::Embed => "embed",
Self::Tts => "tts",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchSlot {
Tavily,
}
impl SearchSlot {
pub const ALL: [SearchSlot; 1] = [Self::Tavily];
fn key(self) -> &'static str {
match self {
Self::Tavily => "tavily",
}
}
pub fn display_name(self) -> &'static str {
match self {
Self::Tavily => "Tavily",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SecretKey {
Provider(crate::shared::config::CloudProvider),
External(ExternalSlot),
Search(SearchSlot),
BackupPassword,
McpEnv { server: String, var: String },
}
impl SecretKey {
pub fn storage_name(&self) -> String {
match self {
Self::Provider(p) => p.key().to_string(),
Self::External(slot) => format!("external-{}", slot.key()),
Self::Search(slot) => format!("search-{}", slot.key()),
Self::BackupPassword => BACKUP_PASSWORD_KEY.to_string(),
Self::McpEnv { server, var } => format!("mcp-{server}-{var}"),
}
}
}
const CHECK_PLAINTEXT: &str = "mindfork-rs api-key check v1";
const ENTROPY: &[u8] = b"mindfork-rs/api-keys/v1";
const HKDF_INFO: &[u8] = b"mindfork-rs api-key v1 user=";
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ApiKeyEntry {
pub label: String,
pub scheme: String,
pub check: String,
pub keys: std::collections::BTreeMap<String, String>,
}
#[allow(dead_code)]
pub fn scheme_available() -> bool {
local_scheme().is_some()
}
pub fn is_ours(entry: &ApiKeyEntry) -> bool {
decrypt(&entry.scheme, &entry.check).as_deref() == Some(CHECK_PLAINTEXT)
}
pub fn stored_key(entries: &[ApiKeyEntry], provider_key: &str) -> Option<String> {
let entry = entries.iter().find(|e| is_ours(e))?;
decrypt(&entry.scheme, entry.keys.get(provider_key)?)
}
pub fn put_key(
entries: &mut Vec<ApiKeyEntry>,
provider_key: &str,
key: &str,
label: impl FnOnce() -> String,
) -> Result<(), SecretError> {
let idx = entries.iter().position(is_ours);
if key.is_empty() {
if let Some(i) = idx {
entries[i].keys.remove(provider_key);
if entries[i].keys.is_empty() {
entries.remove(i);
}
}
return Ok(());
}
let scheme = local_scheme().ok_or(SecretError::Unavailable)?;
let cipher = encrypt(scheme, key)?;
match idx {
Some(i) => {
entries[i].keys.insert(provider_key.into(), cipher);
}
None => entries.push(ApiKeyEntry {
label: label(),
check: encrypt(scheme, CHECK_PLAINTEXT)?,
scheme: scheme.into(),
keys: std::collections::BTreeMap::from([(provider_key.into(), cipher)]),
}),
}
Ok(())
}
pub fn machine_label() -> String {
std::env::var("COMPUTERNAME")
.ok()
.or_else(|| std::env::var("HOSTNAME").ok())
.or_else(|| std::fs::read_to_string("/etc/hostname").ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "?".into())
}
#[derive(Debug, thiserror::Error)]
pub enum SecretError {
#[error("storing keys is not supported on this machine (no machine-id)")]
Unavailable,
#[error("failed to encrypt the secret")]
Encrypt,
}
fn local_scheme() -> Option<&'static str> {
#[cfg(windows)]
{
Some(SCHEME_DPAPI)
}
#[cfg(not(windows))]
{
machine_ikm().map(|_| SCHEME_MACHINE_KEY_V1)
}
}
fn encrypt(scheme: &str, plaintext: &str) -> Result<String, SecretError> {
let bytes = match scheme {
#[cfg(windows)]
SCHEME_DPAPI => dpapi::protect(plaintext.as_bytes()).ok_or(SecretError::Encrypt)?,
SCHEME_MACHINE_KEY_V1 => {
let key = machine_key().ok_or(SecretError::Unavailable)?;
encrypt_with_key(&key, plaintext.as_bytes()).ok_or(SecretError::Encrypt)?
}
_ => return Err(SecretError::Unavailable),
};
Ok(hex_encode(&bytes))
}
fn decrypt(scheme: &str, hex: &str) -> Option<String> {
let bytes = hex_decode(hex)?;
let plain = match scheme {
#[cfg(windows)]
SCHEME_DPAPI => dpapi::unprotect(&bytes)?,
SCHEME_MACHINE_KEY_V1 => decrypt_with_key(&machine_key()?, &bytes)?,
_ => return None,
};
String::from_utf8(plain).ok()
}
const NONCE_LEN: usize = 12;
fn derive_key(ikm: &[u8], user: &str) -> [u8; 32] {
let mut info = HKDF_INFO.to_vec();
info.extend_from_slice(user.as_bytes());
let mut okm = [0u8; 32];
hkdf::Hkdf::<sha2::Sha256>::new(Some(ENTROPY), ikm)
.expand(&info, &mut okm)
.expect("HKDF: 32 bytes is always a valid length");
okm
}
fn encrypt_with_key(key: &[u8; 32], plaintext: &[u8]) -> Option<Vec<u8>> {
use chacha20poly1305::aead::{Aead, OsRng};
use chacha20poly1305::{AeadCore, ChaCha20Poly1305, KeyInit};
let cipher = ChaCha20Poly1305::new(key.into());
let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
let mut out = nonce.to_vec();
out.extend_from_slice(&cipher.encrypt(&nonce, plaintext).ok()?);
Some(out)
}
fn decrypt_with_key(key: &[u8; 32], data: &[u8]) -> Option<Vec<u8>> {
use chacha20poly1305::aead::Aead;
use chacha20poly1305::{ChaCha20Poly1305, KeyInit};
if data.len() <= NONCE_LEN {
return None;
}
let (nonce, ct) = data.split_at(NONCE_LEN);
ChaCha20Poly1305::new(key.into())
.decrypt(nonce.into(), ct)
.ok()
}
fn machine_key() -> Option<[u8; 32]> {
Some(derive_key(&machine_ikm()?, ¤t_user()))
}
fn machine_ikm() -> Option<Vec<u8>> {
for path in ["/etc/machine-id", "/var/lib/dbus/machine-id"] {
if let Ok(s) = std::fs::read_to_string(path) {
let s = s.trim();
if !s.is_empty() {
return Some(s.as_bytes().to_vec());
}
}
}
None
}
fn current_user() -> String {
std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_default()
}
#[cfg(windows)]
mod dpapi {
use super::ENTROPY;
use windows_sys::Win32::Foundation::LocalFree;
use windows_sys::Win32::Security::Cryptography::{
CRYPT_INTEGER_BLOB, CryptProtectData, CryptUnprotectData,
};
pub(super) fn protect(data: &[u8]) -> Option<Vec<u8>> {
crypt(data, true)
}
pub(super) fn unprotect(data: &[u8]) -> Option<Vec<u8>> {
crypt(data, false)
}
fn crypt(data: &[u8], protect: bool) -> Option<Vec<u8>> {
let input = blob(data);
let entropy = blob(ENTROPY);
let mut out = CRYPT_INTEGER_BLOB {
cbData: 0,
pbData: std::ptr::null_mut(),
};
let ok = unsafe {
if protect {
CryptProtectData(
&input,
std::ptr::null(),
&entropy,
std::ptr::null(),
std::ptr::null(),
0,
&mut out,
)
} else {
CryptUnprotectData(
&input,
std::ptr::null_mut(),
&entropy,
std::ptr::null(),
std::ptr::null(),
0,
&mut out,
)
}
};
if ok == 0 || out.pbData.is_null() {
return None;
}
let bytes = unsafe { std::slice::from_raw_parts(out.pbData, out.cbData as usize).to_vec() };
unsafe { LocalFree(out.pbData as *mut core::ffi::c_void) };
Some(bytes)
}
fn blob(data: &[u8]) -> CRYPT_INTEGER_BLOB {
CRYPT_INTEGER_BLOB {
cbData: data.len() as u32,
pbData: data.as_ptr() as *mut u8,
}
}
}
fn hex_encode(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push(char::from_digit((b >> 4) as u32, 16).unwrap_or('0'));
s.push(char::from_digit((b & 0x0f) as u32, 16).unwrap_or('0'));
}
s
}
fn hex_decode(s: &str) -> Option<Vec<u8>> {
if !s.len().is_multiple_of(2) {
return None;
}
let b = s.as_bytes();
(0..b.len() / 2)
.map(|i| {
let hi = (b[i * 2] as char).to_digit(16)?;
let lo = (b[i * 2 + 1] as char).to_digit(16)?;
Some(((hi << 4) | lo) as u8)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn storage_names_are_distinct_across_kinds() {
use crate::shared::config::CloudProvider;
let mut names: Vec<String> = CloudProvider::ALL
.into_iter()
.map(SecretKey::Provider)
.chain(ExternalSlot::ALL.into_iter().map(SecretKey::External))
.chain(SearchSlot::ALL.into_iter().map(SecretKey::Search))
.chain([
SecretKey::BackupPassword,
SecretKey::McpEnv {
server: "chat".into(), var: "TOKEN".into(),
},
SecretKey::McpEnv {
server: "tavily".into(), var: "TOKEN".into(),
},
])
.map(|k| k.storage_name())
.collect();
let total = names.len();
names.sort();
names.dedup();
assert_eq!(names.len(), total, "storage names collide: {names:?}");
assert_eq!(
SecretKey::External(ExternalSlot::Chat).storage_name(),
"external-chat"
);
assert_eq!(
SecretKey::External(ExternalSlot::Impersonation).storage_name(),
"external-impersonation"
);
assert_eq!(
SecretKey::External(ExternalSlot::Embed).storage_name(),
"external-embed"
);
assert_eq!(
SecretKey::External(ExternalSlot::Tts).storage_name(),
"external-tts"
);
}
#[test]
fn hex_round_trip_and_rejects_malformed() {
let data = vec![0u8, 1, 15, 16, 200, 255];
assert_eq!(hex_decode(&hex_encode(&data)).unwrap(), data);
assert_eq!(hex_encode(&[0xab, 0x0f]), "ab0f");
assert!(hex_decode("abc").is_none()); assert!(hex_decode("zz").is_none()); }
#[test]
fn aead_round_trip_with_derived_key() {
let key = derive_key(b"machine-id-abc", "user1");
let enc = encrypt_with_key(&key, b"sk-secret-value").unwrap();
assert_ne!(&enc[NONCE_LEN..], b"sk-secret-value"); assert_eq!(decrypt_with_key(&key, &enc).unwrap(), b"sk-secret-value");
}
#[test]
fn aead_rejects_foreign_key_and_tampering() {
let mine = derive_key(b"machine-A", "user1");
let theirs = derive_key(b"machine-B", "user1");
let other_user = derive_key(b"machine-A", "user2");
let enc = encrypt_with_key(&mine, b"secret").unwrap();
assert!(decrypt_with_key(&theirs, &enc).is_none());
assert!(decrypt_with_key(&other_user, &enc).is_none());
let mut bad = enc.clone();
*bad.last_mut().unwrap() ^= 0xff;
assert!(decrypt_with_key(&mine, &bad).is_none());
assert!(decrypt_with_key(&mine, &[0u8; NONCE_LEN]).is_none());
}
#[test]
fn nonce_is_random_so_ciphertexts_differ() {
let key = derive_key(b"machine-id", "u");
let a = encrypt_with_key(&key, b"same").unwrap();
let b = encrypt_with_key(&key, b"same").unwrap();
assert_ne!(
a, b,
"identical plaintext must not produce identical ciphertext"
);
}
#[test]
fn derive_key_is_deterministic_and_domain_separated() {
assert_eq!(derive_key(b"m", "u"), derive_key(b"m", "u"));
assert_ne!(derive_key(b"m", "u"), derive_key(b"m", "v"));
assert_ne!(derive_key(b"m", "u"), derive_key(b"n", "u"));
}
#[test]
fn derive_key_matches_a_pinned_vector() {
assert_eq!(
hex_encode(&derive_key(b"machine-id-abc", "user1")),
"f2b76bdb73f60eed5e07d306d47e73cff8643c35720f89146167ea37e855f1e4"
);
}
#[test]
fn entry_round_trip_on_local_scheme() {
if !scheme_available() {
return; }
let mut entries: Vec<ApiKeyEntry> = vec![];
put_key(&mut entries, "openai", "sk-test-123", || "test".into()).unwrap();
assert_eq!(entries.len(), 1);
assert!(is_ours(&entries[0]));
let json = serde_json::to_string(&entries).unwrap();
assert!(
!json.contains("sk-test-123"),
"plaintext leaked into serialization: {json}"
);
assert_eq!(
stored_key(&entries, "openai").as_deref(),
Some("sk-test-123")
);
assert_eq!(stored_key(&entries, "claude"), None);
put_key(&mut entries, "claude", "sk-ant-9", || "test".into()).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(stored_key(&entries, "claude").as_deref(), Some("sk-ant-9"));
put_key(&mut entries, "openai", "", || "test".into()).unwrap();
assert_eq!(stored_key(&entries, "openai"), None);
assert_eq!(entries.len(), 1);
put_key(&mut entries, "claude", "", || "test".into()).unwrap();
assert!(entries.is_empty());
}
#[test]
fn foreign_entries_are_ignored_and_preserved() {
if !scheme_available() {
return;
}
let foreign = ApiKeyEntry {
label: "other-pc".into(),
scheme: SCHEME_MACHINE_KEY_V1.into(),
check: hex_encode(&[7u8; 40]), keys: std::collections::BTreeMap::from([("openai".into(), hex_encode(&[9u8; 40]))]),
};
let future = ApiKeyEntry {
label: "future-pc".into(),
scheme: "keychain-v9".into(), check: "00".into(),
keys: std::collections::BTreeMap::from([("openai".into(), "00".into())]),
};
let mut entries = vec![foreign.clone(), future.clone()];
assert!(!is_ours(&entries[0]) && !is_ours(&entries[1]));
assert_eq!(stored_key(&entries, "openai"), None);
put_key(&mut entries, "openai", "sk-mine", || "mine".into()).unwrap();
assert_eq!(
entries.len(),
3,
"our entry is added, foreign ones are not replaced"
);
assert_eq!(entries[0], foreign, "the foreign entry is untouched");
assert_eq!(
entries[1], future,
"the entry with an unfamiliar scheme is untouched"
);
assert_eq!(stored_key(&entries, "openai").as_deref(), Some("sk-mine"));
}
}