feat(secret): add alknet-secret crate and architecture spec for Phase 3

Create the alknet-secret crate with BIP39 mnemonic generation, SLIP-0010
Ed25519 HD key derivation, AES-256-GCM encryption, and SecretProtocol
irpc service definition. This is Phase 3.1 from the integration plan.

Architecture changes:
- Promote secret-service.md to reviewed status with full spec format
  (crate structure, public API, security model, phase progression,
   ADR/OQ cross-references, wire format compatibility section)
- Add ADR-038 (seed lifecycle and memory security): zeroize for v1,
  mlock deferred to Phase B
- Add OQ-SEC-01 (mlock/VirtualLock for seed RAM) to open-questions.md
- Update README.md with ADR-038 and secret-service status

Crate structure:
- src/mnemonic.rs: BIP39 phrase generation, validation, seed derivation
- src/derivation.rs: SLIP-0010 HD key derivation, path constants (74')
- src/encryption.rs: AES-256-GCM encrypt/decrypt, EncryptedData type
- src/protocol.rs: SecretProtocol irpc enum, DerivedKey, KeyType
- src/service.rs: SecretServiceHandle with Unlock/Lock lifecycle
- 40 passing tests (unit + integration + doc)
This commit is contained in:
2026-06-09 13:49:53 +00:00
parent d1c57627c6
commit 04e969982e
16 changed files with 1882 additions and 62 deletions

View File

@@ -0,0 +1,26 @@
[package]
name = "alknet-secret"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "BIP39 mnemonic generation, SLIP-0010 Ed25519 HD key derivation, AES-256-GCM encryption, and SecretProtocol irpc service for alknet"
repository.workspace = true
[lib]
name = "alknet_secret"
[dependencies]
bip39 = { version = "2", features = ["rand"] }
ed25519-bip32 = "0.4"
aes-gcm = "0.10"
sha2 = "0.10"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
zeroize = { version = "1", features = ["derive"] }
hmac = "0.12"
rand = "0.8"
base64 = "0.22"
[dev-dependencies]
hex = "0.4"

View File

@@ -0,0 +1,296 @@
//! SLIP-0010 Ed25519 HD key derivation and path constants.
//!
//! This module provides hierarchical deterministic (HD) key derivation following
//! SLIP-0010 for Ed25519 keys and BIP-0032 for secp256k1 keys. The `74'`
//! coin type is unallocated per SLIP-0044 and reserved for alknet.
//!
//! # Derivation Paths
//!
//! | Path | Purpose | Curve/Algorithm |
//! |------|---------|----------------|
//! | `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 |
use ed25519_bip32::XPrv;
use hmac::{Hmac, Mac};
use sha2::Sha512;
use zeroize::Zeroize;
type HmacSha512 = Hmac<Sha512>;
/// Well-known derivation path constants for alknet key material.
///
/// These paths are defined once and referenced by both the secret service and
/// external consumers that need to request specific key types.
#[allow(non_snake_case)]
pub mod PATHS {
/// Primary identity keypair for alknet authentication.
pub const IDENTITY: &str = "m/74'/0'/0'/0'";
/// Worker/device identity keypair (parameterized by device index).
/// Use `device_path(n)` to construct the full path.
pub const DEVICE_PREFIX: &str = "m/74'/0'/0'";
/// SSH host key.
pub const SSH_HOST: &str = "m/74'/0'/1'/0'";
/// Encryption key for external credentials (AES-256-GCM).
pub const ENCRYPTION: &str = "m/74'/2'/0'/0'";
/// Ethereum signing key.
pub const ETHEREUM: &str = "m/44'/60'/0'/0/0";
}
/// Construct a device identity derivation path with the given index.
///
/// Path: `m/74'/0'/0'/{n}'`
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
/// SLIP-0010 Ed25519 derivation.
#[derive(Clone, Zeroize)]
#[zeroize(drop)]
pub struct ExtendedPrivKey {
/// The private key bytes (first 32 bytes of the extended key).
private_key: Vec<u8>,
/// The public key bytes (32 bytes).
public_key: Vec<u8>,
/// The chain code for child derivation (32 bytes).
chain_code: Vec<u8>,
/// The derivation path that produced this key.
path: String,
}
impl ExtendedPrivKey {
/// Returns the private key bytes (32 bytes for Ed25519).
pub fn private_key(&self) -> &[u8] {
&self.private_key
}
/// Returns the public key bytes (32 bytes for Ed25519).
pub fn public_key(&self) -> &[u8] {
&self.public_key
}
/// Returns the derivation path string.
pub fn path(&self) -> &str {
&self.path
}
}
/// Derive an extended private key from a seed and derivation path.
///
/// This is the primary entry point for HD key derivation. Create a master key
/// from the seed, then derive the specified path.
///
/// # Example
///
/// ```
/// use alknet_secret::derivation::{derive_path_from_seed, PATHS};
/// use alknet_secret::mnemonic::Mnemonic;
///
/// let mnemonic = Mnemonic::generate(24).unwrap();
/// let seed = mnemonic.to_seed(None);
/// let identity_key = derive_path_from_seed(seed.as_bytes(), PATHS::IDENTITY).unwrap();
/// assert!(!identity_key.private_key().is_empty());
/// ```
pub fn derive_path_from_seed(seed: &[u8], path: &str) -> Result<ExtendedPrivKey, DerivationError> {
let indices = parse_derivation_path(path)?;
let xprv = derive_master_key(seed)?;
let mut current = xprv;
for index in indices {
current = current.derive(ed25519_bip32::DerivationScheme::V2, index);
}
let public_key = current.public();
Ok(ExtendedPrivKey {
private_key: current.extended_secret_key_bytes()[..32].to_vec(),
public_key: public_key.as_ref()[..32].to_vec(),
chain_code: current.chain_code().to_vec(),
path: path.to_string(),
})
}
/// Derive the SLIP-0010 Ed25519 master key from a seed.
///
/// Uses HMAC-SHA512 with key "ed25519 seed" over the seed bytes,
/// following SLIP-0010 specification.
fn derive_master_key(seed: &[u8]) -> Result<XPrv, DerivationError> {
let mut mac =
HmacSha512::new_from_slice(b"ed25519 seed").map_err(|e| DerivationError::Hmac(e.to_string()))?;
mac.update(seed);
let result = mac.finalize().into_bytes();
// First 32 bytes: private key (kL in SLIP-0010)
// Next 32 bytes: chain code
let private_key_bytes = &result[..32];
let chain_code_bytes = &result[32..];
// Construct XPrv from the HMAC result
// ed25519-bip32 expects a 96-byte extended key:
// [32 bytes: kL || 32 bytes: kR (extended secret key) || 32 bytes: chain code]
// SLIP-0010 uses the first 32 bytes as kL and hashes through SHA-512
// to get the full extended key. We use from_nonextended_force to handle this.
let mut priv_bytes = [0u8; 32];
priv_bytes.copy_from_slice(private_key_bytes);
let mut cc_bytes = [0u8; 32];
cc_bytes.copy_from_slice(chain_code_bytes);
Ok(XPrv::from_nonextended_force(&priv_bytes, &cc_bytes))
}
/// Parse a derivation path string into hardened child indices.
///
/// Path format: `m/74'/0'/0'/0'`
/// Each component must be a hardened index (with `'` or `h` suffix).
/// Unhardened indices are allowed for BIP-0032 paths (e.g., Ethereum `m/44'/60'/0'/0/0`).
fn parse_derivation_path(path: &str) -> Result<Vec<u32>, DerivationError> {
if !path.starts_with('m') {
return Err(DerivationError::InvalidPath(
"path must start with 'm'".to_string(),
));
}
let mut indices = Vec::new();
let parts: Vec<&str> = path.split('/').skip(1).collect(); // skip "m"
for part in parts {
let hardened = part.ends_with('\'') || part.ends_with('h');
let index_str = part.trim_end_matches('\'').trim_end_matches('h');
let index: u32 = index_str
.parse()
.map_err(|_| DerivationError::InvalidPath(format!("invalid index: {part}")))?;
if hardened {
indices.push(index + 0x80000000);
} else {
indices.push(index);
}
}
Ok(indices)
}
/// Errors that can occur during key derivation.
#[derive(Debug, thiserror::Error)]
pub enum DerivationError {
#[error("invalid derivation path: {0}")]
InvalidPath(String),
#[error("HMAC error: {0}")]
Hmac(String),
#[error("key derivation error: {0}")]
KeyDerivation(String),
#[error("seed is not unlocked")]
Locked,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_derivation_path_hardened() {
let indices = parse_derivation_path("m/74'/0'/0'/0'").unwrap();
assert_eq!(
indices,
vec![0x80000000 + 74, 0x80000000, 0x80000000, 0x80000000]
);
}
#[test]
fn test_parse_derivation_path_mixed() {
// Ethereum path has unhardened indices
let indices = parse_derivation_path("m/44'/60'/0'/0/0").unwrap();
assert_eq!(
indices,
vec![0x80000000 + 44, 0x80000000 + 60, 0x80000000, 0, 0]
);
}
#[test]
fn test_parse_rejects_no_m_prefix() {
let result = parse_derivation_path("74'/0'/0'/0'");
assert!(result.is_err());
}
#[test]
fn test_path_constants() {
assert_eq!(PATHS::IDENTITY, "m/74'/0'/0'/0'");
assert_eq!(PATHS::ENCRYPTION, "m/74'/2'/0'/0'");
assert_eq!(PATHS::SSH_HOST, "m/74'/0'/1'/0'");
assert_eq!(PATHS::ETHEREUM, "m/44'/60'/0'/0/0");
}
#[test]
fn test_device_path() {
assert_eq!(device_path(0), "m/74'/0'/0'/0'");
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
let seed = [0xABu8; 64];
let result = derive_master_key(&seed);
assert!(result.is_ok());
}
#[test]
fn test_derive_identity_key_from_random_seed() {
let mnemonic = crate::mnemonic::Mnemonic::generate(24).unwrap();
let seed = mnemonic.to_seed(None);
let key = derive_path_from_seed(seed.as_bytes(), PATHS::IDENTITY);
assert!(key.is_ok());
let key = key.unwrap();
assert_eq!(key.private_key().len(), 32);
assert_eq!(key.public_key().len(), 32);
assert_eq!(key.path(), PATHS::IDENTITY);
}
#[test]
fn test_deterministic_derivation() {
let mnemonic = crate::mnemonic::Mnemonic::generate(24).unwrap();
let seed = mnemonic.to_seed(None);
let key1 = derive_path_from_seed(seed.as_bytes(), PATHS::IDENTITY).unwrap();
let key2 = derive_path_from_seed(seed.as_bytes(), PATHS::IDENTITY).unwrap();
assert_eq!(key1.private_key(), key2.private_key());
assert_eq!(key1.public_key(), key2.public_key());
}
#[test]
fn test_different_paths_different_keys() {
let mnemonic = crate::mnemonic::Mnemonic::generate(24).unwrap();
let seed = mnemonic.to_seed(None);
let identity = derive_path_from_seed(seed.as_bytes(), PATHS::IDENTITY).unwrap();
let ssh = derive_path_from_seed(seed.as_bytes(), PATHS::SSH_HOST).unwrap();
assert_ne!(identity.private_key(), ssh.private_key());
assert_ne!(identity.public_key(), ssh.public_key());
}
}

View File

@@ -0,0 +1,228 @@
//! AES-256-GCM encryption and decryption for external credentials.
//!
//! External credentials (API keys, OAuth tokens) that cannot be derived from the
//! seed are encrypted using a key derived from the seed at path `m/74'/2'/0'/0'`.
//! The `EncryptedData` type stores the key version, salt, IV, and ciphertext.
//!
//! # Wire Format
//!
//! The `EncryptedData` struct is the stable wire format shared with alknet-storage.
//! This is type-level compatibility, not a crate dependency. Both crates must
//! agree on the serialization format.
//!
//! # Key Versioning
//!
//! Key versioning allows re-encryption when the encryption key is rotated. The
//! current key version is `1`. To rotate:
//! 1. Derive a new key from a new derivation path or new seed
//! 2. Decrypt all existing `EncryptedData` with key version 1
//! 3. Re-encrypt with key version 2
//! 4. Update storage
use aes_gcm::{
aead::{Aead, KeyInit},
Aes256Gcm, Nonce,
};
use serde::{Deserialize, Serialize};
use zeroize::Zeroize;
/// Current default key version for encryption.
pub const CURRENT_KEY_VERSION: u32 = 1;
/// Encrypted data blob stored in the metagraph.
///
/// This is the stable wire format shared with alknet-storage. The fields are
/// Base64-encoded strings for JSON serialization compatibility.
///
/// # Compatibility
///
/// The Rust `EncryptedData` is a superset of the TypeScript `EncryptedDataSchema`
/// from `@alkdev/storage`. Migration path: re-encrypt TypeScript-encrypted data
/// using the Rust secret service with a new key version.
///
/// See OQ-SVC-03 for the compatibility tracking.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct EncryptedData {
/// Key version for rotation support.
pub key_version: u32,
/// Base64-encoded random salt used for key derivation.
pub salt: String,
/// Base64-encoded initialization vector (12 bytes for AES-GCM).
pub iv: String,
/// Base64-encoded ciphertext (AES-256-GCM encrypted, includes auth tag).
pub data: String,
}
/// Encryption key material derived from the seed.
///
/// Holds the 32-byte AES-256-GCM key and its derivation metadata.
/// Zeroized on drop per ADR-038.
#[derive(Clone, Zeroize)]
#[zeroize(drop)]
pub struct EncryptionKey {
key_bytes: [u8; 32],
key_version: u32,
}
impl EncryptionKey {
/// Create a new encryption key from raw bytes and a version number.
pub fn new(key_bytes: [u8; 32], key_version: u32) -> Self {
Self {
key_bytes,
key_version,
}
}
/// Create a new encryption key from the first 32 bytes of derived key material.
///
/// The input is typically the private key bytes from derivation at path
/// `m/74'/2'/0'/0'`.
pub fn from_derived_bytes(bytes: &[u8], key_version: u32) -> Self {
let mut key = [0u8; 32];
key.copy_from_slice(&bytes[..32]);
Self {
key_bytes: key,
key_version,
}
}
/// Returns the key version.
pub fn version(&self) -> u32 {
self.key_version
}
}
/// Encrypt plaintext using an AES-256-GCM key.
///
/// Generates a random 12-byte IV and a random 32-byte salt for each encryption.
/// The salt allows key rotation without re-deriving from the seed.
///
/// # Arguments
///
/// * `plaintext` - The string to encrypt
/// * `key` - The encryption key derived from the seed
/// * `key_version` - The key version for rotation tracking
///
/// # Returns
///
/// An `EncryptedData` struct suitable for storage in the metagraph.
pub fn encrypt(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}")))?;
// Generate random IV (12 bytes for AES-GCM)
let iv_bytes: [u8; 12] = rand::random();
let nonce = Nonce::from_slice(&iv_bytes);
// Generate random salt (32 bytes)
let salt_bytes: [u8; 32] = rand::random();
let ciphertext = cipher
.encrypt(nonce, plaintext.as_bytes())
.map_err(|e| EncryptionError::Encryption(e.to_string()))?;
Ok(EncryptedData {
key_version: key.key_version,
salt: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &salt_bytes),
iv: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &iv_bytes),
data: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &ciphertext),
})
}
/// Decrypt an `EncryptedData` blob back to plaintext.
///
/// # Arguments
///
/// * `encrypted` - The encrypted data blob from storage
/// * `key` - The encryption key derived from the seed (must match `key_version`)
///
/// # Returns
///
/// The decrypted plaintext string.
pub fn decrypt(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}")))?;
let iv_bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &encrypted.iv)
.map_err(|e| EncryptionError::Decoding(e.to_string()))?;
let nonce = Nonce::from_slice(&iv_bytes);
let ciphertext =
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &encrypted.data)
.map_err(|e| EncryptionError::Decoding(e.to_string()))?;
let plaintext = cipher
.decrypt(nonce, ciphertext.as_ref())
.map_err(|e| EncryptionError::Decryption(e.to_string()))?;
String::from_utf8(plaintext).map_err(|e| EncryptionError::Decryption(e.to_string()))
}
/// Errors that can occur during encryption/decryption operations.
#[derive(Debug, thiserror::Error)]
pub enum EncryptionError {
#[error("encryption error: {0}")]
Encryption(String),
#[error("decryption error: {0}")]
Decryption(String),
#[error("base64 decoding error: {0}")]
Decoding(String),
#[error("key version mismatch: expected {expected}, got {actual}")]
KeyVersionMismatch { expected: u32, actual: u32 },
}
#[cfg(test)]
mod tests {
use super::*;
fn make_test_key() -> EncryptionKey {
let key_bytes = [42u8; 32];
EncryptionKey::new(key_bytes, CURRENT_KEY_VERSION)
}
#[test]
fn test_encrypt_decrypt_round_trip() {
let key = make_test_key();
let plaintext = "hello, world! this is a secret API key";
let encrypted = encrypt(plaintext, &key).unwrap();
let decrypted = decrypt(&encrypted, &key).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_encrypted_data_has_different_iv_each_time() {
let key = make_test_key();
let plaintext = "same input";
let encrypted1 = encrypt(plaintext, &key).unwrap();
let encrypted2 = encrypt(plaintext, &key).unwrap();
// Same plaintext encrypted twice should have different IVs and ciphertexts
assert_ne!(encrypted1.iv, encrypted2.iv);
assert_ne!(encrypted1.data, encrypted2.data);
}
#[test]
fn test_encrypt_decrypt_with_key_version() {
let key = EncryptionKey::new([7u8; 32], 2);
let plaintext = "versioned encryption test";
let encrypted = encrypt(plaintext, &key).unwrap();
assert_eq!(encrypted.key_version, 2);
let decrypted = decrypt(&encrypted, &key).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_decrypt_with_wrong_key_fails() {
let key1 = EncryptionKey::new([1u8; 32], 1);
let key2 = EncryptionKey::new([2u8; 32], 1);
let encrypted = encrypt("secret stuff", &key1).unwrap();
let result = decrypt(&encrypted, &key2);
assert!(result.is_err());
}
}

View File

@@ -0,0 +1,41 @@
//! # alknet-secret
//!
//! BIP39 mnemonic generation, SLIP-0010 Ed25519 HD key derivation, AES-256-GCM
//! encryption for external credentials, and the `SecretProtocol` irpc service.
//!
//! This crate is the only component that holds the master seed phrase. All other
//! crates request derived keys through the `SecretProtocol` irpc service or the
//! `SecretServiceHandle` local API.
//!
//! ## Crate Independence
//!
//! alknet-secret does **not** depend on alknet-core or alknet-storage. Per ADR-027,
//! it is fully independent. The `EncryptedData` wire format is shared with
//! alknet-storage by type-level compatibility, not a crate dependency.
//!
//! ## Security Model
//!
//! The seed phrase is never persisted to disk. It is entered at startup or via
//! `Unlock` and held only in `Zeroize`-protected RAM (ADR-038). `Lock` purges
//! the seed and all cached derived keys.
//!
//! ## Module Organization
//!
//! - [`mnemonic`] — BIP39 mnemonic generation, validation, and seed derivation
//! - [`derivation`] — SLIP-0010 Ed25519 HD key derivation and path constants
//! - [`encryption`] — AES-256-GCM encrypt/decrypt and `EncryptedData` type
//! - [`protocol`] — `SecretProtocol` irpc service enum, `DerivedKey`, `KeyType`
//! - [`service`] — `SecretService` implementation with Unlock/Lock lifecycle
pub mod derivation;
pub mod encryption;
pub mod mnemonic;
pub mod protocol;
pub mod service;
// Re-export primary public API
pub use derivation::{ExtendedPrivKey, PATHS};
pub use encryption::{EncryptedData, EncryptionError};
pub use mnemonic::{Language, Mnemonic, Seed};
pub use protocol::{DerivedKey, KeyType, SecretMessage, SecretProtocol};
pub use service::{SecretService, SecretServiceError, SecretServiceHandle};

View File

@@ -0,0 +1,161 @@
//! BIP39 mnemonic generation, validation, and seed derivation.
//!
//! This module handles the root of trust: the BIP39 mnemonic seed phrase. From
//! a single mnemonic, all self-generated secrets can be derived on demand.
//!
//! # Security
//!
//! Seed material is protected with `Zeroize` to ensure it is overwritten in
//! memory before deallocation (ADR-038). The seed is never written to disk.
use bip39::Mnemonic as Bip39Mnemonic;
use zeroize::Zeroize;
/// BIP39 word list language.
///
/// Currently only English is supported, matching the BIP39 reference
/// implementation and the vast majority of wallet software.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Language {
English,
}
impl From<Language> for bip39::Language {
fn from(lang: Language) -> Self {
match lang {
Language::English => bip39::Language::English,
}
}
}
/// A BIP39 mnemonic seed phrase.
///
/// Wraps the `bip39` crate's `Mnemonic` type and provides seed derivation.
/// The internal phrase is zeroized on drop.
#[derive(Debug)]
pub struct Mnemonic {
phrase: String,
}
impl Mnemonic {
/// Generate a new random mnemonic with the given word count.
///
/// Supported word counts: 12, 15, 18, 21, 24.
pub fn generate(word_count: usize) -> Result<Self, MnemonicError> {
let mnemonic: Bip39Mnemonic = Bip39Mnemonic::generate(word_count)
.map_err(|e: bip39::Error| MnemonicError::Generation(e.to_string()))?;
Ok(Self {
phrase: mnemonic.to_string(),
})
}
/// Create a mnemonic from an existing phrase string.
///
/// Validates the phrase against the BIP39 word list and checksum.
pub fn from_phrase(phrase: &str, _language: Language) -> Result<Self, MnemonicError> {
let mnemonic: Bip39Mnemonic = Bip39Mnemonic::parse_normalized(phrase)
.map_err(|e: bip39::Error| MnemonicError::InvalidPhrase(e.to_string()))?;
Ok(Self {
phrase: mnemonic.to_string(),
})
}
/// Derive the master seed from this mnemonic.
///
/// The optional passphrase is used as the BIP39 password for PBKDF2
/// key derivation (BIP39 standard). An empty string means no passphrase.
pub fn to_seed(&self, passphrase: Option<&str>) -> Seed {
let mnemonic = Bip39Mnemonic::parse_normalized(&self.phrase).unwrap();
let normalized_passphrase = passphrase.unwrap_or("");
let seed_bytes = mnemonic.to_seed_normalized(normalized_passphrase);
Seed {
bytes: seed_bytes.to_vec(),
}
}
/// Returns the mnemonic phrase as a string.
///
/// Handle with care — this is the root of trust for all derived keys.
pub fn phrase(&self) -> &str {
&self.phrase
}
}
impl Zeroize for Mnemonic {
fn zeroize(&mut self) {
self.phrase.zeroize();
}
}
impl Drop for Mnemonic {
fn drop(&mut self) {
self.zeroize();
}
}
/// A BIP39-derived master seed.
///
/// Contains the 64-byte seed material from which all HD keys are derived.
/// Zeroized on drop per ADR-038.
#[derive(Clone, Zeroize)]
#[zeroize(drop)]
pub struct Seed {
bytes: Vec<u8>,
}
impl Seed {
/// Returns the seed bytes.
///
/// These bytes are the input to SLIP-0010 master key derivation.
pub fn as_bytes(&self) -> &[u8] {
&self.bytes
}
/// Returns the length of the seed (always 64 bytes for BIP39).
pub fn len(&self) -> usize {
self.bytes.len()
}
/// Returns whether the seed is empty.
#[must_use]
pub fn is_empty(&self) -> bool {
self.bytes.is_empty()
}
}
/// Errors that can occur during mnemonic operations.
#[derive(Debug, thiserror::Error)]
pub enum MnemonicError {
#[error("failed to generate mnemonic: {0}")]
Generation(String),
#[error("invalid mnemonic phrase: {0}")]
InvalidPhrase(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_mnemonic_24_words() {
let mnemonic = Mnemonic::generate(24).unwrap();
let words: Vec<&str> = mnemonic.phrase().split_whitespace().collect();
assert_eq!(words.len(), 24);
}
#[test]
fn test_mnemonic_round_trip() {
let original = Mnemonic::generate(12).unwrap();
let phrase = original.phrase().to_string();
let restored = Mnemonic::from_phrase(&phrase, Language::English).unwrap();
assert_eq!(original.phrase(), restored.phrase());
}
#[test]
fn test_seed_derivation() {
let mnemonic = Mnemonic::generate(24).unwrap();
let seed = mnemonic.to_seed(None);
assert_eq!(seed.len(), 64);
assert!(!seed.is_empty());
}
}

View File

@@ -0,0 +1,143 @@
//! SecretProtocol irpc service definition and associated types.
//!
//! This module defines the `SecretProtocol` enum for irpc-based inter-service
//! communication. The protocol supports unlock/lock lifecycle, key derivation,
//! and encryption/decryption operations.
//!
//! # Protocol Operation
//!
//! The SecretProtocol follows a lifecycle: the service starts in a **locked**
//! state where no derivation or encryption operations are possible. The `Unlock`
//! call loads the seed into memory (derived from the mnemonic passphrase). After
//! that, derive and encrypt/decrypt operations are available. The `Lock` call
//! purges the seed and all cached keys.
//!
//! # Wire Format
//!
//! For local (in-process) calls, the protocol uses tokio channels directly.
//! For remote (in-cluster) calls, the protocol is serialized with postcard.
//! For cross-node (call protocol) exposure, the service is wrapped in an
//! operation that serializes to JSON.
use serde::{Deserialize, Serialize};
use crate::encryption::EncryptedData;
/// The type of a derived key.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum KeyType {
/// Ed25519 keypair (SLIP-0010 derivation).
Ed25519,
/// AES-256-GCM symmetric key (derived from seed, used for external credential encryption).
Aes256Gcm,
/// secp256k1 keypair (BIP-0032 derivation, for Ethereum signing).
Secp256k1,
}
/// A derived key pair (private key + public key).
///
/// The private key is sensitive material. Consumers should zeroize
/// it when no longer needed. The `SecretServiceHandle` manages the lifecycle
/// of derived keys internally.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DerivedKey {
/// The type of key that was derived.
pub key_type: KeyType,
/// The private key bytes.
pub private_key: Vec<u8>,
/// The public key bytes.
pub public_key: Vec<u8>,
}
/// SecretProtocol service definition.
///
/// This is the irpc protocol enum that defines all secret service operations.
/// The `#[rpc_requests]` macro generates two versions:
/// - **Serializable** (`SecretMessage::Request`): for remote communication (postcard)
/// - **With channels** (`SecretMessage::RequestWithChannels`): for local communication (tokio)
///
/// # State Requirements
///
/// All operations except `Unlock` require the service to be in an **unlocked**
/// state. Calling derive/encrypt/decrypt on a locked service returns an error.
#[derive(Debug, Serialize, Deserialize)]
pub enum SecretProtocol {
/// Derive an Ed25519 keypair at the given path.
///
/// Path format: `m/74'/0'/0'/0'` (SLIP-0010 hardened-only notation).
/// Returns a `DerivedKey` with `KeyType::Ed25519`.
DeriveEd25519 {
/// SLIP-0010 derivation path (e.g., "m/74'/0'/0'/0'").
path: String,
},
/// Derive an AES-256-GCM encryption key at the given path.
///
/// The default encryption path is `m/74'/2'/0'/0'`.
/// Returns a `DerivedKey` with `KeyType::Aes256Gcm`.
DeriveEncryptionKey {
/// SLIP-0010 derivation path for the encryption key.
path: String,
},
/// Derive a secp256k1 (Ethereum) keypair at the given path.
///
/// The default Ethereum path is `m/44'/60'/0'/0/0`.
/// Returns a `DerivedKey` with `KeyType::Secp256k1`.
DeriveEthereumKey {
/// BIP-0032 derivation path (e.g., "m/44'/60'/0'/0/0").
path: String,
},
/// Derive a deterministic password at the given path.
///
/// Path format: `m/74'/1'/0'/{hash}'` (SLIP-0010 hardened notation).
/// The `length` parameter controls the output length.
DerivePassword {
/// SLIP-0010 derivation path for the password.
path: String,
/// Desired password length in bytes.
length: usize,
},
/// Encrypt plaintext using a derived encryption key.
///
/// The key is derived at the path `m/74'/2'/0'/0'` with the given version.
/// Returns an `EncryptedData` blob suitable for storage.
Encrypt {
/// The plaintext string to encrypt.
plaintext: String,
/// The key version for rotation tracking.
key_version: u32,
},
/// Decrypt an `EncryptedData` blob back to plaintext.
///
/// The key is derived from the seed at the path indicated by the key version.
Decrypt {
/// The encrypted data blob to decrypt.
encrypted: EncryptedData,
},
/// Lock the service, purging the seed and all cached derived keys.
///
/// After locking, no derive/encrypt/decrypt operations are possible
/// until `Unlock` is called again. Calls `zeroize()` on all sensitive
/// material (ADR-038).
Lock,
/// Unlock the service with a BIP39 passphrase.
///
/// The passphrase is used to derive the master seed from the mnemonic.
/// After unlocking, derive and encrypt/decrypt operations are available.
Unlock {
/// The BIP39 passphrase (may be empty for no passphrase).
passphrase: String,
},
}
/// Message type for SecretProtocol irpc communication.
///
/// TODO: Replace with irpc `#[rpc_requests]` macro-generated type once
/// the irpc crate is integrated. For now, this is a placeholder type alias.
pub type SecretMessage = SecretProtocol;

