6 Commits

Author SHA1 Message Date
bc8e329f90 vault: replace unwrap() on RwLock with poisoned-lock recovery (task: vault/poisoned-lock-recovery)
Drift item #2: replace all .read().unwrap()/.write().unwrap() calls in
VaultServiceHandle with .unwrap_or_else(|e| e.into_inner()) to recover from
poisoned locks instead of bricking the vault. Added test_poisoned_lock_recovery
that poisons the lock via a panicking thread and verifies the vault remains
usable.

Refs: docs/architecture/crates/vault/README.md drift #2
Implements: ADR-025

# Conflicts:
#	crates/alknet-vault/src/service.rs
2026-06-23 13:35:53 +00:00
ad1174b485 vault: change unlock_new return type to Zeroizing<String> (task: vault/unlock-new-zeroizing-return)
Drift item #8: the mnemonic phrase is the root of trust — it must not linger in
freed heap memory. Changed unlock_new return from String to Zeroizing<String>
(zeroized on drop). Existing tests work via Deref coercion.

Refs: docs/architecture/crates/vault/README.md drift #8
Implements: ADR-025 (resolves W7)
2026-06-23 13:33:55 +00:00
aec4bc9b87 refactor(vault): remove derive_password and site_password_path (task: vault/remove-password-derivation)
Drift item #7: remove the password-manager pattern (derive_password,
derive_password_string, site_password_path) — not relevant to an RPC system's
vault. Removed methods, path function, doc-table row, all tests, and the
now-unused base64 URL_SAFE_NO_PAD import.

