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:
84
Cargo.lock
generated
84
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -3,6 +3,7 @@ members = [
|
||||
"crates/alknet-core",
|
||||
"crates/alknet",
|
||||
"crates/alknet-napi",
|
||||
"crates/alknet-secret",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
||||
26
crates/alknet-secret/Cargo.toml
Normal file
26
crates/alknet-secret/Cargo.toml
Normal 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"
|
||||
296
crates/alknet-secret/src/derivation.rs
Normal file
296
crates/alknet-secret/src/derivation.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
228
crates/alknet-secret/src/encryption.rs
Normal file
228
crates/alknet-secret/src/encryption.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
41
crates/alknet-secret/src/lib.rs
Normal file
41
crates/alknet-secret/src/lib.rs
Normal 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};
|
||||
161
crates/alknet-secret/src/mnemonic.rs
Normal file
161
crates/alknet-secret/src/mnemonic.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
143
crates/alknet-secret/src/protocol.rs
Normal file
143
crates/alknet-secret/src/protocol.rs
Normal 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;
|
||||
382
crates/alknet-secret/src/service.rs
Normal file
382
crates/alknet-secret/src/service.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
62
crates/alknet-secret/tests/derivation_tests.rs
Normal file
62
crates/alknet-secret/tests/derivation_tests.rs
Normal 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);
|
||||
}
|
||||
58
crates/alknet-secret/tests/encryption_tests.rs
Normal file
58
crates/alknet-secret/tests/encryption_tests.rs
Normal 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);
|
||||
}
|
||||
100
crates/alknet-secret/tests/service_tests.rs
Normal file
100
crates/alknet-secret/tests/service_tests.rs
Normal 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();
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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<u8>,
|
||||
}
|
||||
|
||||
#[derive(Zeroize)]
|
||||
#[zeroize(drop)]
|
||||
struct DerivedKeyCache {
|
||||
keys: HashMap<String, Vec<u8>>,
|
||||
}
|
||||
```
|
||||
|
||||
`#[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
|
||||
@@ -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)
|
||||
- **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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user