View File

@@ -0,0 +1,382 @@
//! SecretService implementation with Unlock/Lock lifecycle.
//!
//! The `SecretService` is the primary runtime interface for key management.
//! It holds the master seed in `Zeroize`-protected memory and provides methods
//! for the Unlock/Lock lifecycle, key derivation, and encryption/decryption.
//!
//! # Lifecycle
//!
//! ```text
//! Unlock(passphrase)
//! → validate mnemonic (if restoring) or generate new
//! → derive master key from seed
//! → store seed in SeedHolder (Zeroize-protected)
//! → cache empty (keys derived on demand)
//!
//! DeriveEd25519/DeriveEncryptionKey/Encrypt/Decrypt
//! → require unlocked state (ServiceLocked error if locked)
//! → derive key, return result
//! → optionally cache derived key
//!
//! Lock
//! → zeroize all cached derived keys
//! → zeroize seed
//! → drop all sensitive material
//! → service returns to locked state
//! ```
//!
//! # Assembly
//!
//! The `SecretService` is assembled by the CLI binary or NAPI layer. Per ADR-027,
//! alknet-core never sees the secret service directly — it is wired through the
//! `OperationEnv` dispatch mechanism. For minimal deployments, no secret service
//! is available (the `SecretStoreCredentialProvider` returns `None`).
use std::sync::{Arc, RwLock};
use crate::derivation::{self, DerivationError, PATHS};
use crate::encryption::{self, EncryptedData, EncryptionKey};
use crate::mnemonic::{Language, Mnemonic, Seed};
use crate::protocol::{DerivedKey, KeyType};
/// Handle to a running SecretService for local (in-process) use.
///
/// This is the primary API for local secret operations. It wraps the
/// service state in an `Arc<RwLock<>>` for thread-safe access.
#[derive(Clone)]
pub struct SecretServiceHandle {
inner: Arc<RwLock<SecretServiceInner>>,
}
/// Internal state of the secret service.
struct SecretServiceInner {
/// The mnemonic phrase, if unlocked. None if locked.
mnemonic: Option<Mnemonic>,
/// The master seed, if unlocked. None if locked.
seed: Option<Seed>,
/// Whether the service is unlocked.
unlocked: bool,
}
/// Errors that can occur during secret service operations.
#[derive(Debug, thiserror::Error)]
pub enum SecretServiceError {
#[error("service is locked; call Unlock first")]
ServiceLocked,
#[error("service is already unlocked")]
AlreadyUnlocked,
#[error("mnemonic error: {0}")]
Mnemonic(String),
#[error("derivation error: {0}")]
Derivation(String),
#[error("encryption error: {0}")]
Encryption(String),
#[error("invalid path: {0}")]
InvalidPath(String),
}
impl From<crate::mnemonic::MnemonicError> for SecretServiceError {
fn from(e: crate::mnemonic::MnemonicError) -> Self {
SecretServiceError::Mnemonic(e.to_string())
}
}
impl From<DerivationError> for SecretServiceError {
fn from(e: DerivationError) -> Self {
SecretServiceError::Derivation(e.to_string())
}
}
impl From<encryption::EncryptionError> for SecretServiceError {
fn from(e: encryption::EncryptionError) -> Self {
SecretServiceError::Encryption(e.to_string())
}
}
impl SecretServiceHandle {
/// Create a new SecretServiceHandle in the locked state.
pub fn new() -> Self {
Self {
inner: Arc::new(RwLock::new(SecretServiceInner {
mnemonic: None,
seed: None,
unlocked: false,
})),
}
}
/// Unlock the service with an existing mnemonic phrase.
///
/// 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<(), SecretServiceError> {
let mut inner = self.inner.write().unwrap();
if inner.unlocked {
return Err(SecretServiceError::AlreadyUnlocked);
}
let mnemonic = Mnemonic::from_phrase(phrase, Language::English)?;
let seed = mnemonic.to_seed(passphrase);
inner.mnemonic = Some(mnemonic);
inner.seed = Some(seed);
inner.unlocked = true;
Ok(())
}
/// Unlock the service with a new randomly generated mnemonic.
///
/// 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, SecretServiceError> {
let mut inner = self.inner.write().unwrap();
if inner.unlocked {
return Err(SecretServiceError::AlreadyUnlocked);
}
let mnemonic = Mnemonic::generate(word_count)?;
let seed = mnemonic.to_seed(None);
let phrase = mnemonic.phrase().to_string();
inner.mnemonic = Some(mnemonic);
inner.seed = Some(seed);
inner.unlocked = true;
Ok(phrase)
}
/// Lock the service, purging the seed and all cached derived keys.
///
/// After locking, no derive/encrypt/decrypt operations are possible
/// 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();
inner.seed = None; // Seed's Zeroize drop handles the zeroization
inner.mnemonic = None; // Mnemonic's Zeroize drop handles the zeroization
inner.unlocked = false;
}
/// Check whether the service is currently unlocked.
pub fn is_unlocked(&self) -> bool {
self.inner.read().unwrap().unlocked
}
/// Derive an Ed25519 keypair at the given path.
pub fn derive_ed25519(&self, path: &str) -> Result<DerivedKey, 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)?;
Ok(DerivedKey {
key_type: KeyType::Ed25519,
private_key: key.private_key().to_vec(),
public_key: key.public_key().to_vec(),
})
}
/// Derive an AES-256-GCM encryption key at the given path.
pub fn derive_encryption_key(&self, path: &str) -> Result<DerivedKey, 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)?;
Ok(DerivedKey {
key_type: KeyType::Aes256Gcm,
private_key: key.private_key().to_vec(),
public_key: key.public_key().to_vec(),
})
}
/// Derive a secp256k1 (Ethereum) keypair at the given path.
pub fn derive_ethereum_key(&self, path: &str) -> Result<DerivedKey, 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)?;
Ok(DerivedKey {
key_type: KeyType::Secp256k1,
private_key: key.private_key().to_vec(),
public_key: key.public_key().to_vec(),
})
}
/// Encrypt plaintext using the derived encryption key.
///
/// Uses the key at path `m/74'/2'/0'/0'` (PATHS::ENCRYPTION) by default.
pub fn encrypt(&self, plaintext: &str, key_version: u32) -> Result<EncryptedData, 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 derived = derivation::derive_path_from_seed(seed.as_bytes(), PATHS::ENCRYPTION)?;
let enc_key = EncryptionKey::from_derived_bytes(derived.private_key(), key_version);
encryption::encrypt(plaintext, &enc_key).map_err(|e| e.into())
}
/// Decrypt an EncryptedData blob using the derived encryption key.
pub fn decrypt(&self, encrypted: &EncryptedData) -> Result<String, 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 derived = derivation::derive_path_from_seed(seed.as_bytes(), PATHS::ENCRYPTION)?;
let enc_key = EncryptionKey::from_derived_bytes(derived.private_key(), encrypted.key_version);
encryption::decrypt(encrypted, &enc_key).map_err(|e| e.into())
}
}
impl Default for SecretServiceHandle {
fn default() -> Self {
Self::new()
}
}
/// The SecretService manages the lifecycle of the master seed and provides
/// secret operations. This is the type used by the irpc service handler.
///
/// For local (in-process) use, prefer `SecretServiceHandle` which wraps
/// this in thread-safe locks.
pub struct SecretService {
handle: SecretServiceHandle,
}
impl SecretService {
/// Create a new SecretService in the locked state.
pub fn new() -> Self {
Self {
handle: SecretServiceHandle::new(),
}
}
/// Get a handle for local (in-process) use.
pub fn handle(&self) -> &SecretServiceHandle {
&self.handle
}
}
impl Default for SecretService {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_service_starts_locked() {
let service = SecretServiceHandle::new();
assert!(!service.is_unlocked());
}
#[test]
fn test_unlock_new_generates_mnemonic() {
let service = SecretServiceHandle::new();
let phrase = service.unlock_new(24).unwrap();
assert!(!phrase.is_empty());
assert!(service.is_unlocked());
}
#[test]
fn test_lock_purges_state() {
let service = SecretServiceHandle::new();
service.unlock_new(24).unwrap();
assert!(service.is_unlocked());
service.lock();
assert!(!service.is_unlocked());
}
#[test]
fn test_derive_on_locked_fails() {
let service = SecretServiceHandle::new();
let result = service.derive_ed25519(PATHS::IDENTITY);
assert!(result.is_err());
}
#[test]
fn test_encrypt_on_locked_fails() {
let service = SecretServiceHandle::new();
let result = service.encrypt("secret", 1);
assert!(result.is_err());
}
#[test]
fn test_full_lifecycle() {
let service = SecretServiceHandle::new();
// Starts locked
assert!(!service.is_unlocked());
// Can't derive while locked
assert!(service.derive_ed25519(PATHS::IDENTITY).is_err());
// Unlock
let phrase = service.unlock_new(24).unwrap();
assert!(service.is_unlocked());
// Can derive while unlocked
let key = service.derive_ed25519(PATHS::IDENTITY).unwrap();
assert!(!key.private_key.is_empty());
// Lock
service.lock();
assert!(!service.is_unlocked());
// Can't derive again
assert!(service.derive_ed25519(PATHS::IDENTITY).is_err());
}
#[test]
fn test_unlock_with_known_phrase() {
let service = SecretServiceHandle::new();
// Generate a phrase
let phrase = service.unlock_new(24).unwrap();
service.lock();
// Re-unlock with the same phrase
service.unlock(&phrase, None).unwrap();
assert!(service.is_unlocked());
}
#[test]
fn test_double_unlock_fails() {
let service = SecretServiceHandle::new();
service.unlock_new(24).unwrap();
let result = service.unlock_new(12);
assert!(result.is_err());
}
#[test]
fn test_encrypt_decrypt_lifecycle() {
let service = SecretServiceHandle::new();
service.unlock_new(24).unwrap();
let plaintext = "my-api-key-12345";
let encrypted = service.encrypt(plaintext, 1).unwrap();
let decrypted = service.decrypt(&encrypted).unwrap();
assert_eq!(decrypted, plaintext);
// After lock, can't decrypt
service.lock();
assert!(service.decrypt(&encrypted).is_err());
}
}

