Compare commits
5 Commits
20b5c640ec
...
b93a85a280
| Author | SHA1 | Date | |
|---|---|---|---|
| b93a85a280 | |||
| a4b4d89d8f | |||
| d7d879a3fa | |||
| 8dc842b1f4 | |||
| 41f0fc7843 |
3
Cargo.lock
generated
3
Cargo.lock
generated
@@ -71,12 +71,14 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures",
|
"futures",
|
||||||
|
"hex",
|
||||||
"iroh",
|
"iroh",
|
||||||
"quinn",
|
"quinn",
|
||||||
"rustls",
|
"rustls",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"toml",
|
||||||
@@ -333,6 +335,7 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
"serde",
|
"serde",
|
||||||
"unicode-normalization",
|
"unicode-normalization",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -29,4 +29,6 @@ tracing = "0.1"
|
|||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
zeroize = { version = "1", features = ["alloc", "derive"] }
|
zeroize = { version = "1", features = ["alloc", "derive"] }
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
|
sha2 = "0.10"
|
||||||
|
hex = "0.4"
|
||||||
@@ -5,6 +5,11 @@
|
|||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use arc_swap::ArcSwap;
|
||||||
|
|
||||||
|
use crate::config::DynamicConfig;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct Identity {
|
pub struct Identity {
|
||||||
@@ -13,6 +18,11 @@ pub struct Identity {
|
|||||||
pub resources: HashMap<String, Vec<String>>,
|
pub resources: HashMap<String, Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AuthToken {
|
||||||
|
pub raw: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AuthContext {
|
pub struct AuthContext {
|
||||||
pub identity: Option<Identity>,
|
pub identity: Option<Identity>,
|
||||||
@@ -20,3 +30,259 @@ pub struct AuthContext {
|
|||||||
pub remote_addr: Option<SocketAddr>,
|
pub remote_addr: Option<SocketAddr>,
|
||||||
pub tls_client_fingerprint: Option<String>,
|
pub tls_client_fingerprint: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub trait IdentityProvider: Send + Sync + 'static {
|
||||||
|
fn resolve_from_fingerprint(&self, fingerprint: &str) -> Option<Identity>;
|
||||||
|
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ConfigIdentityProvider {
|
||||||
|
dynamic: Arc<ArcSwap<DynamicConfig>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigIdentityProvider {
|
||||||
|
pub fn new(dynamic: Arc<ArcSwap<DynamicConfig>>) -> Self {
|
||||||
|
Self { dynamic }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IdentityProvider for ConfigIdentityProvider {
|
||||||
|
fn resolve_from_fingerprint(&self, fingerprint: &str) -> Option<Identity> {
|
||||||
|
let config = self.dynamic.load();
|
||||||
|
config.auth.resolve_identity_from_fingerprint(fingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity> {
|
||||||
|
let config = self.dynamic.load();
|
||||||
|
let token_str = String::from_utf8_lossy(&token.raw);
|
||||||
|
config.auth.resolve_api_key(&token_str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::config::{ApiKeyEntry, AuthPolicy, DynamicConfig, RateLimitConfig};
|
||||||
|
|
||||||
|
fn compute_api_key_hash(token: &str) -> String {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(token.as_bytes());
|
||||||
|
let result = hasher.finalize();
|
||||||
|
format!("sha256:{}", hex::encode(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_provider(
|
||||||
|
config: DynamicConfig,
|
||||||
|
) -> (ConfigIdentityProvider, Arc<ArcSwap<DynamicConfig>>) {
|
||||||
|
let arc_swap = Arc::new(ArcSwap::new(Arc::new(config)));
|
||||||
|
let provider = ConfigIdentityProvider::new(Arc::clone(&arc_swap));
|
||||||
|
(provider, arc_swap)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_with_fingerprint(fingerprint: &str) -> DynamicConfig {
|
||||||
|
let mut fingerprints = std::collections::HashSet::new();
|
||||||
|
fingerprints.insert(fingerprint.to_string());
|
||||||
|
DynamicConfig {
|
||||||
|
auth: AuthPolicy {
|
||||||
|
authorized_fingerprints: fingerprints,
|
||||||
|
api_keys: Vec::new(),
|
||||||
|
},
|
||||||
|
rate_limits: RateLimitConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_with_api_key(entry: ApiKeyEntry) -> DynamicConfig {
|
||||||
|
DynamicConfig {
|
||||||
|
auth: AuthPolicy {
|
||||||
|
authorized_fingerprints: std::collections::HashSet::new(),
|
||||||
|
api_keys: vec![entry],
|
||||||
|
},
|
||||||
|
rate_limits: RateLimitConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identity_fields_and_equality() {
|
||||||
|
let mut resources = HashMap::new();
|
||||||
|
resources.insert(
|
||||||
|
"service".to_string(),
|
||||||
|
vec!["gitea".to_string(), "registry".to_string()],
|
||||||
|
);
|
||||||
|
let id = Identity {
|
||||||
|
id: "SHA256:abc123".to_string(),
|
||||||
|
scopes: vec!["relay:connect".to_string()],
|
||||||
|
resources,
|
||||||
|
};
|
||||||
|
let id2 = id.clone();
|
||||||
|
assert_eq!(id, id2);
|
||||||
|
assert_eq!(id.id, "SHA256:abc123");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auth_token_is_clone() {
|
||||||
|
let token = AuthToken {
|
||||||
|
raw: b"alk_test".to_vec(),
|
||||||
|
};
|
||||||
|
let cloned = token.clone();
|
||||||
|
assert_eq!(token.raw, cloned.raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auth_context_is_clone() {
|
||||||
|
let ctx = AuthContext {
|
||||||
|
identity: None,
|
||||||
|
alpn: b"alknet/test".to_vec(),
|
||||||
|
remote_addr: None,
|
||||||
|
tls_client_fingerprint: None,
|
||||||
|
};
|
||||||
|
let cloned = ctx.clone();
|
||||||
|
assert_eq!(cloned.alpn, b"alknet/test");
|
||||||
|
assert!(cloned.identity.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fingerprint_resolution_known_returns_some() {
|
||||||
|
let (provider, _) = make_provider(config_with_fingerprint("SHA256:abc123"));
|
||||||
|
let identity = provider
|
||||||
|
.resolve_from_fingerprint("SHA256:abc123")
|
||||||
|
.expect("known fingerprint resolves");
|
||||||
|
assert_eq!(identity.id, "SHA256:abc123");
|
||||||
|
assert_eq!(identity.scopes, vec!["relay:connect".to_string()]);
|
||||||
|
assert!(identity.resources.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fingerprint_resolution_unknown_returns_none() {
|
||||||
|
let (provider, _) = make_provider(config_with_fingerprint("SHA256:abc123"));
|
||||||
|
assert!(provider
|
||||||
|
.resolve_from_fingerprint("SHA256:unknown")
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fingerprint_resolution_empty_config_returns_none() {
|
||||||
|
let (provider, _) = make_provider(DynamicConfig::default());
|
||||||
|
assert!(provider
|
||||||
|
.resolve_from_fingerprint("SHA256:anything")
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn token_resolution_valid_non_expired_returns_some() {
|
||||||
|
let token_str = "alk_testsecret123";
|
||||||
|
let hash = compute_api_key_hash(token_str);
|
||||||
|
let entry = ApiKeyEntry {
|
||||||
|
prefix: "alk_test".to_string(),
|
||||||
|
hash,
|
||||||
|
scopes: vec!["relay:connect".to_string()],
|
||||||
|
description: "test key".to_string(),
|
||||||
|
expires_at: None,
|
||||||
|
};
|
||||||
|
let (provider, _) = make_provider(config_with_api_key(entry));
|
||||||
|
let token = AuthToken {
|
||||||
|
raw: token_str.as_bytes().to_vec(),
|
||||||
|
};
|
||||||
|
let identity = provider
|
||||||
|
.resolve_from_token(&token)
|
||||||
|
.expect("valid non-expired token resolves");
|
||||||
|
assert_eq!(identity.id, "alk_test");
|
||||||
|
assert_eq!(identity.scopes, vec!["relay:connect".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn token_resolution_expired_returns_none() {
|
||||||
|
let token_str = "alk_testsecret123";
|
||||||
|
let hash = compute_api_key_hash(token_str);
|
||||||
|
let entry = ApiKeyEntry {
|
||||||
|
prefix: "alk_test".to_string(),
|
||||||
|
hash,
|
||||||
|
scopes: vec!["relay:connect".to_string()],
|
||||||
|
description: "expired key".to_string(),
|
||||||
|
expires_at: Some(1),
|
||||||
|
};
|
||||||
|
let (provider, _) = make_provider(config_with_api_key(entry));
|
||||||
|
let token = AuthToken {
|
||||||
|
raw: token_str.as_bytes().to_vec(),
|
||||||
|
};
|
||||||
|
assert!(provider.resolve_from_token(&token).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn token_resolution_unknown_returns_none() {
|
||||||
|
let token_str = "alk_testsecret123";
|
||||||
|
let hash = compute_api_key_hash(token_str);
|
||||||
|
let entry = ApiKeyEntry {
|
||||||
|
prefix: "alk_test".to_string(),
|
||||||
|
hash,
|
||||||
|
scopes: vec!["relay:connect".to_string()],
|
||||||
|
description: "test key".to_string(),
|
||||||
|
expires_at: None,
|
||||||
|
};
|
||||||
|
let (provider, _) = make_provider(config_with_api_key(entry));
|
||||||
|
let token = AuthToken {
|
||||||
|
raw: b"alk_unknown".to_vec(),
|
||||||
|
};
|
||||||
|
assert!(provider.resolve_from_token(&token).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn token_resolution_wrong_hash_returns_none() {
|
||||||
|
let entry = ApiKeyEntry {
|
||||||
|
prefix: "alk_test".to_string(),
|
||||||
|
hash: "sha256:deadbeef".to_string(),
|
||||||
|
scopes: vec!["relay:connect".to_string()],
|
||||||
|
description: "wrong hash".to_string(),
|
||||||
|
expires_at: None,
|
||||||
|
};
|
||||||
|
let (provider, _) = make_provider(config_with_api_key(entry));
|
||||||
|
let token = AuthToken {
|
||||||
|
raw: b"alk_testsecret123".to_vec(),
|
||||||
|
};
|
||||||
|
assert!(provider.resolve_from_token(&token).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn token_resolution_non_alk_prefix_returns_none() {
|
||||||
|
let (provider, _) = make_provider(DynamicConfig::default());
|
||||||
|
let token = AuthToken {
|
||||||
|
raw: b"bearer_token".to_vec(),
|
||||||
|
};
|
||||||
|
assert!(provider.resolve_from_token(&token).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_reload_changes_resolution_immediately() {
|
||||||
|
let (provider, arc_swap) = make_provider(DynamicConfig::default());
|
||||||
|
assert!(provider.resolve_from_fingerprint("SHA256:abc123").is_none());
|
||||||
|
|
||||||
|
let new_config = config_with_fingerprint("SHA256:abc123");
|
||||||
|
arc_swap.store(Arc::new(new_config));
|
||||||
|
|
||||||
|
assert!(provider.resolve_from_fingerprint("SHA256:abc123").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_reload_removes_fingerprint_access_immediately() {
|
||||||
|
let (provider, arc_swap) = make_provider(config_with_fingerprint("SHA256:abc123"));
|
||||||
|
assert!(provider.resolve_from_fingerprint("SHA256:abc123").is_some());
|
||||||
|
|
||||||
|
arc_swap.store(Arc::new(DynamicConfig::default()));
|
||||||
|
|
||||||
|
assert!(provider.resolve_from_fingerprint("SHA256:abc123").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_reload_handle_reloads_config() {
|
||||||
|
use crate::config::ConfigReloadHandle;
|
||||||
|
let arc_swap = Arc::new(ArcSwap::new(Arc::new(DynamicConfig::default())));
|
||||||
|
let provider = ConfigIdentityProvider::new(Arc::clone(&arc_swap));
|
||||||
|
let handle = ConfigReloadHandle::new(arc_swap);
|
||||||
|
|
||||||
|
assert!(provider.resolve_from_fingerprint("SHA256:abc123").is_none());
|
||||||
|
|
||||||
|
handle.reload(config_with_fingerprint("SHA256:abc123"));
|
||||||
|
|
||||||
|
assert!(provider.resolve_from_fingerprint("SHA256:abc123").is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,128 @@
|
|||||||
//! Configuration: `StaticConfig`, `DynamicConfig`, `AuthPolicy`, `ApiKeyEntry`,
|
//! Configuration: `DynamicConfig`, `AuthPolicy`, `ApiKeyEntry`,
|
||||||
//! `RateLimitConfig`, `ConfigReloadHandle`, `ConfigError`, `TlsIdentity`.
|
//! `RateLimitConfig`, `ConfigReloadHandle`.
|
||||||
//!
|
//!
|
||||||
//! See `docs/architecture/crates/core/config.md` for the full specification.
|
//! See `docs/architecture/crates/core/config.md` for the full specification.
|
||||||
|
//!
|
||||||
|
//! This module provides the dynamic-config types required by
|
||||||
|
//! `auth::ConfigIdentityProvider`. The remaining types (`StaticConfig`,
|
||||||
|
//! `TlsIdentity`, `ConfigError`) are filled in by the core/config task.
|
||||||
|
|
||||||
// TODO: implement
|
use std::collections::HashSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use arc_swap::ArcSwap;
|
||||||
|
|
||||||
|
use crate::auth::Identity;
|
||||||
|
|
||||||
|
pub const API_KEY_PREFIX: &str = "alk_";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ApiKeyEntry {
|
||||||
|
pub prefix: String,
|
||||||
|
pub hash: String,
|
||||||
|
pub scopes: Vec<String>,
|
||||||
|
pub description: String,
|
||||||
|
pub expires_at: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct AuthPolicy {
|
||||||
|
pub authorized_fingerprints: HashSet<String>,
|
||||||
|
pub api_keys: Vec<ApiKeyEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthPolicy {
|
||||||
|
pub fn empty() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_identity_from_fingerprint(&self, fingerprint: &str) -> Option<Identity> {
|
||||||
|
if self.authorized_fingerprints.contains(fingerprint) {
|
||||||
|
Some(Identity {
|
||||||
|
id: fingerprint.to_string(),
|
||||||
|
scopes: vec!["relay:connect".to_string()],
|
||||||
|
resources: std::collections::HashMap::new(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_api_key(&self, token: &str) -> Option<Identity> {
|
||||||
|
if !token.starts_with(API_KEY_PREFIX) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let prefix_part = &token[..token.len().min(8)];
|
||||||
|
|
||||||
|
let entry = self
|
||||||
|
.api_keys
|
||||||
|
.iter()
|
||||||
|
.find(|e| prefix_part.starts_with(&e.prefix))?;
|
||||||
|
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(token.as_bytes());
|
||||||
|
let result = hasher.finalize();
|
||||||
|
let expected_hash = format!("sha256:{}", hex::encode(result));
|
||||||
|
|
||||||
|
if entry.hash != expected_hash {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(expires_at) = entry.expires_at {
|
||||||
|
let now_secs = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::SystemTime::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
if now_secs >= expires_at {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(Identity {
|
||||||
|
id: entry.prefix.clone(),
|
||||||
|
scopes: entry.scopes.clone(),
|
||||||
|
resources: std::collections::HashMap::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RateLimitConfig {
|
||||||
|
pub max_connections_per_ip: usize,
|
||||||
|
pub max_auth_attempts: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RateLimitConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_connections_per_ip: 100,
|
||||||
|
max_auth_attempts: 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct DynamicConfig {
|
||||||
|
pub auth: AuthPolicy,
|
||||||
|
pub rate_limits: RateLimitConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ConfigReloadHandle {
|
||||||
|
dynamic: Arc<ArcSwap<DynamicConfig>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigReloadHandle {
|
||||||
|
pub fn new(dynamic: Arc<ArcSwap<DynamicConfig>>) -> Self {
|
||||||
|
Self { dynamic }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reload(&self, new_config: DynamicConfig) {
|
||||||
|
self.dynamic.store(Arc::new(new_config));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dynamic(&self) -> Arc<DynamicConfig> {
|
||||||
|
self.dynamic.load_full()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ default = []
|
|||||||
secp256k1 = ["dep:secp256k1"]
|
secp256k1 = ["dep:secp256k1"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
bip39 = { version = "2", features = ["rand"] }
|
bip39 = { version = "2", features = ["rand", "zeroize"] }
|
||||||
ed25519-bip32 = "0.4"
|
ed25519-bip32 = "0.4"
|
||||||
aes-gcm = "0.10"
|
aes-gcm = "0.10"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
use zeroize::Zeroize;
|
use zeroize::Zeroize;
|
||||||
|
|
||||||
use crate::protocol::KeyType;
|
use crate::protocol::{DerivedKey, KeyType};
|
||||||
|
|
||||||
/// Default TTL for cached keys (1 hour).
|
/// Default TTL for cached keys (1 hour).
|
||||||
pub const DEFAULT_TTL: Duration = Duration::from_secs(3600);
|
pub const DEFAULT_TTL: Duration = Duration::from_secs(3600);
|
||||||
@@ -18,47 +18,53 @@ pub const DEFAULT_TTL: Duration = Duration::from_secs(3600);
|
|||||||
/// Default maximum number of cache entries.
|
/// Default maximum number of cache entries.
|
||||||
pub const DEFAULT_MAX_ENTRIES: usize = 64;
|
pub const DEFAULT_MAX_ENTRIES: usize = 64;
|
||||||
|
|
||||||
/// A cached derived key with metadata for TTL and LRU tracking.
|
/// A cached derived key. Wraps a `DerivedKey` with cache metadata.
|
||||||
///
|
///
|
||||||
/// The `private_key` field is zeroized on drop via `#[zeroize(drop)]`.
|
/// Derives `Zeroize` and `ZeroizeOnDrop` — the private key is zeroized
|
||||||
/// This is a separate internal type from `DerivedKey` — it holds the same
|
/// when the entry is evicted (LRU/TTL) or the cache is cleared.
|
||||||
/// data but is managed within the cache lifecycle.
|
|
||||||
#[derive(Zeroize)]
|
#[derive(Zeroize)]
|
||||||
#[zeroize(drop)]
|
#[zeroize(drop)]
|
||||||
pub struct CachedKey {
|
pub struct CachedKey {
|
||||||
/// When this key was derived (for TTL checking).
|
/// The derived key (zeroized on drop).
|
||||||
#[zeroize(skip)]
|
#[zeroize(skip)]
|
||||||
pub derived_at: Instant,
|
pub key: DerivedKey,
|
||||||
/// The type of key that was derived.
|
/// When the entry was inserted (for TTL).
|
||||||
#[zeroize(skip)]
|
#[zeroize(skip)]
|
||||||
pub key_type: KeyType,
|
pub cached_at: Instant,
|
||||||
/// The private key bytes (sensitive — zeroized on drop).
|
|
||||||
#[zeroize]
|
|
||||||
pub private_key: Vec<u8>,
|
|
||||||
/// The public key bytes.
|
|
||||||
#[zeroize(skip)]
|
|
||||||
pub public_key: Vec<u8>,
|
|
||||||
/// Last access time for LRU ordering.
|
/// Last access time for LRU ordering.
|
||||||
#[zeroize(skip)]
|
#[zeroize(skip)]
|
||||||
last_accessed: Instant,
|
last_accessed: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CachedKey {
|
impl CachedKey {
|
||||||
/// Create a new `CachedKey` from derived key material.
|
/// Create a new `CachedKey` from a `DerivedKey`.
|
||||||
pub fn new(key_type: KeyType, private_key: Vec<u8>, public_key: Vec<u8>) -> Self {
|
pub fn new(key: DerivedKey) -> Self {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
Self {
|
Self {
|
||||||
derived_at: now,
|
key,
|
||||||
key_type,
|
cached_at: now,
|
||||||
private_key,
|
|
||||||
public_key,
|
|
||||||
last_accessed: now,
|
last_accessed: now,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The key type of the cached derived key.
|
||||||
|
pub fn key_type(&self) -> &KeyType {
|
||||||
|
&self.key.key_type
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The private key bytes of the cached derived key.
|
||||||
|
pub fn private_key(&self) -> &[u8] {
|
||||||
|
&self.key.private_key
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The public key bytes of the cached derived key.
|
||||||
|
pub fn public_key(&self) -> &[u8] {
|
||||||
|
&self.key.public_key
|
||||||
|
}
|
||||||
|
|
||||||
/// Check whether this cached entry has expired.
|
/// Check whether this cached entry has expired.
|
||||||
pub fn is_expired(&self, ttl: Duration) -> bool {
|
pub fn is_expired(&self, ttl: Duration) -> bool {
|
||||||
Instant::now().duration_since(self.derived_at) > ttl
|
Instant::now().duration_since(self.cached_at) > ttl
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Touch the entry to update its last-accessed time (for LRU).
|
/// Touch the entry to update its last-accessed time (for LRU).
|
||||||
@@ -212,8 +218,6 @@ mod drop_tracker {
|
|||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
struct DropTrackedKey {
|
struct DropTrackedKey {
|
||||||
flag: Arc<AtomicBool>,
|
flag: Arc<AtomicBool>,
|
||||||
bytes: Vec<u8>,
|
bytes: Vec<u8>,
|
||||||
@@ -289,7 +293,11 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn make_cached_key(key_type: KeyType) -> CachedKey {
|
fn make_cached_key(key_type: KeyType) -> CachedKey {
|
||||||
CachedKey::new(key_type, vec![0xABu8; 32], vec![0xCDu8; 32])
|
CachedKey::new(DerivedKey {
|
||||||
|
key_type,
|
||||||
|
private_key: vec![0xABu8; 32],
|
||||||
|
public_key: vec![0xCDu8; 32],
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -298,7 +306,7 @@ mod tests {
|
|||||||
cache.insert("m/74'/0'/0'/0'", make_cached_key(KeyType::Ed25519));
|
cache.insert("m/74'/0'/0'/0'", make_cached_key(KeyType::Ed25519));
|
||||||
|
|
||||||
let entry = cache.get("m/74'/0'/0'/0'").unwrap();
|
let entry = cache.get("m/74'/0'/0'/0'").unwrap();
|
||||||
assert_eq!(entry.key_type, KeyType::Ed25519);
|
assert_eq!(*entry.key_type(), KeyType::Ed25519);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -410,23 +418,33 @@ mod tests {
|
|||||||
let mut cache = KeyCache::with_defaults();
|
let mut cache = KeyCache::with_defaults();
|
||||||
cache.insert(
|
cache.insert(
|
||||||
"path1",
|
"path1",
|
||||||
CachedKey::new(KeyType::Ed25519, vec![1u8; 32], vec![2u8; 32]),
|
CachedKey::new(DerivedKey {
|
||||||
|
key_type: KeyType::Ed25519,
|
||||||
|
private_key: vec![1u8; 32],
|
||||||
|
public_key: vec![2u8; 32],
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
cache.insert(
|
cache.insert(
|
||||||
"path1",
|
"path1",
|
||||||
CachedKey::new(KeyType::Aes256Gcm, vec![3u8; 32], vec![4u8; 32]),
|
CachedKey::new(DerivedKey {
|
||||||
|
key_type: KeyType::Aes256Gcm,
|
||||||
|
private_key: vec![3u8; 32],
|
||||||
|
public_key: vec![4u8; 32],
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
let entry = cache.get("path1").unwrap();
|
let entry = cache.get("path1").unwrap();
|
||||||
assert_eq!(entry.key_type, KeyType::Aes256Gcm);
|
assert_eq!(*entry.key_type(), KeyType::Aes256Gcm);
|
||||||
assert_eq!(entry.private_key, vec![3u8; 32]);
|
assert_eq!(entry.private_key(), vec![3u8; 32]);
|
||||||
assert_eq!(cache.len(), 1);
|
assert_eq!(cache.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_lru_eviction_drops_evicted_cached_key() {
|
fn test_lru_eviction_drops_evicted_cached_key() {
|
||||||
let mut config = CacheConfig::default();
|
let config = CacheConfig {
|
||||||
config.max_entries = 2;
|
max_entries: 2,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
let mut cache = KeyCache::new(config);
|
let mut cache = KeyCache::new(config);
|
||||||
|
|
||||||
@@ -444,8 +462,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_ttl_expiry_evicts_entry_on_access() {
|
fn test_ttl_expiry_evicts_entry_on_access() {
|
||||||
let mut config = CacheConfig::default();
|
let config = CacheConfig {
|
||||||
config.ttl = Duration::from_millis(1);
|
ttl: Duration::from_millis(1),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
let mut cache = KeyCache::new(config);
|
let mut cache = KeyCache::new(config);
|
||||||
cache.insert("path1", make_cached_key(KeyType::Ed25519));
|
cache.insert("path1", make_cached_key(KeyType::Ed25519));
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ use aes_gcm::{
|
|||||||
};
|
};
|
||||||
use rand::{rngs::OsRng, RngCore};
|
use rand::{rngs::OsRng, RngCore};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fmt;
|
||||||
use zeroize::Zeroize;
|
use zeroize::Zeroize;
|
||||||
|
|
||||||
/// Current default key version for encryption.
|
/// Current default key version for encryption.
|
||||||
@@ -83,8 +84,9 @@ pub struct EncryptedData {
|
|||||||
/// Encryption key material derived from the seed.
|
/// Encryption key material derived from the seed.
|
||||||
///
|
///
|
||||||
/// Holds the 32-byte AES-256-GCM key and its derivation metadata.
|
/// Holds the 32-byte AES-256-GCM key and its derivation metadata.
|
||||||
/// Zeroized on drop per ADR-038.
|
/// Zeroized on drop per ADR-038. Not `Clone` — move-only, like `DerivedKey`.
|
||||||
#[derive(Clone, Zeroize)]
|
/// Implements a custom redacting `Debug` (never prints key bytes).
|
||||||
|
#[derive(Zeroize)]
|
||||||
#[zeroize(drop)]
|
#[zeroize(drop)]
|
||||||
pub struct EncryptionKey {
|
pub struct EncryptionKey {
|
||||||
key_bytes: [u8; 32],
|
key_bytes: [u8; 32],
|
||||||
@@ -92,18 +94,21 @@ pub struct EncryptionKey {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl EncryptionKey {
|
impl EncryptionKey {
|
||||||
/// Create a new encryption key from raw bytes and a version number.
|
/// Construct from raw 32 bytes. Private — for internal use (tests).
|
||||||
pub fn new(key_bytes: [u8; 32], key_version: u32) -> Self {
|
#[cfg(test)]
|
||||||
|
fn new(key_bytes: [u8; 32], key_version: u32) -> Self {
|
||||||
Self {
|
Self {
|
||||||
key_bytes,
|
key_bytes,
|
||||||
key_version,
|
key_version,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new encryption key from the first 32 bytes of derived key material.
|
/// Take the first 32 bytes of derived key material (the private key
|
||||||
///
|
/// bytes from SLIP-0010 derivation) and construct an `EncryptionKey`.
|
||||||
/// The input is typically the private key bytes from derivation at path
|
/// This is the bridge from `DerivedKey` (SLIP-0010 output) to
|
||||||
/// `m/74'/2'/0'/0'`.
|
/// `EncryptionKey` (AES-256-GCM input). `VaultServiceHandle::encrypt`
|
||||||
|
/// and `decrypt` call this on the cached `DerivedKey` to obtain the
|
||||||
|
/// `EncryptionKey` for the crypto layer.
|
||||||
pub fn from_derived_bytes(bytes: &[u8], key_version: u32) -> Self {
|
pub fn from_derived_bytes(bytes: &[u8], key_version: u32) -> Self {
|
||||||
let mut key = [0u8; 32];
|
let mut key = [0u8; 32];
|
||||||
key.copy_from_slice(&bytes[..32]);
|
key.copy_from_slice(&bytes[..32]);
|
||||||
@@ -113,10 +118,24 @@ impl EncryptionKey {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the key version.
|
/// Return the key version (for rotation tracking).
|
||||||
pub fn version(&self) -> u32 {
|
pub fn version(&self) -> u32 {
|
||||||
self.key_version
|
self.key_version
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return the key bytes (crate-internal — for `encrypt`/`decrypt`).
|
||||||
|
pub(crate) fn key_bytes(&self) -> &[u8; 32] {
|
||||||
|
&self.key_bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for EncryptionKey {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("EncryptionKey")
|
||||||
|
.field("key_version", &self.key_version)
|
||||||
|
.field("key_bytes", &"[REDACTED]")
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encrypt plaintext using an AES-256-GCM key.
|
/// Encrypt plaintext using an AES-256-GCM key.
|
||||||
@@ -133,8 +152,11 @@ impl EncryptionKey {
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// An `EncryptedData` struct suitable for storage in the metagraph.
|
/// An `EncryptedData` struct suitable for storage in the metagraph.
|
||||||
pub fn encrypt(plaintext: &str, key: &EncryptionKey) -> Result<EncryptedData, EncryptionError> {
|
pub(crate) fn encrypt(
|
||||||
let cipher = Aes256Gcm::new_from_slice(&key.key_bytes)
|
plaintext: &str,
|
||||||
|
key: &EncryptionKey,
|
||||||
|
) -> Result<EncryptedData, EncryptionError> {
|
||||||
|
let cipher = Aes256Gcm::new_from_slice(key.key_bytes())
|
||||||
.map_err(|e| EncryptionError::Encryption(format!("invalid key length: {e}")))?;
|
.map_err(|e| EncryptionError::Encryption(format!("invalid key length: {e}")))?;
|
||||||
|
|
||||||
// Generate random IV (12 bytes for AES-GCM) using OsRng CSPRNG
|
// Generate random IV (12 bytes for AES-GCM) using OsRng CSPRNG
|
||||||
@@ -168,8 +190,11 @@ pub fn encrypt(plaintext: &str, key: &EncryptionKey) -> Result<EncryptedData, En
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// The decrypted plaintext string.
|
/// The decrypted plaintext string.
|
||||||
pub fn decrypt(encrypted: &EncryptedData, key: &EncryptionKey) -> Result<String, EncryptionError> {
|
pub(crate) fn decrypt(
|
||||||
let cipher = Aes256Gcm::new_from_slice(&key.key_bytes)
|
encrypted: &EncryptedData,
|
||||||
|
key: &EncryptionKey,
|
||||||
|
) -> Result<String, EncryptionError> {
|
||||||
|
let cipher = Aes256Gcm::new_from_slice(key.key_bytes())
|
||||||
.map_err(|e| EncryptionError::Decryption(format!("invalid key length: {e}")))?;
|
.map_err(|e| EncryptionError::Decryption(format!("invalid key length: {e}")))?;
|
||||||
|
|
||||||
let iv_bytes =
|
let iv_bytes =
|
||||||
@@ -255,4 +280,42 @@ mod tests {
|
|||||||
let result = decrypt(&encrypted, &key2);
|
let result = decrypt(&encrypted, &key2);
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_encryption_key_debug_redacts_key_bytes() {
|
||||||
|
let key = EncryptionKey::new([0xABu8; 32], 2);
|
||||||
|
let debug_output = format!("{:?}", key);
|
||||||
|
assert!(
|
||||||
|
debug_output.contains("[REDACTED]"),
|
||||||
|
"Debug must redact key_bytes, got: {debug_output}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!debug_output.contains("AB"),
|
||||||
|
"Debug must not leak key bytes, got: {debug_output}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
debug_output.contains("key_version"),
|
||||||
|
"Debug must show key_version, got: {debug_output}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_encryption_key_version_accessor() {
|
||||||
|
let key = EncryptionKey::new([0u8; 32], 7);
|
||||||
|
assert_eq!(key.version(), 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_encryption_key_key_bytes_accessor() {
|
||||||
|
let key = EncryptionKey::new([0x42u8; 32], 2);
|
||||||
|
assert_eq!(key.key_bytes(), &[0x42u8; 32]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_encryption_key_from_derived_bytes_takes_first_32() {
|
||||||
|
let derived = [0xAAu8; 64];
|
||||||
|
let key = EncryptionKey::from_derived_bytes(&derived, 3);
|
||||||
|
assert_eq!(key.key_bytes(), &[0xAAu8; 32]);
|
||||||
|
assert_eq!(key.version(), 3);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ impl From<Language> for bip39::Language {
|
|||||||
/// The internal phrase is zeroized on drop.
|
/// The internal phrase is zeroized on drop.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Mnemonic {
|
pub struct Mnemonic {
|
||||||
|
inner: Bip39Mnemonic,
|
||||||
phrase: String,
|
phrase: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,9 +45,7 @@ impl Mnemonic {
|
|||||||
pub fn generate(word_count: usize) -> Result<Self, MnemonicError> {
|
pub fn generate(word_count: usize) -> Result<Self, MnemonicError> {
|
||||||
let mnemonic: Bip39Mnemonic = Bip39Mnemonic::generate(word_count)
|
let mnemonic: Bip39Mnemonic = Bip39Mnemonic::generate(word_count)
|
||||||
.map_err(|e: bip39::Error| MnemonicError::Generation(e.to_string()))?;
|
.map_err(|e: bip39::Error| MnemonicError::Generation(e.to_string()))?;
|
||||||
Ok(Self {
|
Ok(Self::from_bip39(mnemonic))
|
||||||
phrase: mnemonic.to_string(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a mnemonic from an existing phrase string.
|
/// Create a mnemonic from an existing phrase string.
|
||||||
@@ -55,9 +54,15 @@ impl Mnemonic {
|
|||||||
pub fn from_phrase(phrase: &str, _language: Language) -> Result<Self, MnemonicError> {
|
pub fn from_phrase(phrase: &str, _language: Language) -> Result<Self, MnemonicError> {
|
||||||
let mnemonic: Bip39Mnemonic = Bip39Mnemonic::parse_normalized(phrase)
|
let mnemonic: Bip39Mnemonic = Bip39Mnemonic::parse_normalized(phrase)
|
||||||
.map_err(|e: bip39::Error| MnemonicError::InvalidPhrase(e.to_string()))?;
|
.map_err(|e: bip39::Error| MnemonicError::InvalidPhrase(e.to_string()))?;
|
||||||
Ok(Self {
|
Ok(Self::from_bip39(mnemonic))
|
||||||
phrase: mnemonic.to_string(),
|
}
|
||||||
})
|
|
||||||
|
fn from_bip39(mnemonic: Bip39Mnemonic) -> Self {
|
||||||
|
let phrase = mnemonic.to_string();
|
||||||
|
Self {
|
||||||
|
inner: mnemonic,
|
||||||
|
phrase,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Derive the master seed from this mnemonic.
|
/// Derive the master seed from this mnemonic.
|
||||||
@@ -65,9 +70,8 @@ impl Mnemonic {
|
|||||||
/// The optional passphrase is used as the BIP39 password for PBKDF2
|
/// The optional passphrase is used as the BIP39 password for PBKDF2
|
||||||
/// key derivation (BIP39 standard). An empty string means no passphrase.
|
/// key derivation (BIP39 standard). An empty string means no passphrase.
|
||||||
pub fn to_seed(&self, passphrase: Option<&str>) -> Seed {
|
pub fn to_seed(&self, passphrase: Option<&str>) -> Seed {
|
||||||
let mnemonic = Bip39Mnemonic::parse_normalized(&self.phrase).unwrap();
|
|
||||||
let normalized_passphrase = passphrase.unwrap_or("");
|
let normalized_passphrase = passphrase.unwrap_or("");
|
||||||
let seed_bytes = mnemonic.to_seed_normalized(normalized_passphrase);
|
let seed_bytes = self.inner.to_seed_normalized(normalized_passphrase);
|
||||||
Seed {
|
Seed {
|
||||||
bytes: seed_bytes.to_vec(),
|
bytes: seed_bytes.to_vec(),
|
||||||
}
|
}
|
||||||
@@ -84,6 +88,7 @@ impl Mnemonic {
|
|||||||
impl Zeroize for Mnemonic {
|
impl Zeroize for Mnemonic {
|
||||||
fn zeroize(&mut self) {
|
fn zeroize(&mut self) {
|
||||||
self.phrase.zeroize();
|
self.phrase.zeroize();
|
||||||
|
self.inner.zeroize();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -194,9 +194,9 @@ impl VaultServiceHandle {
|
|||||||
|
|
||||||
if let Some(cached) = inner.cache.get(path) {
|
if let Some(cached) = inner.cache.get(path) {
|
||||||
return Ok(DerivedKey {
|
return Ok(DerivedKey {
|
||||||
key_type: cached.key_type.clone(),
|
key_type: cached.key_type().clone(),
|
||||||
private_key: cached.private_key.clone(),
|
private_key: cached.private_key().to_vec(),
|
||||||
public_key: cached.public_key.clone(),
|
public_key: cached.public_key().to_vec(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,8 +204,12 @@ impl VaultServiceHandle {
|
|||||||
let key = derivation::derive_path_from_seed(seed.as_bytes(), path)?;
|
let key = derivation::derive_path_from_seed(seed.as_bytes(), path)?;
|
||||||
let private_key = key.private_key().to_vec();
|
let private_key = key.private_key().to_vec();
|
||||||
let public_key = key.public_key().to_vec();
|
let public_key = key.public_key().to_vec();
|
||||||
let cached = CachedKey::new(KeyType::Ed25519, private_key.clone(), public_key.clone());
|
let derived = DerivedKey {
|
||||||
inner.cache.insert(path, cached);
|
key_type: KeyType::Ed25519,
|
||||||
|
private_key: private_key.clone(),
|
||||||
|
public_key: public_key.clone(),
|
||||||
|
};
|
||||||
|
inner.cache.insert(path, CachedKey::new(derived));
|
||||||
Ok(DerivedKey {
|
Ok(DerivedKey {
|
||||||
key_type: KeyType::Ed25519,
|
key_type: KeyType::Ed25519,
|
||||||
private_key,
|
private_key,
|
||||||
@@ -222,9 +226,9 @@ impl VaultServiceHandle {
|
|||||||
|
|
||||||
if let Some(cached) = inner.cache.get(path) {
|
if let Some(cached) = inner.cache.get(path) {
|
||||||
return Ok(DerivedKey {
|
return Ok(DerivedKey {
|
||||||
key_type: cached.key_type.clone(),
|
key_type: cached.key_type().clone(),
|
||||||
private_key: cached.private_key.clone(),
|
private_key: cached.private_key().to_vec(),
|
||||||
public_key: cached.public_key.clone(),
|
public_key: cached.public_key().to_vec(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,8 +236,12 @@ impl VaultServiceHandle {
|
|||||||
let key = derivation::derive_path_from_seed(seed.as_bytes(), path)?;
|
let key = derivation::derive_path_from_seed(seed.as_bytes(), path)?;
|
||||||
let private_key = key.private_key().to_vec();
|
let private_key = key.private_key().to_vec();
|
||||||
let public_key = key.public_key().to_vec();
|
let public_key = key.public_key().to_vec();
|
||||||
let cached = CachedKey::new(KeyType::Aes256Gcm, private_key.clone(), public_key.clone());
|
let derived = DerivedKey {
|
||||||
inner.cache.insert(path, cached);
|
key_type: KeyType::Aes256Gcm,
|
||||||
|
private_key: private_key.clone(),
|
||||||
|
public_key: public_key.clone(),
|
||||||
|
};
|
||||||
|
inner.cache.insert(path, CachedKey::new(derived));
|
||||||
Ok(DerivedKey {
|
Ok(DerivedKey {
|
||||||
key_type: KeyType::Aes256Gcm,
|
key_type: KeyType::Aes256Gcm,
|
||||||
private_key,
|
private_key,
|
||||||
@@ -273,9 +281,9 @@ impl VaultServiceHandle {
|
|||||||
|
|
||||||
if let Some(cached) = inner.cache.get(path) {
|
if let Some(cached) = inner.cache.get(path) {
|
||||||
return Ok(DerivedKey {
|
return Ok(DerivedKey {
|
||||||
key_type: cached.key_type.clone(),
|
key_type: cached.key_type().clone(),
|
||||||
private_key: cached.private_key.clone(),
|
private_key: cached.private_key().to_vec(),
|
||||||
public_key: cached.public_key.clone(),
|
public_key: cached.public_key().to_vec(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,9 +292,12 @@ impl VaultServiceHandle {
|
|||||||
let key = crate::ethereum::derive_secp256k1_path(seed.as_bytes(), path)?;
|
let key = crate::ethereum::derive_secp256k1_path(seed.as_bytes(), path)?;
|
||||||
let private_key = key.private_key().to_vec();
|
let private_key = key.private_key().to_vec();
|
||||||
let public_key = key.public_key().to_vec();
|
let public_key = key.public_key().to_vec();
|
||||||
let cached =
|
let derived = DerivedKey {
|
||||||
CachedKey::new(KeyType::Secp256k1, private_key.clone(), public_key.clone());
|
key_type: KeyType::Secp256k1,
|
||||||
inner.cache.insert(path, cached);
|
private_key: private_key.clone(),
|
||||||
|
public_key: public_key.clone(),
|
||||||
|
};
|
||||||
|
inner.cache.insert(path, CachedKey::new(derived));
|
||||||
Ok(DerivedKey {
|
Ok(DerivedKey {
|
||||||
key_type: KeyType::Secp256k1,
|
key_type: KeyType::Secp256k1,
|
||||||
private_key,
|
private_key,
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
//! representation handles clamping differently.
|
//! representation handles clamping differently.
|
||||||
|
|
||||||
use alknet_vault::derivation::{derive_path_from_seed, PATHS};
|
use alknet_vault::derivation::{derive_path_from_seed, PATHS};
|
||||||
use alknet_vault::encryption::{decrypt, encrypt, EncryptionKey, CURRENT_KEY_VERSION};
|
|
||||||
use alknet_vault::mnemonic::{Language, Mnemonic};
|
use alknet_vault::mnemonic::{Language, Mnemonic};
|
||||||
use alknet_vault::protocol::KeyType;
|
use alknet_vault::protocol::KeyType;
|
||||||
|
|
||||||
@@ -305,36 +304,6 @@ fn test_aes256gcm_known_key_encrypt_decrypt() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// AES-256-GCM: encrypt/decrypt round-trip through our EncryptionKey API.
|
|
||||||
#[test]
|
|
||||||
fn test_aes256gcm_encryption_key_round_trip() {
|
|
||||||
let key_bytes: [u8; 32] = [0x42u8; 32];
|
|
||||||
let key = EncryptionKey::new(key_bytes, CURRENT_KEY_VERSION);
|
|
||||||
|
|
||||||
let plaintext = "known-plaintext-for-aes-256-gcm-test";
|
|
||||||
|
|
||||||
let encrypted = encrypt(plaintext, &key).unwrap();
|
|
||||||
let decrypted = decrypt(&encrypted, &key).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
decrypted, plaintext,
|
|
||||||
"Round-trip through our API must preserve plaintext"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// AES-256-GCM: wrong key produces decryption failure.
|
|
||||||
#[test]
|
|
||||||
fn test_aes256gcm_wrong_key_fails() {
|
|
||||||
let key1 = EncryptionKey::new([0x01u8; 32], CURRENT_KEY_VERSION);
|
|
||||||
let key2 = EncryptionKey::new([0x02u8; 32], CURRENT_KEY_VERSION);
|
|
||||||
|
|
||||||
let plaintext = "test-data-for-wrong-key";
|
|
||||||
let encrypted = encrypt(plaintext, &key1).unwrap();
|
|
||||||
|
|
||||||
let result = decrypt(&encrypted, &key2);
|
|
||||||
assert!(result.is_err(), "Decryption with wrong key must fail");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Alknet-specific regression tests
|
// Alknet-specific regression tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: core/auth
|
id: core/auth
|
||||||
name: Implement AuthContext, Identity, AuthToken, IdentityProvider trait, and ConfigIdentityProvider
|
name: Implement AuthContext, Identity, AuthToken, IdentityProvider trait, and ConfigIdentityProvider
|
||||||
status: pending
|
status: completed
|
||||||
depends_on: [core/core-types]
|
depends_on: [core/core-types]
|
||||||
scope: moderate
|
scope: moderate
|
||||||
risk: medium
|
risk: medium
|
||||||
@@ -159,4 +159,12 @@ per-request identity takes precedence for ACL.
|
|||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
> To be filled on completion
|
Implemented `AuthContext`, `Identity`, `AuthToken`, `IdentityProvider` trait,
|
||||||
|
and `ConfigIdentityProvider` in `auth.rs`. ConfigIdentityProvider reads from
|
||||||
|
`ArcSwap<DynamicConfig>` on every call (hot-reloadable): fingerprint resolution
|
||||||
|
via `authorized_fingerprints` HashSet, token resolution via `alk_` prefix +
|
||||||
|
SHA-256 hash + expiry check. Also implemented minimal `config.rs` types
|
||||||
|
(`DynamicConfig`, `AuthPolicy`, `ApiKeyEntry`, `RateLimitConfig`,
|
||||||
|
`ConfigReloadHandle`) needed by auth — aligned with architecture docs for the
|
||||||
|
parallel `core/config` task to extend. 27 unit tests pass; clippy clean.
|
||||||
|
Merged to develop.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: vault/review-vault-sync
|
id: vault/review-vault-sync
|
||||||
name: Review vault implementation against specs after all drift fixes
|
name: Review vault implementation against specs after all drift fixes
|
||||||
status: pending
|
status: completed
|
||||||
depends_on: [vault/irpc-removal, vault/osrng-iv-generation, vault/poisoned-lock-recovery, vault/remove-password-derivation, vault/unlock-new-zeroizing-return, vault/key-versioning-rotation, vault/derivedkey-serialization, vault/cache-zeroization-test]
|
depends_on: [vault/irpc-removal, vault/osrng-iv-generation, vault/poisoned-lock-recovery, vault/remove-password-derivation, vault/unlock-new-zeroizing-return, vault/key-versioning-rotation, vault/derivedkey-serialization, vault/cache-zeroization-test]
|
||||||
scope: moderate
|
scope: moderate
|
||||||
risk: low
|
risk: low
|
||||||
@@ -109,4 +109,12 @@ items were missed or incompletely fixed.
|
|||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
> To be filled on completion
|
Reviewed vault crate against all architecture specs. Fixed 5 spec-conformance
|
||||||
|
deviations: (1) EncryptionKey removed Clone (now move-only), added redacting
|
||||||
|
Debug; (2) EncryptionKey::new made private (cfg(test)), added pub(crate)
|
||||||
|
key_bytes(); (3) encrypt/decrypt made pub(crate) per encryption.md, crypto tests
|
||||||
|
moved to unit tests; (4) CachedKey refactored to wrap DerivedKey with
|
||||||
|
cached_at/last_accessed fields per service.md; (5) Mnemonic::to_seed() unwrap()
|
||||||
|
eliminated by storing validated Bip39Mnemonic (enabled bip39 zeroize feature).
|
||||||
|
All 10 drift items verified resolved. 79 lib + 12 integration tests pass; clippy
|
||||||
|
clean with all features. Merged to develop.
|
||||||
Reference in New Issue
Block a user