Files
alknet/docs/architecture/crates/vault/mnemonic-derivation.md
glm-5.2 2e34590522 docs(architecture): resolve review #003 — type/API surface completeness
Review #003 found 11 critical, 14 warning, and 6 suggestion findings
after reviews #001 (governance/security) and #002 (cross-document
consistency/two-way-door audit) were resolved. The theme: types and
APIs that were *referenced* but never *defined*, and stale ADR sketches
that didn't match the now-updated spec docs.

Critical fixes (11):

- C1: DerivedKey #[derive(Deserialize)] contradicted the custom
  Deserialize that rejects "[REDACTED]" — dropped the derive, added
  explicit manual Serialize/Deserialize impls (protocol.md).
- C2: encrypt prose said "derived at PATHS::ENCRYPTION" but the
  signature takes key_version — updated to encryption_path_for_version
  (service.md).
- C3: derive_encryption_key returned DerivedKey, derive_encryption_key
  _for_version returned EncryptionKey (same cache) — unified on
  DerivedKey, defined CachedKey (service.md).
- C4: tokio vs std::sync::RwLock contradiction — specified
  std::sync::RwLock, dropped tokio from vault deps (ADR-018, ADR-025,
  service.md).
- C5: Missing drift rows in vault README — added #9 (key_version
  ignored) and #10 (rotate not implemented).
- C6: ADR-022 build_root_context and invoke() sketches omitted
  abort_policy (9 fields vs 10) — added the field to both sketches.
- C7: Capabilities type referenced 20+ times, never defined — added
  struct definition to core-types.md with Clone+Send+Sync, Zeroize,
  sealed builder API, immutability guard.