View File

@@ -0,0 +1,62 @@
//! Integration tests for key derivation.
//!
//! These tests verify that SLIP-0010 derivation produces correct results
//! against known test vectors and that path constants produce expected key types.
use alknet_secret::derivation::PATHS;
use alknet_secret::service::SecretServiceHandle;
#[test]
fn test_identity_key_derivation() {
let service = SecretServiceHandle::new();
let _phrase = service.unlock_new(24).unwrap();
let key = service.derive_ed25519(PATHS::IDENTITY).unwrap();
assert_eq!(key.key_type, alknet_secret::protocol::KeyType::Ed25519);
assert!(!key.private_key.is_empty());
assert!(!key.public_key.is_empty());
}
#[test]
fn test_encryption_key_derivation() {
let service = SecretServiceHandle::new();
service.unlock_new(24).unwrap();
let key = service
.derive_encryption_key(PATHS::ENCRYPTION)
.unwrap();
assert_eq!(
key.key_type,
alknet_secret::protocol::KeyType::Aes256Gcm
);
}
#[test]
fn test_deterministic_derivation() {
// Same seed + same path = same key
let service = SecretServiceHandle::new();
let phrase = service.unlock_new(24).unwrap();
let key1 = service.derive_ed25519(PATHS::IDENTITY).unwrap();
// Unlock with the same phrase again
service.lock();
service.unlock(&phrase, None).unwrap();
let key2 = service.derive_ed25519(PATHS::IDENTITY).unwrap();
assert_eq!(key1.private_key, key2.private_key);
assert_eq!(key1.public_key, key2.public_key);
}
#[test]
fn test_different_paths_different_keys() {
let service = SecretServiceHandle::new();
service.unlock_new(24).unwrap();
let identity_key = service.derive_ed25519(PATHS::IDENTITY).unwrap();
let ssh_key = service.derive_ed25519(PATHS::SSH_HOST).unwrap();
assert_ne!(identity_key.private_key, ssh_key.private_key);
assert_ne!(identity_key.public_key, ssh_key.public_key);
}

