|
|
|
|
@@ -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() {
|
|
|
|
|
|