diff --git a/Cargo.lock b/Cargo.lock index 8275536..1550c74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -126,6 +126,24 @@ dependencies = [ "url", ] +[[package]] +name = "alknet-secret" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "base64", + "bip39", + "ed25519-bip32", + "hex", + "hmac", + "rand 0.8.6", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.18", + "zeroize", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -206,6 +224,12 @@ dependencies = [ "rustversion", ] +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "asn1-rs" version = "0.6.2" @@ -499,6 +523,19 @@ dependencies = [ "sha2", ] +[[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes", + "rand 0.8.6", + "rand_core 0.6.4", + "serde", + "unicode-normalization", +] + [[package]] name = "bit-vec" version = "0.9.1" @@ -508,6 +545,15 @@ dependencies = [ "serde", ] +[[package]] +name = "bitcoin_hashes" +version = "0.14.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9901a56e133a1fc86eeb1113e2591f45f4682451ca893bff494d2f88918e3f" +dependencies = [ + "hex-conservative", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -909,6 +955,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "cryptoxide" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "facfae029ec4373769eb4bd936bcf537de1052abaee9f246e667c9443be6aa95" + [[package]] name = "ctor" version = "1.0.7" @@ -1160,6 +1212,15 @@ dependencies = [ "signature", ] +[[package]] +name = "ed25519-bip32" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9bf77cf581d1a8c5f73c45e6d31aa45cfcb94438310f2f628b07e4727949115" +dependencies = [ + "cryptoxide", +] + [[package]] name = "ed25519-dalek" version = "2.2.0" @@ -1678,6 +1739,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + [[package]] name = "hex-literal" version = "0.4.1" @@ -5896,6 +5966,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 52f4019..daa93d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "crates/alknet-core", "crates/alknet", "crates/alknet-napi", + "crates/alknet-secret", ] resolver = "2" diff --git a/crates/alknet-secret/Cargo.toml b/crates/alknet-secret/Cargo.toml new file mode 100644 index 0000000..01f33dc --- /dev/null +++ b/crates/alknet-secret/Cargo.toml @@ -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" \ No newline at end of file diff --git a/crates/alknet-secret/src/derivation.rs b/crates/alknet-secret/src/derivation.rs new file mode 100644 index 0000000..8a0d00c --- /dev/null +++ b/crates/alknet-secret/src/derivation.rs @@ -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; + +/// 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, + /// The public key bytes (32 bytes). + public_key: Vec, + /// The chain code for child derivation (32 bytes). + chain_code: Vec, + /// 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 { + 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 { + 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, 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()); + } +} \ No newline at end of file diff --git a/crates/alknet-secret/src/encryption.rs b/crates/alknet-secret/src/encryption.rs new file mode 100644 index 0000000..46ad71e --- /dev/null +++ b/crates/alknet-secret/src/encryption.rs @@ -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 { + 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 { + 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()); + } +} \ No newline at end of file diff --git a/crates/alknet-secret/src/lib.rs b/crates/alknet-secret/src/lib.rs new file mode 100644 index 0000000..9df6584 --- /dev/null +++ b/crates/alknet-secret/src/lib.rs @@ -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}; \ No newline at end of file diff --git a/crates/alknet-secret/src/mnemonic.rs b/crates/alknet-secret/src/mnemonic.rs new file mode 100644 index 0000000..d182919 --- /dev/null +++ b/crates/alknet-secret/src/mnemonic.rs @@ -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 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 { + 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 { + 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, +} + +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()); + } +} \ No newline at end of file diff --git a/crates/alknet-secret/src/protocol.rs b/crates/alknet-secret/src/protocol.rs new file mode 100644 index 0000000..15597ac --- /dev/null +++ b/crates/alknet-secret/src/protocol.rs @@ -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, + /// The public key bytes. + pub public_key: Vec, +} + +/// 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; \ No newline at end of file diff --git a/crates/alknet-secret/src/service.rs b/crates/alknet-secret/src/service.rs new file mode 100644 index 0000000..03a4b73 --- /dev/null +++ b/crates/alknet-secret/src/service.rs @@ -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>` for thread-safe access. +#[derive(Clone)] +pub struct SecretServiceHandle { + inner: Arc>, +} + +/// Internal state of the secret service. +struct SecretServiceInner { + /// The mnemonic phrase, if unlocked. None if locked. + mnemonic: Option, + /// The master seed, if unlocked. None if locked. + seed: Option, + /// 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 for SecretServiceError { + fn from(e: crate::mnemonic::MnemonicError) -> Self { + SecretServiceError::Mnemonic(e.to_string()) + } +} + +impl From for SecretServiceError { + fn from(e: DerivationError) -> Self { + SecretServiceError::Derivation(e.to_string()) + } +} + +impl From 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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()); + } +} \ No newline at end of file diff --git a/crates/alknet-secret/tests/derivation_tests.rs b/crates/alknet-secret/tests/derivation_tests.rs new file mode 100644 index 0000000..2b5888e --- /dev/null +++ b/crates/alknet-secret/tests/derivation_tests.rs @@ -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); +} \ No newline at end of file diff --git a/crates/alknet-secret/tests/encryption_tests.rs b/crates/alknet-secret/tests/encryption_tests.rs new file mode 100644 index 0000000..3d93c55 --- /dev/null +++ b/crates/alknet-secret/tests/encryption_tests.rs @@ -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); +} \ No newline at end of file diff --git a/crates/alknet-secret/tests/service_tests.rs b/crates/alknet-secret/tests/service_tests.rs new file mode 100644 index 0000000..db5399f --- /dev/null +++ b/crates/alknet-secret/tests/service_tests.rs @@ -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(); +} \ No newline at end of file diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 5285c8d..18694c7 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -36,7 +36,7 @@ OQ-20 (worker registration), OQ-CP-01 (per-identity credentials), OQ-CP-02 | [configuration.md](configuration.md) | draft | StaticConfig, DynamicConfig, API keys, forwarding policy, reload | | [storage.md](storage.md) | draft | alknet-storage: metagraph, identity, ACL, honker | | [flowgraph.md](flowgraph.md) | draft | alknet-flowgraph: call graph, operation graph, petgraph | -| [secret-service.md](secret-service.md) | draft | alknet-secret: BIP39, SLIP-0010, AES-GCM, SecretProtocol | +| [secret-service.md](secret-service.md) | reviewed | alknet-secret: BIP39, SLIP-0010, AES-GCM, SecretProtocol | | [credentials.md](credentials.md) | draft | CredentialProvider, CredentialSet (outbound auth) | | [definitions.md](definitions.md) | draft | Terminology disambiguation and concept mapping | @@ -97,6 +97,8 @@ OQ-20 (worker registration), OQ-CP-01 (per-identity credentials), OQ-CP-02 | [036](decisions/036-credentialprovider-core-type.md) | CredentialProvider as core type (outbound auth) | Accepted | | [037](decisions/037-api-keys-dynamic-config.md) | API keys as DynamicConfig auth | Accepted | +| [038](decisions/038-seed-lifecycle-memory-security.md) | Seed lifecycle and memory security (zeroize for v1) | Accepted | + > ADR numbers 020–022 were allocated to proposals that were withdrawn before > acceptance and are not listed. diff --git a/docs/architecture/decisions/038-seed-lifecycle-memory-security.md b/docs/architecture/decisions/038-seed-lifecycle-memory-security.md new file mode 100644 index 0000000..a3b92bd --- /dev/null +++ b/docs/architecture/decisions/038-seed-lifecycle-memory-security.md @@ -0,0 +1,137 @@ +# ADR-038: Seed Lifecycle and Memory Security + +## Status + +Accepted + +## Context + +The alknet-secret crate holds the master BIP39 seed phrase in RAM. This seed is +the root of trust for all derived keys (identity, encryption, signing). If the +seed is leaked — through memory dumps, swap files, or core dumps — an attacker +can derive every key in the system. + +Security-conscious key management systems typically employ three defenses: + +1. **Zeroize**: Overwrite sensitive memory before deallocating. Prevents + stale-data reads from freed memory. + +2. **Memory locking** (`mlock`/`VirtualLock`): Prevent the OS from paging + sensitive RAM to disk. Prevents swap-file leakage. + +3. **Constant-time comparison**: Prevent timing side-channels when comparing + keys or tokens. + +The question is: which of these should alknet-secret adopt in v1, and which +should be deferred? + +## Decision + +**Phase 3 (v1): Zeroize only. Defer mlock and constant-time comparison to +Phase B.** + +- All sensitive types (seed bytes, derived private keys, passphrase strings) + derive `Zeroize` and implement `Drop` to call `zeroize()` before deallocation. +- The `Lock` operation calls `zeroize()` on the seed and all cached derived + keys, then drops them. +- `mlock`/`VirtualLock` and constant-time comparison are not included in v1. + +### Rationale for deferring mlock + +1. **Complexity**: `mlock` requires root/CAP_IPC_LOCK on Linux or + `SeLockMemory` on Windows. The crate should work in unprivileged contexts + (development, testing, single-user nodes) without requiring system + configuration changes. + +2. **Performance**: `mlock` locks physical pages, which are typically 4KB. + Locking many small buffers wastes physical memory. The seed (64 bytes) and + derived keys (32–64 bytes each) are tiny — the real risk is swap-file + leakage, which `zeroize` partially mitigates by wiping before free. + +3. **Deployment flexibility**: Production head nodes running as root or with + `CAP_IPC_LOCK` can add `mlock` in Phase B. Development and CLI nodes + shouldn't need it. + +4. **Audit surface**: `mlock` introduces platform-specific code paths (Linux + vs macOS vs Windows) that should be audited together, not bolted on + incrementally. + +### Rationale for deferring constant-time comparison + +The `SecretProtocol` service receives requests over irpc (local mpsc or remote +QUIC). Comparison timing is not observable by callers — they send a message and +wait for a response. The comparison that matters (auth token verification) is +in alknet-core's `IdentityProvider`, not in alknet-secret. Key derivation +results (DerivedKey) are not compared against attacker-controlled input within +this crate. + +### Zeroize implementation + +```rust +use zeroize::Zeroize; + +#[derive(Zeroize)] +#[zeroize(drop)] +struct SeedHolder { + seed: Vec, +} + +#[derive(Zeroize)] +#[zeroize(drop)] +struct DerivedKeyCache { + keys: HashMap>, +} +``` + +`#[zeroize(drop)]` ensures that `Drop` calls `zeroize()` on all fields, +overwriting memory before deallocation. This is a compile-time guarantee — +forgetting to zeroize a field is a compile error. + +### Lock lifecycle + +``` +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 (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 +``` + +## Consequences + +- **Positive**: Zeroize is zero-cost at compile time, minimal dependency + (`zeroize` crate is ~500 lines, no `unsafe` on stable), and provides + meaningful protection against stale-memory reads. +- **Positive**: Lock effectively purges all sensitive material. After Lock, + the process memory contains no useful secret data. +- **Positive**: No platform-specific code paths in v1. The crate compiles and + runs everywhere without privilege requirements. +- **Negative**: Without `mlock`, the OS can page the seed to swap before + zeroization occurs. This is a window of vulnerability that Phase B closes. + The risk is acceptable for v1 because swap-file extraction requires root + access or physical access to the machine — the same threat model as reading + process memory directly. +- **Negative**: Without constant-time comparison, timing side-channels exist + in theory. In practice, no comparison in alknet-secret operates on + attacker-controlled input, so the risk is nil within this crate. +- **Negative**: `zeroize` adds a dependency. The `zeroize` crate is widely + used in Rust crypto (ring, ed25519-dalek, x25519-dalek) and is a de facto + standard. + +## References + +- [secret-service.md](../secret-service.md) — Security model, Lock/Unlock lifecycle +- [ADR-027](027-crate-decomposition.md) — Crate decomposition (alknet-secret is independent) +- [credentials.md](../credentials.md) — SecretStoreCredentialProvider integration +- `zeroize` crate — https://crates.io/crates/zeroize \ No newline at end of file diff --git a/docs/architecture/open-questions.md b/docs/architecture/open-questions.md index 7fb5cc8..6165425 100644 --- a/docs/architecture/open-questions.md +++ b/docs/architecture/open-questions.md @@ -328,4 +328,13 @@ last_updated: 2026-06-07 - **Status**: resolved - **Priority**: medium - **Resolution**: Yes. Adopted in [definitions.md](definitions.md). Use "credential presentation" for the mechanism of presenting credentials on a (Transport, Interface) pair. Never use "auth interface" (overloads "Interface"). -- **Cross-references**: [definitions.md](definitions.md), [auth.md](auth.md) \ No newline at end of file +- **Cross-references**: [definitions.md](definitions.md), [auth.md](auth.md) + +## Secret Service + +### OQ-SEC-01: Should alknet-secret use mlock/VirtualLock to prevent seed RAM from being paged to disk? +- **Origin**: [secret-service.md](secret-service.md) +- **Status**: open +- **Priority**: low +- **Resolution**: (deferred to Phase B — zeroize is sufficient for v1; mlock requires root/CAP_IPC_LOCK on Linux and SeLockMemory on Windows, adding platform complexity that should be audited together) +- **Cross-references**: [ADR-038](decisions/038-seed-lifecycle-memory-security.md), [secret-service.md](secret-service.md) \ No newline at end of file diff --git a/docs/architecture/secret-service.md b/docs/architecture/secret-service.md index 307c129..4fd80c8 100644 --- a/docs/architecture/secret-service.md +++ b/docs/architecture/secret-service.md @@ -1,9 +1,9 @@ --- -status: draft -last_updated: 2026-06-07 +status: reviewed +last_updated: 2026-06-09 --- -# Secret Service +# Secret Service (alknet-secret) ## What @@ -22,21 +22,124 @@ OAuth tokens) cannot be derived and must be stored encrypted, with the encryption key itself derived from the seed. The secret service isolates this responsibility: no other crate sees the seed, -and derived keys are provided on demand through an irpc service interface. +and derived keys are provided on demand through an irpc service interface. This +follows ADR-027 (crate decomposition) — alknet-secret is fully independent of +alknet-core and alknet-storage. ## Architecture +### Crate Structure + +``` +alknet-secret/ +├── Cargo.toml +├── src/ +│ ├── lib.rs # Crate root, re-exports +│ ├── mnemonic.rs # BIP39: phrase generation, validation, seed derivation +│ ├── derivation.rs # SLIP-0010: HD key derivation, path constants +│ ├── encryption.rs # AES-256-GCM: encrypt/decrypt, EncryptedData type +│ ├── protocol.rs # SecretProtocol irpc service enum, DerivedKey, KeyType +│ └── service.rs # SecretServiceImpl: in-memory seed, Unlock/Lock lifecycle +└── tests/ + ├── derivation_tests.rs # Path derivation, coin type 74' consistency + ├── encryption_tests.rs # Round-trip encrypt/decrypt, key version + └── service_tests.rs # Unlock/Lock lifecycle, derive on locked = error +``` + +### Dependencies + +```toml +[dependencies] +bip39 = "2" +ed25519-bip32 = "0.x" # IOHK SLIP-0010 Ed25519 HD derivation +aes-gcm = "0.10" # AES-256-GCM +sha2 = "0.10" # SHA-256 +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +irpc = "0.x" # Always-on, not feature-gated (ADR-027) +zeroize = { version = "1", features = ["derive"] } # Secure memory wiping (ADR-038) +``` + +irpc is always a dependency (not behind a feature flag). Per ADR-027, irpc +in alknet-secret and alknet-storage is not feature-gated because these crates +are used in production deployments where the service layer is always active. + +### Crate Interface (Public API) + +The crate exposes these types as its stable public interface: + +```rust +// Core types (always available) +pub use mnemonic::{Mnemonic, Language, Seed}; +pub use derivation::{ExtendedPrivKey, DerivationPath, PATHS}; +pub use encryption::{EncryptedData, EncryptionError}; +pub use protocol::{SecretProtocol, DerivedKey, KeyType, SecretMessage}; +pub use service::{SecretService, SecretServiceHandle, SecretServiceError}; +``` + +Other crates consume this interface: +- **alknet-storage** references `EncryptedData` for wire format compatibility + (type-level, not a crate dependency) +- **alknet** (CLI binary) assembles `SecretService` and wires it to the + `OperationEnv` +- **alknet-core** never depends on alknet-secret; `CredentialProvider` stub + returns `None` until Phase A wiring + ### Security Model +Per ADR-038 (seed lifecycle and memory security): + | State | What's in memory | What's on disk | |-------|-----------------|---------------| | Locked | Nothing | Encrypted database, derivation path metadata | -| Unlocked | Master seed in RAM | Same (seed is never persisted) | -| After use | Derived keys cached in RAM | Derivation paths only | +| Unlocked | Master seed in zeroize-protected RAM | Same (seed is never persisted) | +| After use | Derived keys cached in zeroize-protected RAM | Derivation paths only | -The seed phrase is entered once (at node startup or via `Unlock` call), held -only in RAM, and never written to disk. The `Lock` call purges the seed and all -cached derived keys from memory. +The seed phrase is entered once (at node startup or via `Unlock`), held only in +RAM, and never written to disk. `Lock` calls `zeroize()` on the seed and all +cached derived keys. The `SecretService` uses `Zeroize`-derived types for all +sensitive material. + +### Key Derivation + +#### BIP39 Mnemonic and Seed Derivation + +```rust +let mnemonic = Mnemonic::from_phrase(&phrase, Language::English)?; +let seed = mnemonic.to_seed(Some(&passphrase)); +let master_key = ExtendedPrivKey::new_master(Network::Alknet, &seed)?; +``` + +#### SLIP-0010 Ed25519 HD Key Derivation + +The `74'` coin type is unallocated per SLIP-0044 and reserved for alknet. + +#### Derivation Path Constants + +| 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 | + +These constants are defined in `derivation::PATHS` for programmatic access. + +### AES-256-GCM Encryption for External Credentials + +External credentials (API keys, OAuth tokens) that cannot be derived 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. + +1. The secret service derives an AES-256-GCM key via path `m/74'/2'/0'/0'` +2. External credentials are encrypted with this key +3. The encrypted data is stored as a `SecretNode` in the metagraph +4. Only the derivation path and key version are stored in plain attributes +5. The seed phrase (or derived encryption key) is held only by the secret + service — never in the database ### SecretProtocol irpc Service @@ -100,42 +203,17 @@ struct EncryptedData { } ``` -### BIP39 Mnemonic and Seed Derivation +### Wire Format Compatibility with alknet-storage -```rust -let mnemonic = Mnemonic::from_phrase(&phrase, Language::English)?; -let seed = mnemonic.to_seed(Some(&passphrase)); -let master_key = ExtendedPrivKey::new_master(Network::Alknet, &seed)?; -``` +The `EncryptedData` type (`key_version`, `salt`, `iv`, `data`) is the stable +wire format shared with alknet-storage. This is type-level compatibility — not a +crate dependency. alknet-storage stores encrypted nodes using this format; +alknet-secret encrypts and decrypts using this format. -### SLIP-0010 Ed25519 HD Key Derivation - -The `74'` coin type is unallocated per SLIP-0044 and reserved for alknet. - -### Derivation Path Constants - -| 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 | - -### AES-256-GCM Encryption for External Credentials - -External credentials (API keys, OAuth tokens) that cannot be derived 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. This -format is compatible with the existing `@alkdev/storage` `EncryptedDataSchema`. - -1. The secret service derives an AES-256-GCM key via path `m/74'/2'/0'/0'` -2. External credentials are encrypted with this key -3. The encrypted data is stored as a `SecretNode` in the metagraph -4. Only the derivation path and key version are stored in plain attributes -5. The seed phrase (or derived encryption key) is held only by the secret - service — never in the database +The Rust `EncryptedData` struct in alknet-secret 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. ### Deployment Topologies @@ -149,33 +227,43 @@ never leaves the secret service node. ## Constraints - The seed phrase is never persisted to disk. It is entered at startup or via - `Unlock` and held only in RAM. -- `Lock` purges the seed and all cached derived keys from memory. + `Unlock` and held only in `Zeroize`-protected RAM (ADR-038). +- `Lock` calls `zeroize()` on the seed and all cached derived keys. - alknet-secret does not depend on alknet-core or alknet-storage. It is fully - independent. -- The `EncryptedData` wire format (key_version, salt, iv, data) is shared with - alknet-storage for compatibility, but this is type-level compatibility — not a - crate dependency. -- Per ADR-032, the secret service's Honker streams (key derivation notifications) - stay within the service boundary. External consumers use irpc calls or call - protocol operations that project to integration events. -- The irpc service defines the wire format for in-cluster communication + independent (ADR-027). +- The `EncryptedData` wire format is shared with alknet-storage for type-level + compatibility, not a crate dependency. +- Per ADR-032, secret service domain events (key derivation notifications) stay + within the service boundary. External consumers use irpc calls or call + protocol operations projected to integration events. +- irpc is always a dependency (not feature-gated) per ADR-027. +- `SecretProtocol` defines the wire format for in-cluster communication (postcard serialization). For call protocol exposure (e.g., - `/head/secrets/derive`), the service is wrapped in an operation that serializes - to JSON. + `/head/secrets/derive`), the service is wrapped in an operation that + serializes to JSON. + +## Phase Progression + +| Phase | Scope | Notes | +|-------|-------|-------| +| Phase 3 (now) | Basic crate: mnemonic, derivation, encryption, irpc protocol, service lifecycle | Core key management | +| Phase A | Integration with alknet-storage via `EncryptedData` wire format. CLI commands for unlock/lock/derive. `SecretStoreCredentialProvider` wiring. | Full service integration | +| Phase B | Memory hardening: `mlock`/`VirtualLock` for seed RAM, constant-time comparison, audit logging of derivation requests. | Security hardening | +| Phase C | Multi-seed support (tenant isolation): indexed `Unlock` with tenant ID. | Multi-tenancy | ## Open Questions - **OQ-SVC-01**: Should the secret service support multiple seed phrases (one per tenant)? See [open-questions.md](open-questions.md). -- **OQ-SVC-02**: Should service protocols use postcard (binary) or JSON for - remote calls? See [open-questions.md](open-questions.md). - - **OQ-SVC-03**: How does the secret service integrate with the existing `EncryptedDataSchema` from `@alkdev/storage`? See [open-questions.md](open-questions.md). -- **OQ-SVC-04**: Should workers cache derived keys locally? See [open-questions.md](open-questions.md). +- **OQ-SVC-04**: Should workers cache derived keys locally? See + [open-questions.md](open-questions.md). + +- **OQ-SEC-01**: Should alknet-secret use `mlock`/`VirtualLock` to prevent seed + RAM from being paged to disk? See [open-questions.md](open-questions.md). ## Design Decisions @@ -183,11 +271,13 @@ never leaves the secret service node. |-----|----------|---------| | [027](decisions/027-crate-decomposition.md) | Crate decomposition | alknet-secret is independent of core and storage | | [032](decisions/032-event-boundary-discipline.md) | Event boundary | Secret service domain events stay internal | +| [038](decisions/038-seed-lifecycle-memory-security.md) | Seed lifecycle and memory security | Zeroize for sensitive material, mlock deferred to Phase B | ## References - [research/services.md](../research/services.md) — SecretProtocol definition, DerivedKey, KeyType - [research/storage.md](../research/storage.md) — Secrets section, derivation paths, EncryptedData -- [research/integration-plan.md](../research/integration-plan.md) — Phase 2.1 +- [research/integration-plan.md](../research/integration-plan.md) — Phase 3.1 +- [credentials.md](credentials.md) — CredentialProvider (outbound auth, consumes SecretProtocol::Decrypt) - SLIP-0010 — https://github.com/satoshilabs/slips/blob/master/slip-0010.md - BIP39 — https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki \ No newline at end of file