From c0010c144bed06feaad8170ac12bd85380666db8 Mon Sep 17 00:00:00 2001 From: "glm-5.1" Date: Wed, 10 Jun 2026 07:26:59 +0000 Subject: [PATCH] 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. --- crates/alknet-secret/src/service.rs | 105 ++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/crates/alknet-secret/src/service.rs b/crates/alknet-secret/src/service.rs index 1e26971..40b6498 100644 --- a/crates/alknet-secret/src/service.rs +++ b/crates/alknet-secret/src/service.rs @@ -34,6 +34,9 @@ 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::encryption::{self, EncryptedData, EncryptionKey}; use crate::mnemonic::{Language, Mnemonic, Seed}; @@ -218,6 +221,36 @@ impl SecretServiceHandle { }) } + pub fn derive_password( + &self, + path: &str, + length: usize, + ) -> Result, 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 { + 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. @@ -399,4 +432,76 @@ mod tests { service.lock(); 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); + } }