//! [`FsSecretStore`] — encrypted-at-rest [`SecretStore`] adapter (ticket #92, lot B2). //! //! ```text //! / //! ├── secret.key # 32 raw bytes, AES-256-GCM key material, chmod 0600 (Unix) //! └── secrets.json # { version, entries: { : "" } } //! ``` //! //! Unlike `profiles.json` (plain JSON), `secrets.json` never carries a plaintext //! value: each entry is `nonce (12B) || AES-256-GCM(value)`, hex-encoded. The key //! is generated once (random, `ring::rand::SystemRandom`) and persisted next to //! it. No OS keyring integration in v1 (assumed debt, documented by Architect) — //! a local attacker with read access to `secret.key` recovers every secret; the //! win over `profiles.json` is that `secrets.json` alone (e.g. leaked in a backup //! that excludes `secret.key`, or read by a process without access to the key //! file's restrictive permissions) is inert ciphertext. use std::sync::Arc; use async_trait::async_trait; use ring::aead::{self, BoundKey, Nonce, NonceSequence, OpeningKey, SealingKey, UnboundKey}; use ring::rand::{SecureRandom, SystemRandom}; use serde::{Deserialize, Serialize}; use domain::ports::{FileSystem, FsError, RemotePath, SecretRef, SecretStore, SecretStoreError}; const SECRETS_FILE: &str = "secrets.json"; const KEY_FILE: &str = "secret.key"; const SECRETS_VERSION: u32 = 1; const KEY_LEN: usize = 32; // AES-256 const NONCE_LEN: usize = 12; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct SecretsDoc { version: u32, /// `SecretRef` id -> hex(nonce || ciphertext || tag). entries: std::collections::BTreeMap, } impl Default for SecretsDoc { fn default() -> Self { Self { version: SECRETS_VERSION, entries: std::collections::BTreeMap::new(), } } } /// Single-use nonce sequence wrapping one 12-byte value (ring's streaming AEAD /// API insists on a `NonceSequence`, but every seal/open here uses exactly one /// fresh/parsed nonce, never a stream). struct OnceNonce(Option<[u8; NONCE_LEN]>); impl NonceSequence for OnceNonce { fn advance(&mut self) -> Result { let bytes = self.0.take().ok_or(ring::error::Unspecified)?; Ok(Nonce::assume_unique_for_key(bytes)) } } /// Encrypted-at-rest [`SecretStore`] port implementation. /// /// Cheap to clone (everything behind `Arc`); built once at the composition root. #[derive(Clone)] pub struct FsSecretStore { fs: Arc, app_data_dir: String, } impl FsSecretStore { /// Builds the store from an injected [`FileSystem`] and the app-data dir /// (same machine-local directory as `profiles.json`). #[must_use] pub fn new(fs: Arc, app_data_dir: impl Into) -> Self { Self { fs, app_data_dir: app_data_dir.into(), } } fn path(&self, file: &str) -> RemotePath { let base = self.app_data_dir.trim_end_matches(['/', '\\']); RemotePath::new(format!("{base}/{file}")) } /// Loads the key material, generating and persisting a fresh random key on /// first use. The key file is written with `0600` permissions on Unix /// (best-effort no-op elsewhere). async fn load_or_init_key(&self) -> Result<[u8; KEY_LEN], SecretStoreError> { let key_path = self.path(KEY_FILE); match self.fs.read(&key_path).await { Ok(bytes) => { let key: [u8; KEY_LEN] = bytes .as_slice() .try_into() .map_err(|_| SecretStoreError::Crypto("secret.key has wrong length".into()))?; Ok(key) } Err(FsError::NotFound(_)) => { let mut key = [0_u8; KEY_LEN]; SystemRandom::new().fill(&mut key).map_err(|_| { SecretStoreError::Crypto("failed to generate secret key".into()) })?; let dir = RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned()); self.fs .create_dir_all(&dir) .await .map_err(|e| SecretStoreError::Io(e.to_string()))?; self.fs .write(&key_path, &key) .await .map_err(|e| SecretStoreError::Io(e.to_string()))?; restrict_permissions(key_path.as_str()); Ok(key) } Err(e) => Err(SecretStoreError::Io(e.to_string())), } } async fn read_doc(&self) -> Result { match self.fs.read(&self.path(SECRETS_FILE)).await { Ok(bytes) => serde_json::from_slice(&bytes) .map_err(|e| SecretStoreError::Io(format!("secrets.json parse error: {e}"))), Err(FsError::NotFound(_)) => Ok(SecretsDoc::default()), Err(e) => Err(SecretStoreError::Io(e.to_string())), } } async fn write_doc(&self, doc: &SecretsDoc) -> Result<(), SecretStoreError> { let dir = RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned()); self.fs .create_dir_all(&dir) .await .map_err(|e| SecretStoreError::Io(e.to_string()))?; let bytes = serde_json::to_vec_pretty(doc) .map_err(|e| SecretStoreError::Io(format!("secrets.json serialise error: {e}")))?; self.fs .write(&self.path(SECRETS_FILE), &bytes) .await .map_err(|e| SecretStoreError::Io(e.to_string())) } fn seal(key: &[u8; KEY_LEN], plaintext: &str) -> Result { let mut nonce_bytes = [0_u8; NONCE_LEN]; SystemRandom::new() .fill(&mut nonce_bytes) .map_err(|_| SecretStoreError::Crypto("failed to generate nonce".into()))?; let unbound = UnboundKey::new(&aead::AES_256_GCM, key) .map_err(|_| SecretStoreError::Crypto("invalid key material".into()))?; let mut sealing = SealingKey::new(unbound, OnceNonce(Some(nonce_bytes))); let mut in_out = plaintext.as_bytes().to_vec(); sealing .seal_in_place_append_tag(aead::Aad::empty(), &mut in_out) .map_err(|_| SecretStoreError::Crypto("seal failed".into()))?; let mut out = Vec::with_capacity(NONCE_LEN + in_out.len()); out.extend_from_slice(&nonce_bytes); out.extend_from_slice(&in_out); Ok(hex::encode(out)) } fn open(key: &[u8; KEY_LEN], encoded: &str) -> Result { let bytes = hex::decode(encoded).map_err(|e| SecretStoreError::Crypto(format!("bad hex: {e}")))?; if bytes.len() < NONCE_LEN { return Err(SecretStoreError::Crypto("ciphertext too short".into())); } let (nonce_bytes, ciphertext) = bytes.split_at(NONCE_LEN); let mut nonce_arr = [0_u8; NONCE_LEN]; nonce_arr.copy_from_slice(nonce_bytes); let unbound = UnboundKey::new(&aead::AES_256_GCM, key) .map_err(|_| SecretStoreError::Crypto("invalid key material".into()))?; let mut opening = OpeningKey::new(unbound, OnceNonce(Some(nonce_arr))); let mut in_out = ciphertext.to_vec(); let plaintext = opening .open_in_place(aead::Aad::empty(), &mut in_out) .map_err(|_| SecretStoreError::Crypto("decryption failed".into()))?; String::from_utf8(plaintext.to_vec()) .map_err(|e| SecretStoreError::Crypto(format!("decrypted value is not utf8: {e}"))) } } #[async_trait] impl SecretStore for FsSecretStore { async fn put(&self, key: &SecretRef, value: &str) -> Result<(), SecretStoreError> { let enc_key = self.load_or_init_key().await?; let mut doc = self.read_doc().await?; let sealed = Self::seal(&enc_key, value)?; doc.entries.insert(key.as_str().to_owned(), sealed); self.write_doc(&doc).await } async fn get(&self, key: &SecretRef) -> Result, SecretStoreError> { let doc = self.read_doc().await?; let Some(sealed) = doc.entries.get(key.as_str()) else { return Ok(None); }; let enc_key = self.load_or_init_key().await?; Self::open(&enc_key, sealed).map(Some) } async fn delete(&self, key: &SecretRef) -> Result<(), SecretStoreError> { let mut doc = self.read_doc().await?; doc.entries.remove(key.as_str()); self.write_doc(&doc).await } } #[cfg(unix)] fn restrict_permissions(path: &str) { use std::os::unix::fs::PermissionsExt; if let Ok(metadata) = std::fs::metadata(path) { let mut perms = metadata.permissions(); perms.set_mode(0o600); let _ = std::fs::set_permissions(path, perms); } } #[cfg(not(unix))] fn restrict_permissions(_path: &str) {} #[cfg(test)] mod tests { use super::*; use std::path::PathBuf; struct TempDir(PathBuf); impl TempDir { fn new(label: &str) -> Self { let root = std::env::temp_dir().join(format!( "idea-secrets-store-{label}-{}", uuid::Uuid::new_v4() )); std::fs::create_dir_all(&root).unwrap(); Self(root) } } impl Drop for TempDir { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } fn store(dir: &TempDir) -> FsSecretStore { FsSecretStore::new( Arc::new(crate::fs::LocalFileSystem::new()), dir.0.to_string_lossy().into_owned(), ) } #[tokio::test] async fn put_then_get_round_trips_plaintext() { let dir = TempDir::new("roundtrip"); let store = store(&dir); let key = SecretRef::new("secret-a"); store.put(&key, "sk-live-abc123").await.unwrap(); let got = store.get(&key).await.unwrap(); assert_eq!(got, Some("sk-live-abc123".to_owned())); } #[tokio::test] async fn get_missing_key_returns_none() { let dir = TempDir::new("missing"); let store = store(&dir); let got = store.get(&SecretRef::new("nope")).await.unwrap(); assert_eq!(got, None); } #[tokio::test] async fn delete_removes_the_entry() { let dir = TempDir::new("delete"); let store = store(&dir); let key = SecretRef::new("secret-b"); store.put(&key, "value").await.unwrap(); store.delete(&key).await.unwrap(); assert_eq!(store.get(&key).await.unwrap(), None); } #[tokio::test] async fn secrets_file_never_contains_the_plaintext_value() { let dir = TempDir::new("plaintext-leak"); let store = store(&dir); store .put(&SecretRef::new("secret-c"), "super-secret-literal") .await .unwrap(); let raw = tokio::fs::read_to_string(dir.0.join(SECRETS_FILE)) .await .unwrap(); assert!(!raw.contains("super-secret-literal")); } #[tokio::test] async fn key_file_has_owner_only_permissions_on_unix() { let dir = TempDir::new("perms"); let store = store(&dir); store .put(&SecretRef::new("secret-d"), "value") .await .unwrap(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let metadata = std::fs::metadata(dir.0.join(KEY_FILE)).unwrap(); assert_eq!(metadata.permissions().mode() & 0o777, 0o600); } } #[tokio::test] async fn put_reuses_the_same_key_across_calls() { let dir = TempDir::new("reuse-key"); let store = store(&dir); store.put(&SecretRef::new("a"), "value-a").await.unwrap(); store.put(&SecretRef::new("b"), "value-b").await.unwrap(); assert_eq!( store.get(&SecretRef::new("a")).await.unwrap(), Some("value-a".to_owned()) ); assert_eq!( store.get(&SecretRef::new("b")).await.unwrap(), Some("value-b".to_owned()) ); } }