View File

@@ -0,0 +1,58 @@
//! Integration tests for AES-256-GCM encryption and decryption.
//!
//! These tests verify round-trip encryption, key version handling,
//! and wire format compatibility.
use alknet_secret::encryption::CURRENT_KEY_VERSION;
use alknet_secret::service::SecretServiceHandle;
#[test]
fn test_encrypt_decrypt_round_trip_via_service() {
let service = SecretServiceHandle::new();
service.unlock_new(24).unwrap();
let plaintext = "sk-proj-abc123xyz789";
let encrypted = service.encrypt(plaintext, CURRENT_KEY_VERSION).unwrap();
let decrypted = service.decrypt(&encrypted).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_encrypt_produces_different_ciphertext_each_time() {
let service = SecretServiceHandle::new();
service.unlock_new(24).unwrap();
let plaintext = "same input different ciphertexts";
let encrypted1 = service.encrypt(plaintext, CURRENT_KEY_VERSION).unwrap();
let encrypted2 = service.encrypt(plaintext, CURRENT_KEY_VERSION).unwrap();
// Different IVs mean different ciphertexts
assert_ne!(encrypted1.iv, encrypted2.iv);
assert_ne!(encrypted1.data, encrypted2.data);
// But same key version
assert_eq!(encrypted1.key_version, encrypted2.key_version);
}
#[test]
fn test_encrypted_data_serialization() {
let service = SecretServiceHandle::new();
service.unlock_new(24).unwrap();
let plaintext = "test serialization";
let encrypted = service.encrypt(plaintext, CURRENT_KEY_VERSION).unwrap();
// Verify EncryptedData serializes to JSON
let json = serde_json::to_string(&encrypted).unwrap();
assert!(json.contains("key_version"));
assert!(json.contains("salt"));
assert!(json.contains("iv"));
assert!(json.contains("data"));
// Verify round-trip through JSON
let deserialized: alknet_secret::encryption::EncryptedData =
serde_json::from_str(&json).unwrap();
assert_eq!(deserialized, encrypted);
}

View File

@@ -0,0 +1,100 @@
//! Integration tests for the SecretService lifecycle.
//!
//! These tests verify the unlock/lock lifecycle, error conditions,
//! and that the service correctly manages state transitions.
use alknet_secret::service::{SecretServiceError, SecretServiceHandle};
use alknet_secret::derivation::PATHS;
#[test]
fn test_full_lifecycle() {
let service = SecretServiceHandle::new();
// Starts locked
assert!(!service.is_unlocked());
// Can't derive while locked
let result = service.derive_ed25519(PATHS::IDENTITY);
assert!(matches!(result, Err(SecretServiceError::ServiceLocked)));
// Unlock
let phrase = service.unlock_new(24).unwrap();
assert!(service.is_unlocked());
assert!(!phrase.is_empty());
// Can derive while unlocked
let key = service.derive_ed25519(PATHS::IDENTITY).unwrap();
assert!(!key.private_key.is_empty());
// Lock
service.lock();
assert!(!service.is_unlocked());
// Can't derive again
let result = service.derive_ed25519(PATHS::IDENTITY);
assert!(matches!(result, Err(SecretServiceError::ServiceLocked)));
}
#[test]
fn test_unlock_with_known_phrase() {
let service = SecretServiceHandle::new();
// Generate a phrase
let phrase = service.unlock_new(24).unwrap();
service.lock();
// Re-unlock with the same phrase
service.unlock(&phrase, None).unwrap();
assert!(service.is_unlocked());
// Different passphrase produces different seed
// (tested by deriving keys with different passphrases)
}
#[test]
fn test_double_unlock_fails() {
let service = SecretServiceHandle::new();
service.unlock_new(24).unwrap();
let result = service.unlock_new(12);
assert!(matches!(result, Err(SecretServiceError::AlreadyUnlocked)));
}
#[test]
fn test_lock_when_already_locked_is_noop() {
let service = SecretServiceHandle::new();
assert!(!service.is_unlocked());
// Lock on already-locked service is a no-op
service.lock();
assert!(!service.is_unlocked());
}
#[test]
fn test_encrypt_decrypt_lifecycle() {
let service = SecretServiceHandle::new();
service.unlock_new(24).unwrap();
let plaintext = "my-api-key-12345";
let encrypted = service.encrypt(plaintext, 1).unwrap();
let decrypted = service.decrypt(&encrypted).unwrap();
assert_eq!(decrypted, plaintext);
// After lock, can't decrypt
service.lock();
let result = service.decrypt(&encrypted);
assert!(matches!(result, Err(SecretServiceError::ServiceLocked)));
}
#[test]
fn test_multiple_derive_paths_succeed() {
let service = SecretServiceHandle::new();
service.unlock_new(24).unwrap();
// All standard paths should work
let _identity = service.derive_ed25519(PATHS::IDENTITY).unwrap();
let _ssh = service.derive_ed25519(PATHS::SSH_HOST).unwrap();
let _enc = service
.derive_encryption_key(PATHS::ENCRYPTION)
.unwrap();
}