Refs: docs/architecture/crates/vault/README.md drift #7
Implements: ADR-025 (resolves C9)
2026-06-23 13:33:36 +00:00
9045dd83d3 vault: replace RwLock unwrap with poisoned-lock recovery
Replace all .read().unwrap() and .write().unwrap() calls in
VaultServiceHandle methods with .unwrap_or_else(|e| e.into_inner())
so a panic while holding the lock does not brick the vault for all
subsequent operations. Add unit test that poisons the lock and
verifies the next call recovers.
2026-06-23 13:33:00 +00:00
685413dee4 vault: return Zeroizing<String> from unlock_new
Change unlock_new return type from String to Zeroizing<String>
so the generated mnemonic phrase is zeroized on drop and does not
linger in freed heap memory. Resolves drift item #8 / review W7.
2026-06-23 13:33:00 +00:00
06b715322a refactor(vault): remove derive_password and site_password_path (ADR-025)
Drop the password-manager pattern from alknet-vault (drift item #7,
ADR-025, resolves review #002 C9). Site-specific password derivation
is not relevant to an RPC system's vault.

Removed:
- derive_password method from VaultServiceHandle (service.rs)
- derive_password_string method from VaultServiceHandle (service.rs)
- site_password_path function from derivation.rs
- site-password derivation path row from derivation.rs doc table
- All password-derivation tests from service.rs and derivation.rs
- Now-unused base64 URL_SAFE_NO_PAD import from service.rs
2026-06-23 13:32:45 +00:00
2 changed files with 37 additions and 120 deletions

View File

@@ -11,7 +11,6 @@
//! | `m/74'/0'/0'/0'` | Primary identity keypair | Ed25519 (alknet auth) |
//! | `m/74'/0'/0'/{n}'` | Worker/device identity | Ed25519 |
//! | `m/74'/0'/1'/0'` | SSH host key | Ed25519 |
//! | `m/74'/1'/0'/{hash}'` | Site-specific password | Deterministic |
//! | `m/74'/2'/0'/0'` | Encryption key for external credentials | AES-256-GCM |
//! | `m/44'/60'/0'/0/0` | Ethereum signing key | secp256k1 |
@@ -52,13 +51,6 @@ pub fn device_path(index: u32) -> String {
format!("m/74'/0'/0'/{}'", index)
}
/// Construct a site-specific password derivation path with the given hash.
///
/// Path: `m/74'/1'/0'/{hash}'`
pub fn site_password_path(site_hash: &str) -> String {
format!("m/74'/1'/0'/{}'", site_hash)
}
/// A derived extended private key with its public key.
///
/// Contains the private key bytes and public key bytes from
@@ -248,11 +240,6 @@ mod tests {
assert_eq!(device_path(1), "m/74'/0'/0'/1'");
}
#[test]
fn test_site_password_path() {
assert_eq!(site_password_path("abc123"), "m/74'/1'/0'/abc123'");
}
#[test]
fn test_derive_master_key_from_seed() {
// Use a known 64-byte seed

View File

@@ -42,14 +42,12 @@
use std::sync::{Arc, RwLock};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use crate::cache::{CacheConfig, CachedKey, KeyCache};
use crate::derivation::{self, DerivationError, PATHS};
use crate::encryption::{self, EncryptedData, EncryptionKey};
use crate::mnemonic::{Language, Mnemonic, Seed};
use crate::protocol::{DerivedKey, KeyType};
use zeroize::Zeroizing;
/// Handle to a running VaultService for local (in-process) use.
///
@@ -132,7 +130,7 @@ impl VaultServiceHandle {
/// The passphrase is the BIP39 password (may be empty string for none).
/// After unlocking, derive and encrypt/decrypt operations are available.
pub fn unlock(&self, phrase: &str, passphrase: Option<&str>) -> Result<(), VaultServiceError> {
let mut inner = self.inner.write().unwrap();
let mut inner = self.inner.write().unwrap_or_else(|e| e.into_inner());
if inner.unlocked {
return Err(VaultServiceError::AlreadyUnlocked);
}
@@ -150,15 +148,15 @@ impl VaultServiceHandle {
///
/// Returns the generated mnemonic phrase. Store this phrase securely —
/// it is the root of trust for all derived keys.
pub fn unlock_new(&self, word_count: usize) -> Result<String, VaultServiceError> {
let mut inner = self.inner.write().unwrap();
pub fn unlock_new(&self, word_count: usize) -> Result<Zeroizing<String>, VaultServiceError> {
let mut inner = self.inner.write().unwrap_or_else(|e| e.into_inner());
if inner.unlocked {
return Err(VaultServiceError::AlreadyUnlocked);
}
let mnemonic = Mnemonic::generate(word_count)?;
let seed = mnemonic.to_seed(None);
let phrase = mnemonic.phrase().to_string();
let phrase = Zeroizing::new(mnemonic.phrase().to_string());
inner.mnemonic = Some(mnemonic);
inner.seed = Some(seed);
@@ -172,7 +170,7 @@ impl VaultServiceHandle {
/// until `unlock` is called again. Calls `zeroize()` on all sensitive
/// material per ADR-038.
pub fn lock(&self) {
let mut inner = self.inner.write().unwrap();
let mut inner = self.inner.write().unwrap_or_else(|e| e.into_inner());
inner.cache.clear();
inner.seed = None;
inner.mnemonic = None;
@@ -181,12 +179,15 @@ impl VaultServiceHandle {
/// Check whether the service is currently unlocked.
pub fn is_unlocked(&self) -> bool {
self.inner.read().unwrap().unlocked
self.inner
.read()
.unwrap_or_else(|e| e.into_inner())
.unlocked
}
/// Derive an Ed25519 keypair at the given path.
pub fn derive_ed25519(&self, path: &str) -> Result<DerivedKey, VaultServiceError> {
let mut inner = self.inner.write().unwrap();
let mut inner = self.inner.write().unwrap_or_else(|e| e.into_inner());
if !inner.unlocked {
return Err(VaultServiceError::VaultLocked);
}
@@ -214,7 +215,7 @@ impl VaultServiceHandle {
/// Derive an AES-256-GCM encryption key at the given path.
pub fn derive_encryption_key(&self, path: &str) -> Result<DerivedKey, VaultServiceError> {
let mut inner = self.inner.write().unwrap();
let mut inner = self.inner.write().unwrap_or_else(|e| e.into_inner());
if !inner.unlocked {
return Err(VaultServiceError::VaultLocked);
}
@@ -248,7 +249,7 @@ impl VaultServiceHandle {
pub fn derive_ethereum_key(&self, path: &str) -> Result<DerivedKey, VaultServiceError> {
#[cfg(feature = "secp256k1")]
{
let mut inner = self.inner.write().unwrap();
let mut inner = self.inner.write().unwrap_or_else(|e| e.into_inner());
if !inner.unlocked {
return Err(VaultServiceError::VaultLocked);
}
@@ -283,29 +284,6 @@ impl VaultServiceHandle {
}
}
pub fn derive_password(&self, path: &str, length: usize) -> Result<Vec<u8>, VaultServiceError> {
let inner = self.inner.read().unwrap();
if !inner.unlocked {
return Err(VaultServiceError::VaultLocked);
}
let seed = inner.seed.as_ref().ok_or(VaultServiceError::VaultLocked)?;
let key = derivation::derive_path_from_seed(seed.as_bytes(), path)?;
let private_key = key.private_key();
let truncated_len = length.min(private_key.len());
let result = private_key[..truncated_len].to_vec();
Ok(result)
}
pub fn derive_password_string(
&self,
path: &str,
length: usize,
) -> Result<String, VaultServiceError> {
let bytes = self.derive_password(path, length)?;
Ok(URL_SAFE_NO_PAD.encode(&bytes))
}
/// Encrypt plaintext using the derived encryption key.
///
/// Uses the key at path `m/74'/2'/0'/0'` (PATHS::ENCRYPTION) by default.
@@ -314,7 +292,7 @@ impl VaultServiceHandle {
plaintext: &str,
key_version: u32,
) -> Result<EncryptedData, VaultServiceError> {
let mut inner = self.inner.write().unwrap();
let mut inner = self.inner.write().unwrap_or_else(|e| e.into_inner());
if !inner.unlocked {
return Err(VaultServiceError::VaultLocked);
}
@@ -338,7 +316,7 @@ impl VaultServiceHandle {
/// Decrypt an EncryptedData blob using the derived encryption key.
pub fn decrypt(&self, encrypted: &EncryptedData) -> Result<String, VaultServiceError> {
let mut inner = self.inner.write().unwrap();
let mut inner = self.inner.write().unwrap_or_else(|e| e.into_inner());
if !inner.unlocked {
return Err(VaultServiceError::VaultLocked);
}
@@ -429,6 +407,28 @@ mod tests {
assert!(service.derive_ed25519(PATHS::IDENTITY).is_err());
}
#[test]
fn test_poisoned_lock_recovery() {
let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap();
let inner_arc = service.inner.clone();
std::thread::spawn(move || {
let _guard = inner_arc.write().unwrap();
panic!("simulated panic while holding write lock");
})
.join()
.expect_err("thread must panic to poison the lock");
assert!(
service.is_unlocked(),
"vault must remain usable after a poisoned lock"
);
let key = service.derive_ed25519(PATHS::IDENTITY).unwrap();
assert!(!key.private_key.is_empty());
}
#[test]
fn test_unlock_with_known_phrase() {
let service = VaultServiceHandle::new();
@@ -463,76 +463,6 @@ mod tests {
assert!(service.decrypt(&encrypted).is_err());
}
#[test]
fn test_derive_password_deterministic() {
let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap();
let path = "m/74'/1'/0'/12345'";
let pw1 = service.derive_password(path, 16).unwrap();
let pw2 = service.derive_password(path, 16).unwrap();
assert_eq!(pw1, pw2, "derive_password must be deterministic");
}
#[test]
fn test_derive_password_different_paths() {
let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap();
let pw_a = service.derive_password("m/74'/1'/0'/100'", 16).unwrap();
let pw_b = service.derive_password("m/74'/1'/0'/200'", 16).unwrap();
assert_ne!(
pw_a, pw_b,
"different paths must produce different passwords"
);
}
#[test]
fn test_derive_password_length_truncation() {
let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap();
let path = "m/74'/1'/0'/999'";
let pw_full = service.derive_password(path, 32).unwrap();
let pw_short = service.derive_password(path, 16).unwrap();
assert_eq!(pw_short.len(), 16);
assert_eq!(pw_full.len(), 32);
assert_eq!(
&pw_full[..16],
&pw_short[..],
"truncated bytes must match prefix of full key"
);
}
#[test]
fn test_derive_password_locked_error() {
let service = VaultServiceHandle::new();
let result = service.derive_password("m/74'/1'/0'/1'", 16);
assert!(matches!(result, Err(VaultServiceError::VaultLocked)));
}
#[test]
fn test_derive_password_string_base64url() {
let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap();
let path = "m/74'/1'/0'/42'";
let encoded = service.derive_password_string(path, 16).unwrap();
assert!(!encoded.contains('='), "Base64url must not contain padding");
assert!(
encoded
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
"Base64url must only contain URL-safe characters"
);
let raw_bytes = service.derive_password(path, 16).unwrap();
let decoded = URL_SAFE_NO_PAD.decode(&encoded).unwrap();
assert_eq!(raw_bytes, decoded);
}
#[cfg(feature = "secp256k1")]
#[test]
fn test_derive_ethereum_key_bip32() {