use std::collections::BTreeMap; use std::fmt; use std::fs::{self, OpenOptions}; use std::io::Write as _; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; const STORE_VERSION: u32 = 1; const KEY_LEN: usize = 32; const TAG_LEN: usize = 32; const MAX_ID_LEN: usize = 128; static NONCE_COUNTER: AtomicU64 = AtomicU64::new(0); #[derive(Debug, thiserror::Error)] pub enum Error { #[error("secret id is empty")] EmptyId, #[error("secret id `{id}` is too long (max {max} bytes)")] IdTooLong { id: String, max: usize }, #[error("secret id `{0}` contains unsupported characters")] UnsupportedIdChars(String), #[error("secret id `{0}` must not be absolute or contain traversal components")] UnsafeId(String), #[error("failed to read secret store {}: {source}", .path.display())] Read { path: PathBuf, #[source] source: std::io::Error, }, #[error("failed to parse secret store {}: {source}", .path.display())] Parse { path: PathBuf, #[source] source: serde_json::Error, }, #[error("unsupported secret store version {version} in {}", .path.display())] UnsupportedVersion { path: PathBuf, version: u32 }, #[error("failed to decode secret `{id}`")] Decode { id: String }, #[error("secret `{id}` was not found")] NotFound { id: String }, #[error("failed to create secret store directory {}: {source}", .path.display())] CreateDir { path: PathBuf, #[source] source: std::io::Error, }, #[error("failed to write secret store {}: {source}", .path.display())] Write { path: PathBuf, #[source] source: std::io::Error, }, } pub type Result = std::result::Result; #[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] pub struct SecretId(String); impl SecretId { pub fn parse(value: impl Into) -> Result { let value = value.into(); validate_id(&value)?; Ok(Self(value)) } pub fn as_str(&self) -> &str { &self.0 } } impl fmt::Debug for SecretId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_tuple("SecretId").field(&self.0).finish() } } impl fmt::Display for SecretId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0) } } #[derive(Clone, Eq, PartialEq)] pub struct SecretValue(String); impl SecretValue { pub fn new(value: impl Into) -> Self { Self(value.into()) } pub fn expose_secret(&self) -> &str { &self.0 } pub fn into_string(self) -> String { self.0 } } impl fmt::Debug for SecretValue { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str("SecretValue([redacted])") } } #[derive(Debug, Clone)] pub struct SecretStore { path: PathBuf, key: [u8; KEY_LEN], } #[derive(Debug, Clone, Serialize, Deserialize, Default)] struct StoreFile { version: u32, #[serde(default)] entries: BTreeMap, } #[derive(Debug, Clone, Serialize, Deserialize)] struct Entry { nonce: String, ciphertext: String, tag: String, } impl SecretStore { pub fn new(data_dir: impl AsRef) -> Self { let data_dir = data_dir.as_ref(); let path = data_dir.join("secrets").join("store.json"); Self::at_path_with_key(path, derive_key(data_dir)) } pub fn at_path_for_tests(path: impl AsRef) -> Self { let path = path.as_ref().to_path_buf(); Self::at_path_with_key( path.clone(), derive_key(path.parent().unwrap_or(Path::new(""))), ) } pub fn at_path_with_key(path: PathBuf, key: [u8; KEY_LEN]) -> Self { Self { path, key } } pub fn path(&self) -> &Path { &self.path } pub fn list_ids(&self) -> Result> { let file = self.load()?; file.entries.into_keys().map(SecretId::parse).collect() } pub fn get(&self, id: &str) -> Result { let id = SecretId::parse(id.to_string())?; let file = self.load()?; let entry = file .entries .get(id.as_str()) .ok_or_else(|| Error::NotFound { id: id.to_string() })?; let plaintext = decrypt_entry(&self.key, &id, entry)?; Ok(SecretValue::new( String::from_utf8(plaintext).map_err(|_| Error::Decode { id: id.to_string() })?, )) } pub fn set(&self, id: &str, value: SecretValue) -> Result<()> { let id = SecretId::parse(id.to_string())?; let mut file = self.load()?; file.entries.insert( id.to_string(), encrypt_entry(&self.key, &id, value.expose_secret().as_bytes()), ); self.save(&file) } pub fn delete(&self, id: &str) -> Result { let id = SecretId::parse(id.to_string())?; let mut file = self.load()?; let removed = file.entries.remove(id.as_str()).is_some(); if removed { self.save(&file)?; } Ok(removed) } fn load(&self) -> Result { match fs::read_to_string(&self.path) { Ok(text) => { let file: StoreFile = serde_json::from_str(&text).map_err(|source| Error::Parse { path: self.path.clone(), source, })?; if file.version != STORE_VERSION { return Err(Error::UnsupportedVersion { path: self.path.clone(), version: file.version, }); } Ok(file) } Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(StoreFile { version: STORE_VERSION, entries: BTreeMap::new(), }), Err(source) => Err(Error::Read { path: self.path.clone(), source, }), } } fn save(&self, file: &StoreFile) -> Result<()> { let parent = self.path.parent().unwrap_or(Path::new(".")); fs::create_dir_all(parent).map_err(|source| Error::CreateDir { path: parent.to_path_buf(), source, })?; let data = serde_json::to_vec_pretty(file).map_err(|source| Error::Write { path: self.path.clone(), source: std::io::Error::other(source), })?; let tmp = self.temp_path(); { let mut fh = OpenOptions::new() .create_new(true) .write(true) .open(&tmp) .map_err(|source| Error::Write { path: tmp.clone(), source, })?; fh.write_all(&data).map_err(|source| Error::Write { path: tmp.clone(), source, })?; fh.sync_all().map_err(|source| Error::Write { path: tmp.clone(), source, })?; } fs::rename(&tmp, &self.path).map_err(|source| Error::Write { path: self.path.clone(), source, })?; if let Ok(dir) = fs::File::open(parent) { let _ = dir.sync_all(); } Ok(()) } fn temp_path(&self) -> PathBuf { let suffix = NONCE_COUNTER.fetch_add(1, Ordering::Relaxed); let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_nanos(); let file_name = self .path .file_name() .and_then(|s| s.to_str()) .unwrap_or("store.json"); self.path.with_file_name(format!( ".{file_name}.{}.{}.{}.tmp", std::process::id(), now, suffix )) } } pub fn validate_id(id: &str) -> Result<()> { if id.is_empty() { return Err(Error::EmptyId); } if id.len() > MAX_ID_LEN { return Err(Error::IdTooLong { id: id.to_string(), max: MAX_ID_LEN, }); } if id.starts_with('/') || id.starts_with('~') || id.contains("//") { return Err(Error::UnsafeId(id.to_string())); } for component in id.split('/') { if component.is_empty() || component == "." || component == ".." { return Err(Error::UnsafeId(id.to_string())); } } if !id .bytes() .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/')) { return Err(Error::UnsupportedIdChars(id.to_string())); } Ok(()) } fn derive_key(data_dir: &Path) -> [u8; KEY_LEN] { let mut hasher = Sha256::new(); hasher.update(b"yoi local secret store obfuscation key v1"); hasher.update(data_dir.as_os_str().as_encoded_bytes()); hasher.finalize().into() } fn encrypt_entry(key: &[u8; KEY_LEN], id: &SecretId, plaintext: &[u8]) -> Entry { let nonce = make_nonce(id.as_str(), plaintext); let ciphertext = xor_stream(key, &nonce, plaintext); let tag = tag(key, id.as_str(), &nonce, &ciphertext); Entry { nonce: hex_encode(&nonce), ciphertext: hex_encode(&ciphertext), tag: hex_encode(&tag), } } fn decrypt_entry(key: &[u8; KEY_LEN], id: &SecretId, entry: &Entry) -> Result> { let nonce = hex_decode(&entry.nonce).map_err(|_| Error::Decode { id: id.to_string() })?; let ciphertext = hex_decode(&entry.ciphertext).map_err(|_| Error::Decode { id: id.to_string() })?; let actual_tag = hex_decode(&entry.tag).map_err(|_| Error::Decode { id: id.to_string() })?; let expected = tag(key, id.as_str(), &nonce, &ciphertext); if actual_tag.as_slice() != expected { return Err(Error::Decode { id: id.to_string() }); } Ok(xor_stream(key, &nonce, &ciphertext)) } fn make_nonce(id: &str, plaintext: &[u8]) -> Vec { let mut hasher = Sha256::new(); let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_nanos(); hasher.update(b"yoi nonce v1"); hasher.update(now.to_le_bytes()); hasher.update(std::process::id().to_le_bytes()); hasher.update(NONCE_COUNTER.fetch_add(1, Ordering::Relaxed).to_le_bytes()); hasher.update(id.as_bytes()); hasher.update(plaintext); hasher.finalize()[..16].to_vec() } fn xor_stream(key: &[u8; KEY_LEN], nonce: &[u8], input: &[u8]) -> Vec { let mut out = Vec::with_capacity(input.len()); let mut counter = 0u64; for chunk in input.chunks(KEY_LEN) { let mut hasher = Sha256::new(); hasher.update(b"yoi secret keystream v1"); hasher.update(key); hasher.update(nonce); hasher.update(counter.to_le_bytes()); let block = hasher.finalize(); for (b, k) in chunk.iter().zip(block.iter()) { out.push(b ^ k); } counter += 1; } out } fn tag(key: &[u8; KEY_LEN], id: &str, nonce: &[u8], ciphertext: &[u8]) -> [u8; TAG_LEN] { let mut hasher = Sha256::new(); hasher.update(b"yoi secret tag v1"); hasher.update(key); hasher.update(id.as_bytes()); hasher.update(nonce); hasher.update(ciphertext); hasher.finalize().into() } fn hex_encode(bytes: &[u8]) -> String { const HEX: &[u8; 16] = b"0123456789abcdef"; let mut out = String::with_capacity(bytes.len() * 2); for b in bytes { out.push(HEX[(b >> 4) as usize] as char); out.push(HEX[(b & 0x0f) as usize] as char); } out } fn hex_decode(s: &str) -> std::result::Result, ()> { if !s.len().is_multiple_of(2) { return Err(()); } let mut out = Vec::with_capacity(s.len() / 2); let bytes = s.as_bytes(); for pair in bytes.chunks_exact(2) { let high = hex_value(pair[0])?; let low = hex_value(pair[1])?; out.push((high << 4) | low); } Ok(out) } fn hex_value(b: u8) -> std::result::Result { match b { b'0'..=b'9' => Ok(b - b'0'), b'a'..=b'f' => Ok(b - b'a' + 10), b'A'..=b'F' => Ok(b - b'A' + 10), _ => Err(()), } } #[cfg(test)] mod tests { use super::*; fn test_store() -> (tempfile::TempDir, SecretStore) { let dir = tempfile::tempdir().unwrap(); let store = SecretStore::at_path_with_key(dir.path().join("secrets/store.json"), [7u8; KEY_LEN]); (dir, store) } #[test] fn roundtrip_list_delete() { let (_dir, store) = test_store(); store .set("anthropic/default", SecretValue::new("sk-test-secret")) .unwrap(); assert_eq!( store.get("anthropic/default").unwrap().expose_secret(), "sk-test-secret" ); assert_eq!(store.list_ids().unwrap()[0].as_str(), "anthropic/default"); assert!(store.delete("anthropic/default").unwrap()); assert!(matches!( store.get("anthropic/default"), Err(Error::NotFound { .. }) )); } #[test] fn invalid_ids_are_rejected() { for id in ["", "/abs", "../x", "x/../y", "x//y", "x y", "x\ny", "~home"] { assert!(SecretId::parse(id).is_err(), "{id:?} should be invalid"); } assert!(SecretId::parse("a".repeat(MAX_ID_LEN + 1)).is_err()); assert!(SecretId::parse("web/brave.default-1").is_ok()); } #[test] fn corrupted_store_fails_closed() { let (dir, store) = test_store(); store .set("web/brave", SecretValue::new("secret-value")) .unwrap(); let path = dir.path().join("secrets/store.json"); let mut file: StoreFile = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); let entry = file.entries.get_mut("web/brave").unwrap(); let replacement = if entry.ciphertext.starts_with('0') { "1" } else { "0" }; entry.ciphertext.replace_range(0..1, replacement); fs::write(&path, serde_json::to_string_pretty(&file).unwrap()).unwrap(); assert!(matches!(store.get("web/brave"), Err(Error::Decode { id }) if id == "web/brave")); } #[test] fn plaintext_is_not_written_to_disk_or_debug() { let (dir, store) = test_store(); let value = SecretValue::new("sk-plain-must-not-appear"); assert!(!format!("{value:?}").contains("sk-plain")); store.set("provider/test", value).unwrap(); let text = fs::read_to_string(dir.path().join("secrets/store.json")).unwrap(); assert!(!text.contains("sk-plain-must-not-appear")); assert!(text.contains("provider/test")); } #[test] fn wrong_key_or_tamper_fails_decode() { let (dir, store) = test_store(); store .set("provider/test", SecretValue::new("secret-value")) .unwrap(); let wrong = SecretStore::at_path_with_key(dir.path().join("secrets/store.json"), [9u8; KEY_LEN]); assert!( matches!(wrong.get("provider/test"), Err(Error::Decode { id }) if id == "provider/test") ); } }