diff --git a/crates/alknet-core/src/endpoint.rs b/crates/alknet-core/src/endpoint.rs index eb7070e..4dfc013 100644 --- a/crates/alknet-core/src/endpoint.rs +++ b/crates/alknet-core/src/endpoint.rs @@ -1581,6 +1581,7 @@ mod tests { let static_config = StaticConfig { listen_addr: None, tls_identity: Some(TlsIdentity::RawKey(sk)), + #[cfg(feature = "iroh")] iroh_relay: None, drain_timeout: Duration::from_millis(10), }; diff --git a/crates/alknet-core/src/lib.rs b/crates/alknet-core/src/lib.rs index 9a3d9fc..29e3f71 100644 --- a/crates/alknet-core/src/lib.rs +++ b/crates/alknet-core/src/lib.rs @@ -9,4 +9,7 @@ pub mod auth; pub mod config; pub mod endpoint; +pub mod store; pub mod types; + +pub use store::{CredentialStore, EncryptedData, InMemoryCredentialStore, StoreError}; diff --git a/crates/alknet-core/src/store.rs b/crates/alknet-core/src/store.rs new file mode 100644 index 0000000..417881e --- /dev/null +++ b/crates/alknet-core/src/store.rs @@ -0,0 +1,203 @@ +//! Credential store: `CredentialStore` repo trait, `InMemoryCredentialStore` +//! default adapter, `EncryptedData` core mirror, and the shared `StoreError`. +//! +//! See `docs/architecture/crates/core/auth.md` and ADR-031 / ADR-035 for the +//! full specification. The store persists `EncryptedData` blobs keyed by +//! provider; it never decrypts (ADR-025 — the vault is the sole decryption +//! boundary). + +use std::collections::HashMap; +use std::sync::RwLock; + +use async_trait::async_trait; + +#[non_exhaustive] +#[derive(Debug, thiserror::Error)] +pub enum StoreError { + #[error("backend error: {message}")] + Backend { message: String }, + #[error("not found: {entity}")] + NotFound { entity: String }, + #[error("serialization error: {message}")] + Serialization { message: String }, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct EncryptedData { + pub key_version: u32, + pub salt: Vec, + pub iv: Vec, + pub data: Vec, +} + +#[async_trait] +pub trait CredentialStore: Send + Sync { + fn get(&self, provider: &str) -> Option; + async fn put(&self, provider: &str, data: &EncryptedData) -> Result<(), StoreError>; + async fn delete(&self, provider: &str) -> Result<(), StoreError>; +} + +pub struct InMemoryCredentialStore { + entries: RwLock>, +} + +impl InMemoryCredentialStore { + pub fn new() -> Self { + Self { + entries: RwLock::new(HashMap::new()), + } + } + + pub fn with_entries(entries: HashMap) -> Self { + Self { + entries: RwLock::new(entries), + } + } +} + +impl Default for InMemoryCredentialStore { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl CredentialStore for InMemoryCredentialStore { + fn get(&self, provider: &str) -> Option { + let entries = self.entries.read().unwrap_or_else(|e| e.into_inner()); + entries.get(provider).cloned() + } + + async fn put(&self, provider: &str, data: &EncryptedData) -> Result<(), StoreError> { + let mut entries = self.entries.write().unwrap_or_else(|e| e.into_inner()); + entries.insert(provider.to_string(), data.clone()); + Ok(()) + } + + async fn delete(&self, provider: &str) -> Result<(), StoreError> { + let mut entries = self.entries.write().unwrap_or_else(|e| e.into_inner()); + entries.remove(provider); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_encrypted_data() -> EncryptedData { + EncryptedData { + key_version: 2, + salt: vec![], + iv: vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + data: vec![0xde, 0xad, 0xbe, 0xef], + } + } + + #[tokio::test] + async fn in_memory_get_put_delete_round_trip() { + let store = InMemoryCredentialStore::new(); + let data = sample_encrypted_data(); + + assert!(store.get("openai").is_none()); + + store.put("openai", &data).await.unwrap(); + let retrieved = store.get("openai").expect("provider should be present"); + assert_eq!(retrieved.key_version, data.key_version); + assert_eq!(retrieved.salt, data.salt); + assert_eq!(retrieved.iv, data.iv); + assert_eq!(retrieved.data, data.data); + + store.delete("openai").await.unwrap(); + assert!(store.get("openai").is_none()); + } + + #[tokio::test] + async fn in_memory_get_returns_none_for_missing_provider() { + let store = InMemoryCredentialStore::new(); + assert!(store.get("never-configured").is_none()); + } + + #[tokio::test] + async fn in_memory_delete_missing_provider_is_ok() { + let store = InMemoryCredentialStore::new(); + store.delete("absent").await.unwrap(); + } + + #[tokio::test] + async fn in_memory_put_replaces_existing() { + let store = InMemoryCredentialStore::new(); + let first = sample_encrypted_data(); + let mut second = sample_encrypted_data(); + second.data = vec![0xc0, 0xff, 0xee]; + + store.put("anthropic", &first).await.unwrap(); + store.put("anthropic", &second).await.unwrap(); + + let retrieved = store.get("anthropic").expect("provider should be present"); + assert_eq!(retrieved.data, second.data); + } + + #[tokio::test] + async fn in_memory_with_entries_seeds_store() { + let mut entries = HashMap::new(); + entries.insert("github".to_string(), sample_encrypted_data()); + let store = InMemoryCredentialStore::with_entries(entries); + + assert!(store.get("github").is_some()); + assert!(store.get("openai").is_none()); + } + + #[test] + fn encrypted_data_serializes_and_deserializes_round_trip() { + let data = sample_encrypted_data(); + let json = serde_json::to_string(&data).expect("serialize"); + let decoded: EncryptedData = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(decoded.key_version, data.key_version); + assert_eq!(decoded.salt, data.salt); + assert_eq!(decoded.iv, data.iv); + assert_eq!(decoded.data, data.data); + } + + #[test] + fn encrypted_data_round_trips_non_empty_salt() { + let data = EncryptedData { + key_version: 1, + salt: vec![0xab, 0xcd, 0xef], + iv: vec![0; 12], + data: vec![0x01, 0x02, 0x03], + }; + let json = serde_json::to_string(&data).expect("serialize"); + let decoded: EncryptedData = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(decoded.salt, data.salt); + } + + #[test] + fn store_error_display_formatting() { + let backend = StoreError::Backend { + message: "disk full".to_string(), + }; + assert_eq!(backend.to_string(), "backend error: disk full"); + + let not_found = StoreError::NotFound { + entity: "openai".to_string(), + }; + assert_eq!(not_found.to_string(), "not found: openai"); + + let serialization = StoreError::Serialization { + message: "invalid utf8".to_string(), + }; + assert_eq!( + serialization.to_string(), + "serialization error: invalid utf8" + ); + } + + #[test] + fn store_error_is_non_exhaustive() { + let err = StoreError::Backend { + message: "x".to_string(), + }; + let _ = err.to_string(); + } +}