Add derive_password and derive_password_string methods to SecretServiceHandle
Implements deterministic password derivation using SLIP-0010 at the given path. derive_password returns raw truncated key bytes; derive_password_string returns Base64url-encoded (no padding) string. Both require unlocked state (ServiceLocked if locked). Includes unit tests for determinism, different paths, length truncation, locked error, and Base64url encoding.
This commit is contained in:
@@ -34,6 +34,9 @@
|
|||||||
|
|
||||||
use std::sync::{Arc, RwLock};
|
use std::sync::{Arc, RwLock};
|
||||||
|
|
||||||
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||||
|
use base64::Engine;
|
||||||
|
|
||||||
use crate::derivation::{self, DerivationError, PATHS};
|
use crate::derivation::{self, DerivationError, PATHS};
|
||||||
use crate::encryption::{self, EncryptedData, EncryptionKey};
|
use crate::encryption::{self, EncryptedData, EncryptionKey};
|
||||||
use crate::mnemonic::{Language, Mnemonic, Seed};
|
use crate::mnemonic::{Language, Mnemonic, Seed};
|
||||||
@@ -218,6 +221,36 @@ impl SecretServiceHandle {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn derive_password(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
length: usize,
|
||||||
|
) -> Result<Vec<u8>, SecretServiceError> {
|
||||||
|
let inner = self.inner.read().unwrap();
|
||||||
|
if !inner.unlocked {
|
||||||
|
return Err(SecretServiceError::ServiceLocked);
|
||||||
|
}
|
||||||
|
let seed = inner
|
||||||
|
.seed
|
||||||
|
.as_ref()
|
||||||
|
.ok_or(SecretServiceError::ServiceLocked)?;
|
||||||
|
|
||||||
|
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, SecretServiceError> {
|
||||||
|
let bytes = self.derive_password(path, length)?;
|
||||||
|
Ok(URL_SAFE_NO_PAD.encode(&bytes))
|
||||||
|
}
|
||||||
|
|
||||||
/// Encrypt plaintext using the derived encryption key.
|
/// Encrypt plaintext using the derived encryption key.
|
||||||
///
|
///
|
||||||
/// Uses the key at path `m/74'/2'/0'/0'` (PATHS::ENCRYPTION) by default.
|
/// Uses the key at path `m/74'/2'/0'/0'` (PATHS::ENCRYPTION) by default.
|
||||||
@@ -399,4 +432,76 @@ mod tests {
|
|||||||
service.lock();
|
service.lock();
|
||||||
assert!(service.decrypt(&encrypted).is_err());
|
assert!(service.decrypt(&encrypted).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_derive_password_deterministic() {
|
||||||
|
let service = SecretServiceHandle::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 = SecretServiceHandle::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 = SecretServiceHandle::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 = SecretServiceHandle::new();
|
||||||
|
let result = service.derive_password("m/74'/1'/0'/1'", 16);
|
||||||
|
assert!(matches!(result, Err(SecretServiceError::ServiceLocked)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_derive_password_string_base64url() {
|
||||||
|
let service = SecretServiceHandle::new();
|
||||||
|
service.unlock_new(24).unwrap();
|
||||||
|
|
||||||
|
let path = "m/74'/1'/0'/42'";
|
||||||
|
let encoded = service.derive_password_string(path, 16).unwrap();
|
||||||
|
|
||||||
|
// Base64url no-pad: only [A-Za-z0-9-_], no '=' padding
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify round-trip: decode the string and compare with raw bytes
|
||||||
|
let raw_bytes = service.derive_password(path, 16).unwrap();
|
||||||
|
let decoded = URL_SAFE_NO_PAD.decode(&encoded).unwrap();
|
||||||
|
assert_eq!(raw_bytes, decoded);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user