- C8: SessionOverlaySource on CallAdapter but never defined, crate
  violation (alknet-call can't depend on alknet-agent) — defined the
  trait in alknet-call (call-protocol.md), matching the IdentityProvider
  pattern.
- C9: CompositeOperationEnv dispatch fall-through was "a two-way door"
  — added contains() to OperationEnv trait, made the composite probe
  before dispatching, eliminating the sentinel ambiguity.
- C10: No API for Layer 2 (connection overlay) registration, CallConnection
  undefined — defined CallConnection struct + register_imported() API
  (call-protocol.md).
- C11: with_local signature diverged between two examples (4 args vs 5)
  — added capabilities as the 5th arg, made both examples consistent.

Warning fixes (14):

- W1: invoke_with_policy restructured as required method, invoke gets a
  default impl delegating to it — eliminates duplication across impls.
- W2: CachedKey defined (service.md).
- W3: EncryptionKey constructor/glue specified, added to re-export list.
- W4: Secp256k1ExtendedPrivKey defined, derive_ethereum_key glue shown.
- W5: encryption_path_for_version rejects version < 2 (v1 is TS PBKDF2).
- W6: Wire payload schemas for all event types + ResponseEnvelope →
  EventEnvelope conversion table (call-protocol.md).
- W7: Timeout section — deadline on OperationContext, composed calls
  inherit parent's deadline, CallAdapter::with_timeout().
- W8: Request ID generation spec — UUID v4 for composed calls, wire ID
  vs internal ID relationship for abort cascade.
- W9: unlock_new already-unlocked behavior specified (returns
  AlreadyUnlocked).
- W10: KeyType Serialize/Deserialize justification corrected (stale
  irpc reference removed).
- W11: OperationProvenance and CompositionAuthority defined inline in
  operation-registry.md (were only in ADR-022).
- W12: encrypt/decrypt free functions marked pub(crate), relationship
  to VaultServiceHandle methods stated.
- W13: rotate signature removed from encryption.md (it's a
  VaultServiceHandle method, not a free function).
- W14: CallAdapter::new() + with_session_source() + with_timeout()
  constructors shown.

Suggestion fixes (6): Seed: Clone note, VaultServiceInner invariant,
ExtendedPrivKey accessor signatures, CURRENT_KEY_VERSION location, ADR-018
stale actor text, derivation helpers re-export note.
2026-06-23 10:56:05 +00:00

12 KiB

status, last_updated
status last_updated
draft 2026-06-23

Mnemonic and Key Derivation

BIP39 mnemonic generation, SLIP-0010 Ed25519 HD key derivation, BIP-0032 secp256k1 derivation (feature-gated), and the derivation path constants that alknet uses.

What

The vault derives keys from a single root: a BIP39 mnemonic. From one mnemonic, all self-generated secrets are derived on demand via hierarchical deterministic (HD) derivation. This is the same model as cryptocurrency wallets — one seed phrase, many derived keys.

Two derivation schemes are supported:

Scheme Curve Standard Paths Feature
SLIP-0010 Ed25519 HMAC-SHA512 with "ed25519 seed" Hardened only default
BIP-0032 secp256k1 HMAC-SHA512 with "Bitcoin seed" Hardened + unhardened secp256k1

Ed25519 is the default — it's what alknet's TLS identity (ADR-010), SSH host keys, and signing keys use. secp256k1 is feature-gated for Ethereum signing (the standard Ethereum path m/44'/60'/0'/0/0 requires unhardened indices, which SLIP-0010 cannot handle).

Why HD Derivation

HD derivation lets one seed produce an unlimited number of keys at deterministic paths. This means:

  • No key storage: keys are derived on demand, not stored. The vault caches derived keys for performance, but the cache is rebuildable from the seed.
  • Reproducible across nodes: the same mnemonic on a different node produces the same keys. A backup node derives the same identity key.
  • Domain separation: different paths produce different keys. The identity key, SSH host key, encryption key, and signing keys are all cryptographically independent despite coming from one seed.
  • Auditable derivation: the path records what a key is for. m/74'/0'/0'/0' is the identity key; m/74'/0'/1'/0' is the SSH host key. The path is the documentation.

BIP39 Mnemonic

The root of trust is a BIP39 mnemonic seed phrase. The vault generates, validates, and derives seeds from mnemonics.

pub struct Mnemonic {
    phrase: String,  // zeroized on drop
}

impl Mnemonic {
    pub fn generate(word_count: usize) -> Result<Self, MnemonicError>;
    pub fn from_phrase(phrase: &str, language: Language) -> Result<Self, MnemonicError>;
    pub fn to_seed(&self, passphrase: Option<&str>) -> Seed;
    pub fn phrase(&self) -> &str;
}
  • generate(word_count): Generate a new random mnemonic. Supported word counts: 12, 15, 18, 21, 24. The mnemonic is the root of trust — store it securely.
  • from_phrase(phrase, language): Restore from an existing phrase. Validates against the BIP39 word list and checksum.
  • to_seed(passphrase): Derive the 64-byte master seed. The passphrase is the optional BIP39 password extension (the "25th word"). Different passphrases produce different seeds.
  • phrase(): Return the phrase string. Handle with care — this is the root of trust.

Mnemonic implements Zeroize and Drop — the phrase is zeroized before deallocation. Only English is supported (matching the BIP39 reference and the majority of wallet software).

Seed

#[derive(Clone, Zeroize)]
#[zeroize(drop)]
pub struct Seed {
    bytes: Vec<u8>,  // 64 bytes, zeroized on drop
}

The 64-byte seed from which all HD keys are derived. Zeroized on drop. This is the input to SLIP-0010 / BIP-0032 master key derivation.

Seed derives Clone for convenience (derivation functions take &[u8], and the cache rebuild may need to reference the seed multiple times). Callers should prefer &Seed and avoid cloning — the seed is the root of trust, and each clone duplicates it into heap memory that lingers until zeroized.

SLIP-0010 Ed25519 Derivation

The default derivation scheme. SLIP-0010 specifies Ed25519 HD key derivation using HMAC-SHA512 with the key "ed25519 seed".

pub fn derive_path_from_seed(seed: &[u8], path: &str) -> Result<ExtendedPrivKey, DerivationError>;

Master key derivation

The master key is derived from the seed via HMAC-SHA512:

HMAC-SHA512(key = "ed25519 seed", data = seed)
  → first 32 bytes: private key (kL)
  → next 32 bytes: chain code

The ed25519-bip32 crate handles the extended key format (kL || kR || chain code). The vault extracts the first 32 bytes as the private key and the public key (32 bytes) via XPrv::public().

Child derivation

SLIP-0010 Ed25519 supports hardened child derivation only. Every child index must have the ' (or h) suffix, meaning index + 0x80000000. Unhardened indices are rejected by the derivation logic (Ed25519 cannot support them because public key derivation is not possible without the private key).

Path parsing

pub fn parse_derivation_path(path: &str) -> Result<Vec<u32>, DerivationError>;

Parses paths like m/74'/0'/0'/0' into child indices. The m prefix is required. Hardened indices have ' or h suffix; unhardened indices are allowed in the parser (for BIP-0032 paths) but Ed25519 derivation will fail on them.

ExtendedPrivKey

#[derive(Clone, Zeroize)]
#[zeroize(drop)]
pub struct ExtendedPrivKey {
    private_key: Vec<u8>,   // 32 bytes
    public_key: Vec<u8>,    // 32 bytes
    chain_code: Vec<u8>,    // 32 bytes
    path: String,           // the path that produced this key
}

The result of SLIP-0010 derivation. Zeroized on drop. Accessors return slices — the caller copies what it needs.

impl ExtendedPrivKey {
    pub fn private_key(&self) -> &[u8];   // 32 bytes
    pub fn public_key(&self) -> &[u8];    // 32 bytes
    pub fn chain_code(&self) -> &[u8];    // 32 bytes
    pub fn path(&self) -> &str;
}

BIP-0032 secp256k1 Derivation (Ethereum)

Feature-gated behind secp256k1. Implements BIP-0032 HD key derivation for the secp256k1 curve, used for Ethereum signing keys.

#[cfg(feature = "secp256k1")]
pub fn derive_secp256k1_path(seed: &[u8], path: &str) -> Result<Secp256k1ExtendedPrivKey, DerivationError>;

Unlike SLIP-0010 (Ed25519), BIP-0032 supports both hardened and unhardened child derivation. The standard Ethereum path m/44'/60'/0'/0/0 uses unhardened indices for the last two levels.

#[derive(Clone, Zeroize)]
#[zeroize(drop)]
#[cfg(feature = "secp256k1")]
pub struct Secp256k1ExtendedPrivKey {
    private_key: Vec<u8>,   // 32 bytes
    public_key: Vec<u8>,    // 33 bytes (compressed)
    chain_code: Vec<u8>,   // 32 bytes
    path: String,           // the path that produced this key
}

#[cfg(feature = "secp256k1")]
impl Secp256k1ExtendedPrivKey {
    pub fn private_key(&self) -> &[u8];
    pub fn public_key(&self) -> &[u8];
    pub fn chain_code(&self) -> &[u8];
    pub fn path(&self) -> &str;
}

The VaultServiceHandle::derive_ethereum_key method calls derive_secp256k1_path and wraps the result into a DerivedKey: DerivedKey { key_type: KeyType::Secp256k1, private_key: extended.private_key().to_vec(), public_key: extended.public_key().to_vec() }. The Secp256k1ExtendedPrivKey is then dropped and zeroized; the DerivedKey is the caller-facing type.

Why a separate module

SLIP-0010 and BIP-0032 differ in:

Aspect SLIP-0010 (Ed25519) BIP-0032 (secp256k1)
HMAC key "ed25519 seed" "Bitcoin seed"
Child derivation Hardened only Hardened + unhardened
Public key size 32 bytes 33 bytes (compressed)
Public derivation Not possible Possible (unhardened)

The secp256k1 crate is a heavy dependency (it includes a C library for curve operations). Feature-gating it keeps the default vault lightweight — nodes that don't need Ethereum signing don't pay the cost.

When the feature is disabled, derive_ethereum_key returns VaultServiceError::UnsupportedKeyType.

Derivation Paths

alknet reserves the 74' coin type (unallocated per SLIP-0044) for its keys. Well-known paths are constants in the PATHS module:

pub mod PATHS {
    pub const IDENTITY: &str = "m/74'/0'/0'/0'";       // Primary identity keypair
    pub const DEVICE_PREFIX: &str = "m/74'/0'/0'";      // Worker/device identity prefix
    pub const SSH_HOST: &str = "m/74'/0'/1'/0'";        // SSH host key
    pub const ENCRYPTION: &str = "m/74'/2'/0'/0'";     // AES-256-GCM encryption key
    pub const ETHEREUM: &str = "m/44'/60'/0'/0/0";     // Ethereum signing key (secp256k1)
}

Helper functions construct parameterized paths:

pub fn device_path(index: u32) -> String;                      // m/74'/0'/0'/{index}'
pub fn encryption_path_for_version(version: u32) -> Result<String, DerivationError>;
// m/74'/2'/0'/{version-2}' — returns InvalidPath for version < 2

encryption_path_for_version returns DerivationError::InvalidPath for version < 2. v1 is reserved for the TS PBKDF2 legacy (ADR-020) — the vault cannot derive it, and silently mapping v1 to the v2 path would produce the wrong key (making v1 blobs appear to "decrypt" with a corrupted key). v0 is meaningless. derive_encryption_key_for_version propagates this error (VaultServiceError::InvalidPath).

Path semantics

Path Purpose Key type Used by
m/74'/0'/0'/0' Primary node identity (Ed25519) Ed25519 TLS raw key (ADR-010), node identity
m/74'/0'/0'/{n}' Worker/device identity Ed25519 Multi-device nodes, workers
m/74'/0'/1'/0' SSH host key Ed25519 SSH handler
m/74'/2'/0'/0' Encryption key for external credentials AES-256-GCM Credential encryption (v2, see encryption.md)
m/44'/60'/0'/0/0 Ethereum signing key secp256k1 Ethereum signing (feature-gated)

encryption_path_for_version maps a key version to its derivation path (ADR-021). v2 (current) maps to m/74'/2'/0'/0' (which is PATHS::ENCRYPTION); v3 maps to m/74'/2'/0'/1'; etc. This is the rotation mechanism — each version gets a cryptographically independent key from the same seed. Returns InvalidPath for version < 2 (v1 is TS PBKDF2 legacy — undecryptable by the vault by design).

KeyType tags DerivedKey (see protocol.md) and CachedKey (see service.md) so consumers know what they received without inspecting byte lengths.

Determinism

Derivation is deterministic: the same mnemonic + passphrase + path always produces the same key. This is verified by regression tests in tests/test_vectors.rs against the BIP39 "abandon...about" test vector.

Passphrase sensitivity

Different passphrases produce different seeds and therefore different keys. The passphrase is a legitimate access-control mechanism: two operators with the same mnemonic but different passphrases get different keysets. The vault does not enforce a passphrase policy — that's an assembly-layer concern.

Design Decisions

Decision ADR Summary
Vault is standalone ADR-018 Zero alknet crate dependencies
HD derivation (not stored keys) ADR-026 One seed, many keys, no key storage; reproducible across nodes
74' coin type reserved for alknet ADR-026 SLIP-0044 unallocated; alknet namespace
secp256k1 feature-gated ADR-026 Heavy dep; only needed for Ethereum
Hardened-only for Ed25519 SLIP-0010 Ed25519 cannot do public derivation
Vault is local-only ADR-025 Direct method calls, no irpc, no remote dispatch

Open Questions

See open-questions.md for full details.

  • OQ-20 (resolved by ADR-020): Encryption key derivation — HD derivation from seed, not PBKDF2. The salt field is unused in v2. See encryption.md.

References

  • BIP39 — mnemonic seed phrases
  • SLIP-0010 — Ed25519 HD derivation
  • BIP-0032 — secp256k1 HD derivation
  • SLIP-0044 — registered coin types (74' is unallocated)
  • Implementation: crates/alknet-vault/src/mnemonic.rs, crates/alknet-vault/src/derivation.rs, crates/alknet-vault/src/ethereum.rs
  • Test vectors: crates/alknet-vault/tests/test_vectors.rs