Compare commits
2 Commits
d1b8811432
...
4c31f19c9c
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c31f19c9c | |||
| f3702196e4 |
@@ -9,4 +9,7 @@
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod endpoint;
|
pub mod endpoint;
|
||||||
|
pub mod store;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
|
pub use store::{CredentialStore, EncryptedData, InMemoryCredentialStore, StoreError};
|
||||||
|
|||||||
203
crates/alknet-core/src/store.rs
Normal file
203
crates/alknet-core/src/store.rs
Normal file
@@ -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<u8>,
|
||||||
|
pub iv: Vec<u8>,
|
||||||
|
pub data: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait CredentialStore: Send + Sync {
|
||||||
|
fn get(&self, provider: &str) -> Option<EncryptedData>;
|
||||||
|
async fn put(&self, provider: &str, data: &EncryptedData) -> Result<(), StoreError>;
|
||||||
|
async fn delete(&self, provider: &str) -> Result<(), StoreError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct InMemoryCredentialStore {
|
||||||
|
entries: RwLock<HashMap<String, EncryptedData>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InMemoryCredentialStore {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
entries: RwLock::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_entries(entries: HashMap<String, EncryptedData>) -> 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<EncryptedData> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: core/credential-store-trait
|
id: core/credential-store-trait
|
||||||
name: Add CredentialStore trait, InMemoryCredentialStore, EncryptedData mirror, and StoreError (ADR-031/035)
|
name: Add CredentialStore trait, InMemoryCredentialStore, EncryptedData mirror, and StoreError (ADR-031/035)
|
||||||
status: pending
|
status: completed
|
||||||
depends_on: []
|
depends_on: []
|
||||||
scope: narrow
|
scope: narrow
|
||||||
risk: low
|
risk: low
|
||||||
@@ -141,4 +141,4 @@ Re-export `CredentialStore`, `InMemoryCredentialStore`, `EncryptedData`, and
|
|||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
> To be filled on completion
|
Added `store` module to alknet-core with: `CredentialStore` trait (sync `get`, async `put`/`delete` via #[async_trait], no `list`), `InMemoryCredentialStore` default adapter (`new()`/`with_entries()`, async put/delete with no .await points, RwLock-backed), `EncryptedData` core mirror (4 fields: key_version/salt/iv/data, derives Serialize/Deserialize/Clone/Debug), and `StoreError` enum (3 variants, #[non_exhaustive], thiserror::Error). Re-exported all four from lib.rs. No vault dependency added (core-owned mirror per ADR-018). 9 unit tests covering get/put/delete round-trip, missing-provider None, put-replaces, with_entries seeding, EncryptedData serde round-trip (empty + non-empty salt), and StoreError Display for all variants. 119 total tests pass, clippy clean.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: core/peer-entry-model
|
id: core/peer-entry-model
|
||||||
name: Add PeerEntry struct and replace AuthPolicy.authorized_fingerprints with peers (ADR-030)
|
name: Add PeerEntry struct and replace AuthPolicy.authorized_fingerprints with peers (ADR-030)
|
||||||
status: pending
|
status: completed
|
||||||
depends_on: []
|
depends_on: []
|
||||||
scope: moderate
|
scope: moderate
|
||||||
risk: medium
|
risk: medium
|
||||||
@@ -172,4 +172,4 @@ validation method or assertion that duplicate `peer_id` values in
|
|||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
> To be filled on completion
|
Implemented PeerEntry struct (7 fields) in config.rs, replaced `AuthPolicy.authorized_fingerprints: HashSet<String>` with `peers: Vec<PeerEntry>`. Added `resolve_identity_from_token` (PeerEntry.auth_token_hash → fall through to resolve_api_key) and updated `resolve_identity_from_fingerprint` to resolve via PeerEntry returning `Identity.id = peer_id` (stable). Added `validate_peer_ids()` returning `DuplicatePeerId` error. Migrated all auth.rs/config.rs tests to PeerEntry model with new unit tests covering fingerprint resolution (known/unknown/disabled), token resolution (matching/non-matching/fall-through), multi-fingerprint peers, resource population on both paths, and duplicate peer_id detection. Also fixed a pre-existing test compile bug in endpoint.rs (StaticConfig.iroh_relay field reference not gated behind #[cfg(feature = "iroh")]). 110 tests pass, clippy clean, fmt clean.
|
||||||
Reference in New Issue
Block a user