Port alknet-vault crate from alknet

Copy the local key vault (src/, tests/) verbatim from
alknet/crates/alknet-vault and create a standalone Cargo.toml
(workspace-inherited fields inlined). Port the architecture docs
(specs, ADRs 018-026, OQs 020-022) from alknet's nested multi-crate
layout to a flat single-crate layout, fixing relative link paths.

ADR and OQ numbers are preserved from alknet; a subsequent pass will
renumber them to a per-project sequence (001, 002, ...) and rebrand
alknet-vault -> alkvault (crate name, lib name, prose), updating the
cross-references to non-vault alknet ADRs (003, 005, 008, 010, 014,
064) that are referenced in the copied docs but not copied over.

Build, 108 tests, and clippy all pass clean.
This commit is contained in:
glm-5.2 committed 2026-08-02 06:39:49 +00:00
1 parent 9a137b5a21
commit 8fd3428544
30 files changed
+6684

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
target/
node_modules/
.worktrees/
Generated
+575
View File
@@ -0,0 +1,575 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]]
name = "alknet-vault"
version = "0.1.0"
dependencies = [
"aes-gcm",
"base64",
"bip39",
"ed25519-bip32",
"hex",
"hmac",
"rand",
"secp256k1",
"serde",
"serde_json",
"sha2",
"thiserror",
"zeroize",
]
[[package]]
name = "arrayvec"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bip39"
version = "2.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc"
dependencies = [
"bitcoin_hashes",
"rand",
"rand_core",
"serde",
"unicode-normalization",
"zeroize",
]
[[package]]
name = "bitcoin_hashes"
version = "0.14.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2"
dependencies = [
"hex-conservative",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "cc"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core",
"typenum",
]
[[package]]
name = "cryptoxide"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93f80e26fec88f5ae9450cd5f4e59f5c6421abafc919ea68a3edd521f9580c9b"
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
name = "ed25519-bip32"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10c212afef25445afd5cd3fc05941661b0473e681d7a39991b46c2821dbb9ba6"
dependencies = [
"cryptoxide",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]]
name = "hex"
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 = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "secp256k1"
version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
dependencies = [
"secp256k1-sys",
]
[[package]]
name = "secp256k1-sys"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9"
dependencies = [
"cc",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "tinyvec"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "zerocopy"
version = "0.8.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+31
View File
@@ -0,0 +1,31 @@
[package]
name = "alknet-vault"
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
description = "Local key vault: BIP39 mnemonic generation, SLIP-0010 Ed25519 HD key derivation, AES-256-GCM encryption for securing provider keys, credentials, and identity material"
repository = "https://git.alk.dev/alkdev/alkvault"
[lib]
name = "alknet_vault"
[features]
default = []
secp256k1 = ["dep:secp256k1"]
[dependencies]
bip39 = { version = "2", features = ["rand", "zeroize"] }
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"
secp256k1 = { version = "0.29", optional = true }
[dev-dependencies]
hex = "0.4"
+144
View File
@@ -0,0 +1,144 @@
---
status: stable
last_updated: 2026-06-23
---
# alknet-vault
Local key vault: BIP39 mnemonic generation, SLIP-0010 Ed25519 HD key
derivation, BIP-0032 secp256k1 derivation (feature-gated), and AES-256-GCM
encryption. Holds the master seed — the root of trust for all derived keys
and encrypted credentials in the alknet system.
## What This Crate Is
alknet-vault is a **standalone crate** with zero alknet crate dependencies
(ADR-018) and zero RPC framework dependencies (ADR-025). It provides the
cryptographic primitives and runtime API for managing the root of trust.
The CLI binary (the `alknet` crate) is the sole component that talks to the
vault directly (ADR-019) — handlers receive derived/decrypted material
through capabilities, never through a vault reference.
The vault is **not a network service**. It has no ALPN, no
`ProtocolHandler` implementation, no operations registered in the call
protocol (ADR-008, ADR-014), and no remote dispatch capability (ADR-025).
The vault is **local-only by construction** — direct method calls on
`VaultServiceHandle`, no actor, no message enum, no wire format. The master
seed and derived private keys never cross the network.
## Documents
| Document | Status | Description |
|----------|--------|-------------|
| [mnemonic-derivation.md](mnemonic-derivation.md) | stable | BIP39, SLIP-0010, BIP-0032, derivation paths, key types |
| [encryption.md](encryption.md) | stable | AES-256-GCM, EncryptedData, key versioning, HD derivation (ADR-020) |
| [service.md](service.md) | stable | VaultServiceHandle lifecycle, direct dispatch, cache, error model |
| [protocol.md](protocol.md) | stable | DerivedKey redaction, KeyType, serialization behavior |
## Applicable ADRs
| ADR | Title | Relevance |
|-----|-------|-----------|
| [003](decisions/003-crate-decomposition.md) | Crate Decomposition | alknet-vault's standalone position |
| [008](decisions/008-secret-service-integration.md) | Vault Integration Point | CLI-embedded, capability source |
| [010](decisions/010-alpn-router-and-endpoint.md) | ALPN Router and Endpoint | Ed25519 as default curve for TLS raw key identity |
| [014](decisions/014-secret-material-flow-and-capability-injection.md) | Secret Material Flow and Capability Injection | Capabilities carry vault-derived material |
| [018](decisions/018-vault-standalone-crate.md) | Vault as Standalone Crate | Zero alknet crate dependencies |
| [019](decisions/019-vault-assembly-layer-only.md) | Vault Assembly-Layer-Only Access | The assembly layer is the sole caller |
| [020](decisions/020-hd-derivation-for-encryption-keys.md) | HD Derivation for Encryption Keys | SLIP-0010 derivation, not PBKDF2; salt unused in v2 |
| [021](decisions/021-key-rotation-via-version-indexed-paths.md) | Key Rotation via Version-Indexed Paths | Version-indexed paths; `rotate` re-encrypts |
| [025](decisions/025-vault-local-only-dispatch.md) | Vault Local-Only Dispatch | Dropped irpc; direct method calls; local-only by construction |
| [026](decisions/026-vault-key-model-hd-derivation.md) | Vault Key Model — HD Derivation | HD derivation from BIP39 seed; `74'` coin type; AES-256-GCM |
## Relevant Open Questions
| OQ | Title | Status | Relevance |
|----|-------|--------|-----------|
| OQ-20 | Encryption key derivation | resolved (ADR-020) | HD derivation from seed; salt field unused in v2 |
| OQ-21 | Remote vault access | resolved (ADR-025) | Vault is local-only by construction; remote access requires a separate vault-server crate with its own ADR |
| OQ-22 | Key rotation mechanism | resolved (ADR-021) | Version-indexed paths; `rotate` method |
## Key Design Principles
1. **Standalone**: The vault depends on no alknet crate and no RPC framework.
It defines its own types and errors. External crates depend on the vault;
the vault depends on nothing in alknet.
2. **Assembly-layer only**: The vault's API is consumed by the CLI binary,
not by handlers. Handlers receive material through capabilities
(ADR-014). The vault is not on the wire.
3. **Local-only by construction**: The vault has no remote dispatch
capability. Direct method calls on `VaultServiceHandle` — no actor, no
message enum, no wire format (ADR-025). Remote access, if ever needed,
requires a separate crate with its own ADR.
4. **Zeroize everything sensitive**: The mnemonic, seed, derived private
keys, encryption keys, and cached keys all implement `Zeroize` and
`ZeroizeOnDrop`. Secret material does not linger in freed heap memory.
5. **Deterministic derivation**: The same mnemonic + passphrase + path
always produces the same key. Derivation is reproducible across runs
and across nodes.
6. **OsRng for nonces**: AES-GCM IVs and any cryptographic nonces use
`OsRng` (or equivalent CSPRNG), never `rand::random()`. IV reuse under
the same key is catastrophic for GCM.
7. **No `unwrap()` or `expect()` outside tests**: vault operations
propagate errors. A poisoned lock is recovered with
`unwrap_or_else(|e| e.into_inner())`, not `unwrap()`. A panic in one
vault operation must not brick the vault for all other operations.
## Security Constraints
These are security-critical implementation requirements, not architectural
decisions (the architecture is locked by the ADRs above). They are
documented here so implementation agents don't miss them. See
[service.md → Security Constraints](service.md#security-constraints) for
the full list.
- **OsRng for IVs**: AES-GCM IVs must use `OsRng`, not `rand::random()`.
- **Zeroized drop**: `Seed`, `Mnemonic`, `ExtendedPrivKey`,
`Secp256k1ExtendedPrivKey`, `EncryptionKey`, `CachedKey`, and
`DerivedKey` all derive `Zeroize` and `ZeroizeOnDrop`. The cache must
clear on drop, not just on explicit `lock()`.
- **No `unwrap()` outside tests**: poisoned lock recovery uses
`unwrap_or_else(|e| e.into_inner())` or explicit error propagation.
- **DerivedKey redaction in serialization**: `DerivedKey` serializes the
`private_key` as `"[REDACTED]"` in all formats (ADR-025 dropped the
postcard/remote path that previously preserved bytes in binary formats).
Deserialization rejects `"[REDACTED]"` with an error (resolves review
#002 W8). The redaction is a defense-in-depth measure for logging safety,
not the primary control — the primary control is that `DerivedKey` never
crosses the call protocol wire (ADR-014).
## Public API
The vault re-exports its primary types from the crate root:
```rust
// Mnemonic and seed
pub use mnemonic::{Language, Mnemonic, Seed};
// Derivation
pub use derivation::{DerivationError, ExtendedPrivKey, PATHS};
// Derivation helpers (derive_path_from_seed, parse_derivation_path,
// device_path, encryption_path_for_version) are accessible as
// alknet_vault::derivation::* — not re-exported at crate root to avoid
// clutter, but fully public.
// Encryption
pub use encryption::{EncryptedData, EncryptionError, EncryptionKey};
pub use encryption::CURRENT_KEY_VERSION;
// Key types (DerivedKey, KeyType)
pub use protocol::{DerivedKey, KeyType};
// Service (runtime)
pub use service::{VaultServiceError, VaultServiceHandle};
// Cache
pub use cache::CacheConfig;
```
The `secp256k1` feature flag gates Ethereum (BIP-0032) derivation:
```rust
#[cfg(feature = "secp256k1")]
pub mod ethereum;
```
@@ -0,0 +1,213 @@
# ADR-018: Vault as Standalone Crate
## Status
Accepted
## Context
alknet-vault provides BIP39 mnemonic generation, SLIP-0010 Ed25519 HD key
derivation, BIP-0032 secp256k1 derivation (feature-gated), and AES-256-GCM
encryption. It holds the master seed — the root of trust for all derived keys
and encrypted credentials in the alknet system.
The question is: what does alknet-vault depend on? The candidates:
1. **Depend on alknet-core** for shared types (errors, maybe Identity). This
pulls QUIC, quinn, iroh, rustls, and tokio runtime dependencies into the
vault's dependency tree.
2. **Stand alone** — zero alknet crate dependencies. The vault defines its own
types, its own error enum. Other crates depend on
the vault; the vault depends on nothing in alknet.
This is a one-way door. Once the vault depends on alknet-core, reversing it
requires removing that dependency from every type, error conversion, and
test — and the longer it stays, the more entangled it becomes.
### Why standalone matters
The vault is used in contexts where QUIC networking does not exist:
- **CLI tools**: a key-derivation utility that derives an identity key from a
mnemonic without starting a network endpoint.
- **Test harnesses**: integration tests in other crates derive test keys
without spinning up a QUIC endpoint.
- **WASM key derivation**: a future WASM target that derives keys in a browser
(the BiStream trait in ADR-007 preserves this door at the transport layer;
the vault's independence preserves it at the secret layer).
- **Embedded assembly**: a binary that only needs the vault to decrypt a
config file at startup, with no networking at all.
If the vault depends on alknet-core, all of these contexts pull in quinn,
iroh, rustls, and tokio — none of which they need. The vault's job is
cryptographic derivation and encryption. It has no networking concern.
### What the vault provides without alknet-core
The vault defines its own types and traits:
- `Mnemonic`, `Seed` — BIP39 root material
- `ExtendedPrivKey` (Ed25519), `Secp256k1ExtendedPrivKey` (Ethereum) —
derived key material
- `DerivedKey`, `KeyType` — protocol-level key representation
- `EncryptedData`, `EncryptionKey` — AES-256-GCM blobs
- `VaultServiceHandle` — runtime API (direct method calls; no actor, no
message enum — see ADR-025)
- `VaultServiceError` — its own error enum (string-wrapped sub-errors; the
vault doesn't share an error type with alknet-core)
The vault uses direct method calls on `VaultServiceHandle`, not irpc
dispatch (ADR-025). The vault is local-only by construction — no remote
dispatch capability, no `RemoteService` trait, no wire format for vault
messages. If remote vault access is ever needed, it's a separate crate that
wraps the vault (see ADR-025, OQ-021).
## Decision
**alknet-vault has zero alknet crate dependencies.** It depends only on
external crates (`bip39`, `ed25519-bip32`, `aes-gcm`, `sha2`, `hmac`,
`secp256k1`, `serde`, `zeroize`, `thiserror`, `base64`, `rand`). ADR-025
dropped `irpc`, `irpc-derive`, `postcard`, and `tokio` — the vault no longer
uses irpc dispatch or async sync primitives. All vault methods are
synchronous; `std::sync::RwLock` provides thread safety without a tokio
dependency.
The vault does not depend on:
- `alknet-core` — no shared types, no `Identity`, no `AuthContext`
- `alknet-call` — no `OperationSpec`, no `OperationContext`, no call protocol
- `alknet-vault` does not implement `ProtocolHandler` — it has no ALPN (see
ADR-019)
Dependency flow is strictly one-directional:
```
alknet-vault (standalone)
↑
alknet (CLI binary) — the only crate that depends on alknet-vault
```
No handler crate depends on alknet-vault directly. Handlers receive derived
material through capabilities injected by the assembly layer (ADR-014). The
CLI binary is the sole integration point (ADR-008, ADR-019).
### Type independence
The vault defines its own types and does not share types with alknet-core:
- `VaultServiceError` is the vault's error enum. It is a plain
`thiserror::Error` (ADR-025 dropped irpc, so vault errors no longer need
`Serialize`/`Deserialize` for wire dispatch). It does not implement
`From` for alknet-core error types — the CLI binary converts at the
assembly boundary.
- `DerivedKey` is the vault's key representation. It is not shared with
alknet-core's `Identity` type. The CLI binary extracts the bytes it needs
(private key for signing, public key for TLS identity) and constructs the
alknet-core types at the assembly layer.
- `EncryptedData` is the vault's encrypted blob format. It is shared with
`alknet-storage` (a future crate) by type-level agreement, not by a crate
dependency — both crates must agree on the serialization format (see
[encryption.md](../encryption.md)). The format is **frozen**
(see Decision below).
## `EncryptedData` Wire Format Lock
The `EncryptedData` struct is a **stable wire format** shared with
`alknet-storage` (a future crate) and the TypeScript consumer
(`@alkdev/storage`) by type-level agreement, not by a crate dependency.
Both crates and the TypeScript consumer must agree on the serialization
format. The format is now explicitly **frozen**:
```rust
pub struct EncryptedData {
pub key_version: u32, // rotation tracking
pub salt: String, // base64, 32 bytes — unused in v2 (wire-format compat)
pub iv: String, // base64, 12 bytes — AES-GCM nonce
pub data: String, // base64 — ciphertext + auth tag
}
```
The frozen compatibility surface:
- **Fields**: `key_version`, `salt`, `iv`, `data` — no fields may be
removed or renamed. New fields may be added only if they are optional
(default on deserialization) and do not change the meaning of existing
fields.
- **Encoding**: all binary fields are base64-encoded as strings for JSON
serialization. This is the cross-language wire format.
- **Field semantics**: `key_version` selects the derivation path
(ADR-021). `salt` is unused in v2 but is part of the frozen format —
it cannot be removed without a format-version migration (a future KDF
in v3 would use the salt for *new* data, not retroactively for v2 data
— see ADR-020, W6). `iv` is the 12-byte GCM nonce. `data` is the
ciphertext with the GCM auth tag appended.
**Why this needs an explicit lock**: the "type-level agreement, not a
crate dependency" approach means there is no compiler enforcement of the
format across crates. The stability contract existed only in prose. An
implementer modifying `EncryptedData` (e.g., removing the unused `salt`
field) would find no ADR saying "this format is frozen." This decision
makes the freeze explicit and enforceable by review.
**This resolves review #002 W10.**
## Consequences
**Positive:**
- The vault compiles and runs without QUIC, quinn, iroh, rustls, or a tokio
runtime (the `VaultServiceHandle` works with just `std::sync::RwLock`;
ADR-025 removed the actor and its `tokio::sync::mpsc` dependency entirely).
- CLI tools, test harnesses, and future WASM targets can use the vault for key
derivation without pulling in networking crates.
- The vault's API surface is stable — changes to alknet-core types don't
force a vault recompile, and changes to vault types don't force a
handler recompile (the CLI is the only consumer).
- No circular dependency risk. The dependency graph is a strict DAG.
- The vault can be published and used independently of alknet — it's a
general-purpose local key vault, not an alknet-specific component.
**Negative:**
- The vault cannot share types with alknet-core. If a type wants to be shared
(e.g., a future `Fingerprint` type), it must live in alknet-core and the
vault must define its own equivalent, or a new shared crate must be
created. This is a feature, not a bug — it forces explicit boundaries.
- The CLI binary must convert between vault types and alknet-core types at
the assembly boundary. This is a small amount of glue code (extract bytes
from `DerivedKey`, construct alknet-core types). See ADR-019.
- The vault's `VaultServiceError` is separate from alknet-core's
`HandlerError`. The CLI binary maps vault errors to handler errors or
startup failures. This is expected — the vault is a library, not a
handler.
## Assumptions
1. **The vault's API is consumed by one component (the CLI binary) in the
alknet system.** If a future use case requires multiple crates to depend
on the vault directly, the dependency flow still holds — they depend on
the vault, the vault depends on nothing. The standalone property is
preserved.
2. **Shared types between the vault and other crates are agreed by type-level
compatibility, not by a crate dependency.** `EncryptedData` is the example:
both the vault and `alknet-storage` (future) must agree on the
serialization format. This is documented in the type's spec, not enforced
by the type system across crates.
3. **The vault's error type does not need to integrate with alknet-core's
error handling.** The vault returns `VaultServiceError`; the CLI binary
handles it at the assembly boundary. If a future use case requires
propagating vault errors through alknet-core's error types, the CLI
converts at the boundary.
## References
- ADR-003: Crate decomposition (alknet-vault is standalone)
- ADR-005: irpc as call protocol foundation (superseded by ADR-064 — irpc
was never integrated into alknet-call; the vault no longer uses irpc
either — see ADR-025)
- ADR-025: Vault local-only dispatch (dropped irpc from the vault; the
vault uses direct method calls, no actor, no remote capability)
- ADR-008: Vault integration point (CLI-embedded, assembly-layer only)
- ADR-014: Secret material flow and capability injection
- ADR-019: Vault assembly-layer-only access
- [crates/vault/README.md](../README.md)
- Implementation: `crates/alknet-vault/`
@@ -0,0 +1,169 @@
# ADR-019: Vault Assembly-Layer-Only Access
## Status
Accepted
## Context
ADR-008 established that the vault is a **capability source** — the CLI
binary unlocks it at startup, derives and decrypts the credentials each
handler needs, and injects the results into handler capabilities. ADR-014
specified the injection mechanism (`Capabilities` on `OperationContext`) and
locked the constraint that no vault operations are registered in the call
protocol.
These ADRs answer *how the vault integrates with the rest of alknet*. This
ADR answers a narrower question that the vault's own spec needs to be
explicit about: **what is the vault's access model from its own
perspective?**
The vault provides a `VaultServiceHandle` with `unlock`, `lock`,
`derive_ed25519`, `derive_encryption_key`, `derive_ethereum_key`,
`encrypt`, and `decrypt` methods. Who is allowed to call these, and
through what path?
The candidates:
1. **Handlers call the vault directly** — each handler holds a
`VaultServiceHandle` and derives keys at call time. This was the
pre-ADR-008 model and is rejected: it exposes the vault to every handler,
requires the vault to enforce per-handler path restrictions itself, and
means the master seed is reachable from every call path.
2. **The call protocol exposes vault operations** — `vault/derive`,
`vault/decrypt`, `vault/unlock` registered as operations. This was the
contradiction ADR-014 resolved: the master seed and mnemonics would cross
the wire.
3. **The assembly layer is the sole caller** — the CLI binary (or an
embedded assembly layer) holds the `VaultServiceHandle`, calls vault
methods at startup and (rarely) at call time through scoped capabilities,
and injects results into handlers. Handlers never hold a vault reference.
## Decision
**The assembly layer is the sole direct caller of the vault.** This
restates ADR-008/ADR-014 from the vault's perspective and makes the access
model explicit in the vault's own spec.
### What the assembly layer does
At startup:
1. Constructs `VaultServiceHandle::new()`
2. Unlocks with a mnemonic (from a secure prompt, a file, or a hardware
token) and optional passphrase
3. Derives the keys each handler needs (identity, SSH host, TLS identity,
signing keys)
4. Decrypts the credentials each handler needs (LLM provider API keys,
OAuth tokens)
5. Constructs handlers with the derived/decrypted material injected into
their `Capabilities`
6. Registers the handlers in the `OperationRegistry`
7. Starts the endpoint
After startup, the vault is typically not called again. The common case is
construction-time injection — a handler holds a static decrypted API key for
its lifetime.
### What handlers do NOT do
Handlers never:
- Hold a `VaultServiceHandle` reference
- Call `derive_*`, `encrypt`, or `decrypt` directly
- Receive the master seed or mnemonic
- Import `alknet_vault` as a dependency
Handlers receive secret material through `OperationContext.capabilities`
(ADR-014). The `Capabilities` type holds non-serializable, zeroized secret
material that the assembly layer populated at construction time.
### The scoped-capability exception
The narrow exception is a handler that needs a child key at an
unpredictable path determined by call input (e.g., signing for a specific
GitHub repo). This handler receives a **scoped capability** — a restricted
handle that performs a specific derivation at a restricted path set and
returns the result in-process. The handler never sees the master seed and
never holds a full `VaultServiceHandle`.
The scoped capability is still a capability (it lives on
`OperationContext.capabilities`), not a vault reference. Whether it is a
distinct type or a pre-derived key injected at construction is a two-way
door for the alknet-call and alknet-agent crate specs (ADR-014).
### No vault operations on the wire
The vault has no ALPN (ADR-003, ADR-008). No vault operation is registered
in the call protocol's `OperationRegistry` (ADR-014). The master seed,
mnemonics, and derived private keys never appear in `call.requested`
payloads, `call.responded` payloads, or `OperationContext.metadata`
(ADR-014).
If a future use case requires exposing a vault operation over the call
protocol (e.g., a restricted `vault/public-key` operation that returns only
public key material for identity verification), it requires its own ADR
with an explicit threat model justification. This decision does not close
that door; it simply does not open it.
## Consequences
**Positive:**
- The master seed is reachable from exactly one place: the assembly layer.
The attack surface for the root of trust is a single process boundary, not
a distributed set of handlers.
- Handlers don't need to enforce path restrictions — they don't have the
vault. The scoped-capability mechanism enforces restrictions by
construction.
- The vault's API is consumed by one caller. This simplifies the vault's
threat model: it doesn't need per-caller authentication, rate limiting, or
path-based access control. The assembly layer is trusted.
- The vault can be tested in isolation — `VaultServiceHandle::new()` →
`unlock_new(24)` → `derive_*` is the test pattern, with no networking or
handler mockery.
**Negative:**
- The assembly layer has more construction-time responsibility: it must
know which handlers need which credentials and wire them. This is expected
— the CLI assembles everything (ADR-008).
- Adding a new handler that needs a new credential requires updating the
assembly layer, not just registering an operation. This is a feature:
it forces an explicit decision about what secret material a handler needs.
- Remote vault administration (unlock a running node's vault over the
network) is not supported. The vault is local-only by construction
(ADR-025) — no remote dispatch capability exists in the vault crate. If
remote vault access is needed in the future, it requires a separate
vault-server crate that depends on both alknet-core (for auth) and
alknet-vault (for the handle), with a heavily restricted mechanism
(admin scope, mTLS-only, never expose the mnemonic over an
unauthenticated channel) and its own ADR.
## Assumptions
1. **The assembly layer is trusted.** The CLI binary holds the vault handle
and is the trust boundary. If the assembly layer is compromised, all
handlers' capabilities are compromised. This is the same trust boundary
as ADR-008 and ADR-014.
2. **Handlers need credentials at construction time or at call time, not
dynamically discovered at call time.** If a handler needs to derive a key
at an unpredictable path determined by call input, the scoped-capability
model covers it (the handler holds a scoped vault access), but the
surface area is larger. The assumption is that this case is rare.
3. **No legitimate use case requires returning a private key over the
wire.** Public key sharing (identity verification, encryption to a
recipient) is the only cross-node key material flow. If a use case for
returning a private key emerges (e.g., a key-escrow service), it needs
its own ADR and a very different threat model.
## References
- ADR-003: Crate decomposition (alknet-vault is standalone)
- ADR-008: Vault integration point (CLI-embedded, capability source)
- ADR-014: Secret material flow and capability injection (the injection
mechanism this ADR relies on)
- ADR-018: Vault as standalone crate (the independence this ADR preserves)
- [crates/vault/service.md](../service.md)
- [crates/vault/README.md](../README.md)
@@ -0,0 +1,230 @@
# ADR-020: HD Derivation for Encryption Keys
## Status
Accepted
## Context
The vault encrypts external credentials (API keys, OAuth tokens) that cannot
be derived from the BIP39 seed — they're arbitrary bytes. The encryption
key for AES-256-GCM must come from somewhere. Two approaches exist:
### The TypeScript predecessor
The `@alkdev/storage` library (`/workspace/@alkdev/storage/src/graphs/crypto.ts`)
implemented credential encryption before the vault existed. It uses
**PBKDF2** (Password-Based Key Derivation Function 2) with a password and
salt:
```
key = PBKDF2(password, salt, iterations=100_000, hash=SHA-256, output=32 bytes)
```
- The **password** is the secret (a user-provided string, not a BIP39 seed)
- The **salt** is 16 bytes, randomly generated per encryption, and is
load-bearing — it participates in key derivation
- **Iterations**: 100,000 for key_version=1, 200,000 for key_version=2
- The resulting key is used for AES-256-GCM encryption
This was the right design before the vault existed: without a BIP39 seed,
PBKDF2 from a password was the only option. The salt prevents rainbow-table
attacks, and the iteration count slows brute-force.
### The vault's approach
The vault derives the encryption key from the BIP39 seed via **SLIP-0010
HD derivation** at path `m/74'/2'/0'/0'`:
```
seed → SLIP-0010 derive(m/74'/2'/0'/0') → first 32 bytes → AES-256-GCM key
```
- The **seed** is the secret (64 bytes, derived from the BIP39 mnemonic)
- The **salt** is generated (32 bytes) but **not used** in key derivation —
it's stored in `EncryptedData.salt` for forward compatibility
- No PBKDF2, no iteration count, no password stretching
- The key is deterministic: the same mnemonic + path always produces the
same key
### Why HD derivation is better now
With the vault in place, HD derivation is strictly better than PBKDF2 for
credential encryption:
1. **No password to manage.** The BIP39 mnemonic is already the root of
trust. PBKDF2 requires a separate password — another secret to manage,
lose, or have stolen. HD derivation uses the seed that already exists.
2. **Deterministic and reproducible.** The same mnemonic always produces the
same encryption key at the same path. A backup node derives the same key.
PBKDF2 with a different password produces a different key — there's no
way to reproduce the key without the exact password.
3. **No iteration overhead.** PBKDF2 with 100k iterations is intentionally
slow (that's the point — it slows brute-force). HD derivation is a few
HMAC operations — effectively instant. This matters when encrypting or
decrypting multiple credentials at startup.
4. **Domain separation via paths.** Different encryption purposes can use
different derivation paths (`m/74'/2'/0'/0'` for v2, `m/74'/2'/0'/1'`
for a future v3). PBKDF2 has no equivalent — the only versioning knob is
the iteration count or the password. See ADR-021 for the version-indexed
path scheme.
5. **The salt becomes unnecessary for key derivation.** HD derivation
doesn't need a salt — the path provides domain separation. The salt
field in `EncryptedData` is kept for wire-format compatibility but does
not participate in key derivation.
### The compatibility problem
The `EncryptedData` wire format is the same across both implementations
(`keyVersion`, `salt`, `iv`, `data` — all base64-encoded strings). But the
key derivation is different:
- **TS v1**: PBKDF2(password, salt, 100k iterations) → key
- **Rust v1**: SLIP-0010(seed, `m/74'/2'/0'/0'`) → key
Data encrypted by the TS implementation **cannot be decrypted by the vault**
— the keys are different even if the password equals the mnemonic. This is a
hard incompatibility at the crypto layer, not a format issue.
## Decision
### 1. HD derivation is the vault's encryption key derivation method
The vault uses SLIP-0010 HD derivation from the BIP39 seed at path
`m/74'/2'/0'/0'` (`PATHS::ENCRYPTION`) to produce the AES-256-GCM
encryption key. No PBKDF2. No password-based key derivation. The seed is
the sole secret input.
### 2. The salt field is unused in vault-encrypted data
The `EncryptedData.salt` field exists in the wire format for compatibility
with the TS `EncryptedDataSchema`, but the vault does not use it for key
derivation. The vault generates a random salt and stores it (for wire-format
consistency), but it plays no cryptographic role. If a future KDF-based
derivation is needed (see "Future KDF" below), the field is already present.
### 3. key_version semantics
| Version | Key derivation | Used by | Decryptable by vault? |
|---------|---------------|---------|----------------------|
| 1 | PBKDF2 (password + salt + 100k iterations) | TS `@alkdev/storage` | No — different key |
| 2 | SLIP-0010 HD derivation (seed → `m/74'/2'/0'/0'`) | Rust vault | Yes |
The vault stamps `key_version: 2` on new encryptions. `CURRENT_KEY_VERSION`
is `2`.
**The current source uses `CURRENT_KEY_VERSION = 1` with HD derivation.**
This is a drift from the spec — the source's v1 is HD-derived, but the TS
v1 is PBKDF2-derived. Same version number, different derivation. The source
must be updated to use `key_version: 2` for HD-derived data, reserving v1
for the TS PBKDF2 legacy.
### 4. Migration path: TS → vault
TS-encrypted credentials (PBKDF2, key_version=1) are migrated to vault-
encrypted credentials (HD derivation, key_version=2) through a one-time
re-encryption:
1. Decrypt the TS-encrypted data with the original password and PBKDF2
(using the TS `@alkdev/storage` `decrypt()` function or a migration
tool that implements PBKDF2)
2. Re-encrypt the plaintext with the vault at `key_version: 2`
3. Replace the old `EncryptedData` blob in storage
The vault does **not** implement PBKDF2. The migration is performed by a
separate tool or script that has access to both the TS `decrypt()` function
and the vault's `encrypt()`. This is a one-time migration — once all data
is at key_version=2, PBKDF2 is no longer needed.
### 5. No PBKDF2 in the vault
The vault does not implement PBKDF2 and does not support decrypting
key_version=1 (TS PBKDF2) data. The vault's `decrypt()` method derives the
key via HD derivation and attempts decryption. If the data was encrypted
with PBKDF2 (TS), decryption fails (wrong key) — this is correct behavior,
not a bug. The migration tool handles the TS→vault transition.
### 6. Future KDF (not v2)
If a future use case requires KDF-based key derivation (e.g., stretching a
key derived from a non-seed source, or using a salt for additional domain
separation), it would be a new key_version with its own derivation method.
The `salt` field is available for this.
**Clarification (review #002 W6)**: the salt field is reserved for *future
versions'* use. v2 data's salt is permanently unused — it was random, never
participated in key derivation, and cannot be retroactively made
load-bearing for v2 data. Introducing a KDF in v3 is a new derivation
method (not a version-indexed path), requiring its own design and a v2→v3
migration (re-encrypt with the new KDF, using a newly-generated v3 salt —
the v2 salt is not reused). The field's presence saves a wire-format struct
change only (ADR-018 locks the wire format); it does not make the KDF
design or migration trivial. A KDF doesn't fit the rotation scheme
(version-indexed paths, ADR-021) — it's a different derivation *family*,
not another version index. See OQ-22 (key rotation) and ADR-018
(`EncryptedData` wire format lock).
## Consequences
**Positive:**
- One secret (the BIP39 seed) is the root of trust for both derived keys
and encryption keys. No separate password to manage.
- Encryption key derivation is instant (HD derivation) vs. slow (PBKDF2
100k iterations). Startup with many credentials is fast.
- The encryption key is reproducible — a backup node with the same mnemonic
derives the same key and can decrypt the same credentials.
- Domain separation via paths — future encryption purposes can use
different paths without changing the wire format.
- Clean break from the TS approach. No PBKDF2 code in the vault. The vault
is smaller and simpler.
**Negative:**
- TS-encrypted data cannot be decrypted by the vault. Migration requires a
separate tool with access to both the TS `decrypt()` and the vault's
`encrypt()`. This is expected — the TS implementation is being replaced,
not integrated.
- The `salt` field is unused in v2. It occupies 44 bytes (base64-encoded 32
bytes) per `EncryptedData` blob for no cryptographic purpose. This is the
cost of wire-format compatibility — keeping the field means the struct
doesn't need to change if a future KDF uses it.
- `CURRENT_KEY_VERSION` must change from 1 to 2 in the source. If any
vault-encrypted data already exists at key_version=1 (with HD derivation),
it would need re-encryption at key_version=2. In practice, the vault is
pre-production, so this is a source change, not a data migration.
## Assumptions
1. **The TS `@alkdev/storage` encrypted data is the only legacy.** If other
systems produce PBKDF2-encrypted `EncryptedData` blobs, they need the same
migration treatment. The assumption is that `@alkdev/storage` is the only
consumer.
2. **The vault is pre-production.** No significant amount of vault-encrypted
data (HD derivation, key_version=1) exists in production. Bumping to
key_version=2 is a source change, not a data migration. If vault-encrypted
data does exist, it needs re-encryption at key_version=2 (decrypt with HD
key at v1 path, re-encrypt at v2 — same key, just version bump).
3. **The migration is one-time and one-directional.** Once data is at
key_version=2, there's no path back to PBKDF2. The TS `@alkdev/storage`
crypto module becomes legacy after migration.
4. **The `salt` field's forward compatibility is worth the 44 bytes.** If a
future KDF is never needed, the salt field is wasted space. The assumption
is that the cost is negligible (credentials are small, not bulk data) and
the flexibility is worth it.
## References
- ADR-018: Vault as standalone crate
- ADR-019: Vault assembly-layer-only access
- [encryption.md](../encryption.md) — AES-256-GCM, EncryptedData
- [mnemonic-derivation.md](../mnemonic-derivation.md) — SLIP-0010, PATHS::ENCRYPTION
- OQ-20: Salt/KDF Phase B (resolved by this ADR)
- OQ-22: Key rotation mechanism (still open — this ADR defines v2 but not the rotation workflow)
- TypeScript predecessor: `/workspace/@alkdev/storage/src/graphs/crypto.ts`
- TypeScript secret graph: `/workspace/@alkdev/storage/src/graphs/modules/secret-graph.ts`
@@ -0,0 +1,253 @@
# ADR-021: Key Rotation via Version-Indexed Derivation Paths
## Status
Accepted
## Context
ADR-020 established that the vault derives the AES-256-GCM encryption key
from the BIP39 seed via SLIP-0010 HD derivation at path `m/74'/2'/0'/0'`.
The `EncryptedData.key_version` field exists for rotation tracking, but
the current implementation always derives at the same path regardless of
version — `key_version` is metadata, not a functional selector.
OQ-22 asked: how does key rotation work? The key versioning is in place,
but the rotation mechanism — how a new key is derived, how existing data
is re-encrypted, and how the vault selects the right key for decryption —
is not specified.
### Why rotation matters
Key rotation is a fundamental security hygiene practice. The scenarios
that require it:
1. **Suspected key compromise**: the encryption key may have leaked
(memory dump, process compromise, log accident). All data encrypted
with that key must be re-encrypted with a new key.
2. **Periodic rotation**: security policy mandates key rotation every N
months. The vault must support this without re-deriving from a new
mnemonic (which would require re-deploying all nodes).
3. **Version transition**: moving from TS PBKDF2 data (v1) to vault HD
data (v2, per ADR-020) is itself a rotation. The mechanism should
generalize — it's the same operation.
### What "rotation" means concretely
Rotating from key version N to N+1:
1. Derive a new encryption key at a new derivation path
2. For each existing `EncryptedData` blob with `key_version: N`:
- Decrypt with the v-N key
- Re-encrypt the plaintext with the v-(N+1) key
- Replace the blob in storage with `key_version: N+1`
3. New encryptions use `key_version: N+1`
4. Old keys remain available for decrypting any data that hasn't been
rotated yet (partial rotation is safe)
The question is: **how is the new key derived?** The options:
- **Option A: New derivation path per version.** `m/74'/2'/0'/0'` for v2,
`m/74'/2'/0'/1'` for v3, etc. Each version gets its own HD key. No
new seed needed.
- **Option B: New mnemonic (new seed).** Generate a new mnemonic, unlock
with it, re-encrypt everything. This is heavy — it changes *all* derived
keys (identity, SSH host, etc.), not just the encryption key.
- **Option C: KDF from the existing key.** Use HKDF or PBKDF2 with the
existing derived key + the salt as input. This is the salt field's
potential use (OQ-20 mentioned this), but it adds KDF complexity and
the salt becomes load-bearing.
## Decision
### 1. Version-indexed derivation paths
Each key version maps to a unique derivation path. The last hardened index
in the encryption path is the key version:
```
v2: m/74'/2'/0'/0' ← PATHS::ENCRYPTION (current)
v3: m/74'/2'/0'/1'
v4: m/74'/2'/0'/2'
...
```
The `encryption_path_for_version(version)` function constructs the path:
```rust
pub fn encryption_path_for_version(version: u32) -> String {
// v1 is the TS PBKDF2 legacy — not an HD path. The vault starts at v2.
// v2 → m/74'/2'/0'/0', v3 → m/74'/2'/0'/1', etc.
let index = version.saturating_sub(2);
format!("m/74'/2'/0'/{}'", index)
}
```
`PATHS::ENCRYPTION` remains `m/74'/2'/0'/0'` — it's the v2 path, and v2
is the current version. When the vault is rotated to v3,
`encryption_path_for_version(3)` produces `m/74'/2'/0'/1'`.
This means:
- No new mnemonic needed — rotation uses the same seed, different path
- Each version's key is cryptographically independent (HD derivation
ensures this)
- The derivation path is self-documenting (`m/74'/2'/0'/1'` is clearly
"encryption key, version 3")
- Old keys are always derivable (the seed doesn't change), so partial
rotation is safe — the vault can decrypt any version
### 2. `encrypt_key(version)` and `decrypt_key(version)` methods
The `VaultServiceHandle` gains version-aware key derivation:
```rust
impl VaultServiceHandle {
/// Derive the encryption key for the given version. Cached.
fn derive_encryption_key_for_version(
&self,
version: u32,
) -> Result<EncryptionKey, VaultServiceError> {
let path = encryption_path_for_version(version);
// ... derive at path, cache by path ...
}
/// Encrypt with the current key version.
pub fn encrypt(&self, plaintext: &str, key_version: u32) -> Result<EncryptedData, VaultServiceError>;
/// Decrypt by deriving the key at the version indicated by the blob.
pub fn decrypt(&self, encrypted: &EncryptedData) -> Result<String, VaultServiceError> {
let key = self.derive_encryption_key_for_version(encrypted.key_version)?;
encryption::decrypt(encrypted, &key)
}
}
```
`decrypt` now derives the key at the path **indicated by
`encrypted.key_version`** — not always at `PATHS::ENCRYPTION`. This corrects
a source drift: the current source ignores `key_version` for key selection;
the spec now makes it functional.
### 3. `rotate` method
```rust
impl VaultServiceHandle {
/// Re-encrypt an EncryptedData blob from one key version to another.
///
/// Decrypts with the key at the blob's current key_version,
/// re-encrypts with the key at `to_version`. Returns the new
/// EncryptedData. Does not update storage — the caller replaces the
/// blob in storage.
pub fn rotate(
&self,
encrypted: &EncryptedData,
to_version: u32,
) -> Result<EncryptedData, VaultServiceError> {
let plaintext = self.decrypt(encrypted)?;
self.encrypt(&plaintext, to_version)
}
}
```
`rotate` is a vault method, not a storage operation. It decrypts and
re-encrypts; the caller (the assembly layer or a migration tool) handles
replacing the blob in storage. This keeps the vault focused on crypto and
the storage system focused on storage.
### 4. `CURRENT_KEY_VERSION` and rotation policy
```rust
pub const CURRENT_KEY_VERSION: u32 = 2;
```
`encrypt()` stamps `CURRENT_KEY_VERSION` (or the explicitly-passed version)
onto new `EncryptedData` blobs. The assembly layer decides when to rotate:
- **Manual rotation**: an operator triggers rotation (e.g., a CLI command
`alknet vault rotate --to v3` that loads all blobs, calls `rotate` on
each, and writes them back to storage).
- **No automatic rotation**: the vault does not self-rotate. Rotation is
an operational action, not a runtime behavior. The vault provides the
mechanism; the policy is external.
### 5. Cache implications
The `KeyCache` is keyed by derivation path. Since each version has a
distinct path, the cache naturally holds multiple versions simultaneously.
This is correct — during a rotation, the vault may need to decrypt old
blobs (v2) and encrypt new blobs (v3), and both keys should be cached.
The cache's TTL and LRU eviction still apply. If the cache evicts an old
version's key during a long rotation, the next `decrypt` of an old blob
re-derives it (the seed hasn't changed). This is correct but slightly
slower — the rotation tool should be aware that cache misses on old keys
are expected.
## Consequences
**Positive:**
- Key rotation is a vault method (`rotate`), not a storage operation or a
full mnemonic change. It's cheap (HD derivation) and local.
- Partial rotation is safe. Old and new keys coexist — the vault can
decrypt any version. This means a rotation can be performed incrementally
(rotate some blobs, verify, rotate the rest).
- No new mnemonic needed. The same seed produces all version keys. A
backup node with the same mnemonic can decrypt any version.
- The derivation path is self-documenting. `m/74'/2'/0'/1'` is clearly
"encryption key version 3."
- The `salt` field remains unused — no KDF complexity. Rotation is pure HD
path indexing.
- The mechanism generalizes the TS→vault migration (v1→v2 is a rotation,
though v1 requires the TS PBKDF2 `decrypt`, not the vault's `decrypt`).
**Negative:**
- `decrypt` now derives the key at the version-indicated path, which means
a cache miss on an old version re-derives from the seed. This is a few
HMAC operations — negligible, but the path construction and cache lookup
add a small amount of complexity over the current "always use
`PATHS::ENCRYPTION`" approach.
- The rotation tool (CLI command or migration script) must iterate all
stored blobs and call `rotate` on each. This is an operational concern,
not a vault concern — but the vault spec should document the expected
usage pattern so the tool implementer knows the contract.
- Old version keys are always derivable (the seed doesn't change). This is
a feature (partial rotation is safe) but also means a compromised seed
allows decrypting all versions. If the seed itself is compromised, all
versions are compromised — rotation doesn't help. This is inherent to
HD derivation and not specific to this design.
## Assumptions
1. **The seed is not compromised.** If the seed is compromised, rotating
the encryption key path doesn't help — the attacker can derive all
version keys. Seed compromise requires a full mnemonic change (new
seed, re-derive everything, re-deploy). This ADR covers encryption key
rotation, not seed rotation. Seed rotation is an operational procedure
(generate new mnemonic, unlock with it, re-encrypt all data) that is
outside the vault's API.
2. **Rotation is infrequent.** The vault does not optimize for frequent
rotation (e.g., per-request key derivation). Rotation is an operational
event triggered by policy or incident. The cache and path-indexed
approach are efficient for this usage pattern.
3. **The storage system tracks which blobs to rotate.** The vault's `rotate`
method handles one blob at a time. Iterating all stored
`EncryptedData` blobs is the storage system's job (or the CLI's). The
vault doesn't know what's in storage — it only knows how to rotate a
blob it's given.
4. **v1 (TS PBKDF2) data is not rotated through the vault.** v1 data is
decrypted by the TS `decrypt()` function (PBKDF2), not the vault's
`decrypt()` (which uses HD derivation). The v1→v2 migration is a
separate tool that has access to both. Once data is at v2, future
rotations (v2→v3, etc.) use the vault's `rotate` method.
## References
- ADR-020: HD derivation for encryption keys (this ADR builds on the
version-indexed path scheme)
- OQ-22: Key rotation mechanism (resolved by this ADR)
- [encryption.md](../encryption.md) — AES-256-GCM, EncryptedData
- [service.md](../service.md) — encrypt, decrypt, rotate methods
- [mnemonic-derivation.md](../mnemonic-derivation.md) —
derivation paths, `PATHS::ENCRYPTION`
@@ -0,0 +1,336 @@
# ADR-025: Vault Local-Only Dispatch
## Status
Accepted
## Context
alknet-vault uses irpc for its internal dispatch. The `VaultProtocol` enum is
annotated with `#[rpc_requests(message = VaultMessage, no_spans)]`, which
generates a `Service` trait impl (for in-process mpsc dispatch) and a
`RemoteService` trait impl (for remote QUIC dispatch). The vault's
`VaultServiceActor` processes `VaultMessage` variants from an mpsc channel.
This was adopted from irpc's actor pattern (ADR-005).
### What irpc gives the vault
Separating irpc into its constituent parts and asking which the vault
actually needs:
| irpc component | What it does | Does the vault need it? |
|---|---|---|
| `#[rpc_requests]` macro | Generates message enum, `Channels` impls, `From` conversions | Marginally — it's convenient boilerplate, but the vault's protocol is 8 variants |
| `Service` trait | Local in-process dispatch via mpsc + oneshot | No — `VaultServiceHandle` direct calls are already preferred (service.md: "For local in-process use, prefer `VaultServiceHandle` directly — no channel, no serialization") |
| `RemoteService` trait | Remote dispatch via QUIC + postcard | No — this is the footgun |
| `Client<S>` | Wraps either local mpsc or remote QUIC | No — the assembly layer uses the handle directly |
| `IrohProtocol` handler | Forwards all messages without auth | No — this is the default-insecure handler |
| postcard serialization | Binary serialization for remote dispatch | No — not needed without remote dispatch |
| `DerivedKey` dual serialization | JSON redacts, postcard preserves | Only needed *because* remote dispatch exists |
The vault uses irpc for the actor pattern (in-process mpsc dispatch), but
the actor pattern is the *secondary* dispatch path. The primary path — direct
method calls on `VaultServiceHandle` — doesn't use irpc at all. And the thing
that makes irpc attractive for the actor pattern (the macro-generated
boilerplate) is a convenience, not a structural need. The vault's protocol
is small enough that the boilerplate is manageable by hand, or simply
unnecessary when the actor is removed.
### The security problem: default-insecure
The core problem is not that remote vault access is *possible* in principle
— it's that irpc makes it possible *by default*, with the unsafe path being
the easy path.
The `#[rpc_requests]` macro generates `RemoteService` unless you pass
`no_rpc`. The `IrohProtocol` handler forwards all message types without auth
checks. The docs frame "register an ALPN" as a server-setup change
(OQ-21: "Enabling remote access is a server-setup change"). The result is
an architecture where:
1. The vault is remote-capable by construction (the footgun is loaded).
2. Enabling remote access is easy — one line: `Router::builder(endpoint)
.accept(b"alknet/vault", protocol).spawn()`.
3. The default handler has no auth (the safety is off).
4. Making it safe requires an auth-wrapping handler *outside the vault
crate* (the safety is a separate part you have to remember to install).
This is the **default-insecure anti-pattern**. Security should be opt-in, not
opt-out. The vault should be local-only by default, and remote access should
require *adding* something, not *removing* a default.
### The use cases don't justify the default
**Single node, local vault (the designed path):** The CLI binary unlocks the
vault at startup, derives/decrypts credentials, injects them into handler
capabilities. The vault is accessed only at the assembly layer (ADR-019). No
network. This is the path every deployment starts with, and it needs only
direct in-process method calls on `VaultServiceHandle`. irpc adds nothing.
**Many nodes encrypt/decrypt the same data:** The most likely network-vault
use case, but a stretch. The better pattern is per-node vaults: the head
encrypts credentials *for* the worker using the worker's public key or a
shared derivation path the worker can derive locally. The worker decrypts
locally. This is end-to-end encryption between nodes, not a centralized
decryption oracle. It matches ADR-008's "capability source" model —
credentials are injected at the assembly layer, not fetched over the network
at call time.
**Machine node → workers (OQ-21's use case):** A long-lived machine node
holds the mnemonic and exposes a restricted vault API to ephemeral workers.
This is the use case the vault docs actually spec. But `from_call`'s trust
model already flags the risk: "a compromised remote node can do anything its
operations are declared to do" (operation-registry.md). If the machine node
is compromised, every worker that calls it is compromised. That's inherent
to remote vault access and not a reason to forbid it, but it *is* a reason
to make the exposure a deliberate, hard-to-accidentally-enable act — not the
default state of the crate.
None of these use cases justify making the vault remote-capable *by
construction*. The first needs no remote. The second has a better pattern
(per-node vaults). The third is real but should be an explicit addition, not
a default that's already loaded.
### The actor path is dead code
service.md says "For local in-process use, prefer `VaultServiceHandle`
directly — no channel, no serialization." The actor exists *for* irpc, and
the direct path is preferred. So the vault has two dispatch paths, and the
one irpc provides (actor) is the secondary one. The primary path (direct
method calls) doesn't use irpc at all. The actor is dead code for the
designed use case — it exists only to make irpc's `Service` trait work,
which exists only to make `RemoteService` work, which is the footgun.
## Decision
### 1. alknet-vault drops irpc entirely
The vault's dispatch is direct method calls on `VaultServiceHandle`. No
`VaultProtocol` enum, no `VaultMessage`, no `VaultServiceActor`, no mpsc
channel, no `Service` trait, no `RemoteService` trait, no `Client<S>`, no
`IrohProtocol` handler, no postcard serialization.
The vault's public API is `VaultServiceHandle` (and the types it returns:
`DerivedKey`, `KeyType`, `EncryptedData`, `EncryptionKey`). That's it. An
implementer reading the vault crate sees one way to use it, not two ways
with a note saying "prefer the first."
### 2. The vault is local-only by construction
The vault crate has no remote dispatch capability. There is no
`RemoteService` trait, no remote handler, no wire format for vault messages.
Enabling remote vault access is not a flag flip or a server-setup change —
it requires *building a separate crate* that depends on both alknet-core
(for auth) and alknet-vault (for the handle) and adds the remote transport
+ auth-wrapping handler. That is a visible architectural act that shows up
in code review, not a runtime config flip on a macro that was already
generating the remote code.
This inverts the security default: local-only is the only mode. Remote
access requires adding something, not removing a default.
### 3. `DerivedKey` serialization simplifies
Without the postcard/remote-dispatch path, `DerivedKey`'s custom
`Serialize` always redacts the private key (for logging safety) — there is
no "postcard preserves bytes" path. The custom `Deserialize` rejects
`private_key == "[REDACTED]"` with an error rather than producing a
corrupted key (this resolves review #002 finding W8).
The redaction is purely for defense-in-depth against logging accidents.
The architectural control — `DerivedKey` never appears in call protocol
payloads (ADR-014) — is unchanged and remains the primary control. The
serialization redaction is the safety net, not the primary mechanism.
`VaultServiceError` no longer needs `Serialize`/`Deserialize` (which it had
for irpc dispatch). It can be a plain `thiserror::Error` enum. If a future
remote-vault crate needs to serialize errors across the wire, *that crate*
defines the wire representation.
### 4. If remote vault access is ever needed, it's a separate crate
The vault-server-crate question (review #002 C7) is decided: *if* remote
vault access is ever needed, it is a separate crate that depends on both
alknet-core (for `IdentityProvider`, scopes, auth-wrapping) and
alknet-vault (for `VaultServiceHandle`). The vault crate itself remains
local-only. This is a decision not to create the crate now, and not to
preclude it. It is the path of least commitment, and it matches ADR-018's
standalone-vault principle.
The remote vault crate would need its own ADR (matching ADR-019's language:
"requires its own ADR") defining the threat model, the access policy, the
auth-wrapping handler, and the operation filtering (Unlock/Lock local-only).
### 5. The vault's dependency footprint shrinks
The vault drops: `irpc`, `irpc-derive`, `postcard` (for remote), `noq`
(via irpc), `iroh` (via irpc-iroh), and `tokio` (the actor's
`tokio::sync::mpsc` channels are gone; all vault methods are synchronous
and use `std::sync::RwLock` for thread safety). It retains: `bip39`,
`ed25519-bip32`, `aes-gcm`, `sha2`, `hmac`, `secp256k1` (feature-gated),
`serde` (for `DerivedKey` redaction and `EncryptedData` wire format),
`zeroize`, `thiserror`, `base64`, `rand`.
ADR-018's "zero alknet crate dependencies" becomes "zero alknet crate
dependencies and zero RPC framework dependencies." This is the cleanest
version of ADR-018's intent.
## Consequences
**Positive:**
- The security default is inverted. Local-only is the only mode. Remote
access requires building a separate crate — a visible, deliberate act.
This matches the principle that security should be opt-in, not opt-out.
- The vault's API is honest. `VaultServiceHandle` is the API. No secondary
dispatch path that exists for a feature (remote) that isn't enabled. An
implementer sees one way to use the vault, not two with a note saying
"prefer the first."
- Dead code is removed. The actor path, which service.md says is secondary
to direct calls, is gone. The `VaultProtocol` enum, `VaultMessage`,
`VaultServiceActor`, and the mpsc dispatch loop are gone. The vault is a
pure library with a thread-safe handle.
- `DerivedKey` serialization simplifies. The dual serialization (JSON
redacts, postcard preserves) is replaced by always-redact-on-serialize,
reject-on-deserialize. No "postcard preserves bytes" path to test or
document. This resolves review #002 W8 (silent corruption on
JSON-deserialized `DerivedKey`) — the custom `Deserialize` rejects
redacted payloads with an error.
- The dependency footprint shrinks. No irpc, no postcard-for-remote, no
noq, no iroh via irpc. The vault is truly standalone (ADR-018's intent,
strengthened). Supply-chain surface is reduced.
- The vault's concurrency model is honest. `VaultServiceHandle` is
`Arc<RwLock<...>>` — the RwLock provides concurrent reads (derive) and
exclusive writes (unlock/lock). The actor's sequential processing was
actually *worse* for throughput than the RwLock. Removing the actor
makes the concurrency model visible and correct.
- `derive_password` and `site_password_path` are removed from the vault's
API and path model. The password-manager pattern (deterministic per-site
passwords from HD derivation) is not relevant to an RPC system's vault —
handlers call APIs (using API keys, OAuth tokens, mTLS), not websites
with passwords. The vault is for cryptographic key derivation and
credential encryption. This resolves review #002 C9 (site_password_path
hash mapping underspecified) by removing the feature rather than
specifying the non-standard string→u32 mapping and Ed25519-as-password-
entropy construction. If deterministic password generation is ever needed
(browser-automation edge case), it can be re-added or implemented as a
separate concern — the cost is near-zero, and removing it now eliminates
permanent API surface that was inherited from a prior project's
password-manager pattern.
**Negative:**
- The vault's `VaultProtocol` enum and `VaultServiceActor` are removed.
This is a breaking change to the vault crate's public API (`VaultProtocol`,
`VaultMessage`, `VaultServiceActor`, `Client<VaultProtocol>` are removed
from the public exports). Since no implementation consumer exists outside
the vault crate itself (ADR-019: the assembly layer uses
`VaultServiceHandle` directly), this is a spec edit, not a migration.
- If a future use case needs the actor pattern (e.g., for a remote-vault
crate that wants in-process mpsc dispatch before forwarding over the
wire), it must be re-added in *that crate*, not in the vault. This is
additive — the vault's direct-handle API is unchanged.
- The `DerivedKey` postcard round-trip tests in `protocol.rs` are removed.
The JSON-redaction tests remain. If a future remote-vault crate needs
postcard serialization, it defines and tests its own serialization path
for the types it sends over the wire.
- `VaultServiceError` loses `Serialize`/`Deserialize`. Any code that
serialized vault errors (only the irpc dispatch path, which is removed)
must adapt. The assembly layer converts vault errors to alknet-core
errors at the boundary (ADR-018), and that conversion is string-based
already.
**On review #002 findings resolved by this ADR:**
- **C7 (OQ-21 remote vault)**: resolved. OQ-21 moves from "deferred" to
"resolved: remote vault access is not a feature of the vault crate; if
needed, a separate vault-server crate wraps the vault and adds remote
transport + auth, requiring its own ADR." The vault-server-crate question
is decided: not created now, not precluded. The crate-decomposition
one-way door (ADR-003 territory) is decided by *not* creating the crate
now.
- **W8 (`DerivedKey` JSON deserialization silently corrupts)**: resolved.
Without the postcard path, the custom `Deserialize` rejects
`private_key == "[REDACTED]"` with an error. There is no
"postcard preserves bytes" path to complicate the serialization story.
The redaction is purely for logging safety; deserialization of a redacted
payload is always an error.
- **C8 (operation access policy table incomplete)**: dissolved. Without
`VaultProtocol`'s remote capability, there is no operation access policy
table to complete — all operations are local-only by default. The table
in protocol.md goes away. If a future vault-server crate exposes some
operations remotely, *that crate* defines the access policy in its own
ADR.
- **C9 (site_password_path hash mapping underspecified)**: resolved. The
`derive_password` / `derive_password_string` / `site_password_path`
methods are removed from the vault's API. The password-manager pattern
is not relevant to an RPC system's vault. No hash mapping to specify,
no Ed25519-as-password-entropy question to answer.
## Assumptions
1. **The vault's designed use case is local-only.** ADR-019 says the
assembly layer is the sole direct caller. ADR-008 says the vault is a
capability source accessed at assembly time. ADR-014 says handlers
receive credentials through `OperationContext.capabilities`, not by
calling vault operations. The vault was always designed to be local —
irpc's remote capability was an accident of adoption, not a designed
feature.
2. **Per-node vaults are the right pattern for multi-node deployments.**
Each node has its own vault and mnemonic. Credentials are encrypted *for*
the receiving node's public key, not decrypted centrally. This is
end-to-end encryption, not a centralized decryption oracle. If this
assumption is wrong (a use case truly requires centralized vault
access), a remote-vault crate is the answer — not making the vault
remote-capable by default.
3. **The actor pattern's sequential processing is not needed.**
`VaultServiceHandle`'s `Arc<RwLock<...>>` provides concurrent reads
(derive operations) and exclusive writes (unlock/lock). The actor's
sequential processing was a constraint, not a feature — it serialized
all operations including independent reads. The RwLock is the better
concurrency model for this workload.
4. **The vault's protocol is small enough that macro-generated boilerplate
is not a maintenance burden.** With 8 operations, the
`VaultServiceHandle` method signatures *are* the protocol. There is no
need for a separate protocol enum when the handle's methods are the
API. If the vault grew to dozens of operations (unlikely given its
scope), a protocol enum could be re-introduced — but it would be a
local enum, not an irpc-generated one.
5. **`DerivedKey` never needs to cross a wire format that preserves
private key bytes.** The architectural control (ADR-014:
`DerivedKey` never appears in call protocol payloads) means
`DerivedKey` is always used in-process. The redacting `Serialize` impl
is for logging safety (defense-in-depth), not for wire transport. If a
future remote-vault crate needs to send `DerivedKey` over the wire, it
defines its own serialization for that context — the vault's
`DerivedKey` stays redact-always.
## References
- ADR-005: irpc as call protocol foundation (this ADR amends the vault
reference in ADR-005's Decision and Consequences; ~~irpc remains the
foundation for alknet-*call*, just not for alknet-*vault*~~ — **this
claim is itself superseded by [ADR-064](064-irpc-never-integrated-hand-rolled-framing.md)**,
which records that irpc was never integrated into alknet-call either;
neither the vault nor the call protocol uses irpc)
- ADR-008: Vault integration point (the vault is a capability source
accessed at assembly time — this ADR makes that the *only* mode)
- ADR-014: Secret material flow and capability injection (`DerivedKey`
never appears in call protocol payloads — the redacting `Serialize`
is defense-in-depth for logging, not for wire transport)
- ADR-018: Vault as standalone crate (this ADR strengthens the
standalone principle: zero alknet crate dependencies *and* zero RPC
framework dependencies)
- ADR-019: Vault assembly-layer-only access (this ADR makes the vault
local-only, not just assembly-layer-only-for-direct-calls)
- OQ-21: Remote vault administration (resolved by this ADR — not a vault
crate feature; if needed, a separate crate with its own ADR)
- docs/reviews/002-pre-implementation-architecture-sanity-check.md
(findings C7, C8, W8 — resolved or dissolved by this ADR)
- irpc design patterns: `docs/research/references/iroh/irpc/09-design-patterns-and-examples.md`
(Pattern 3: `no_rpc` flag — this ADR goes further by dropping irpc
entirely, since the actor pattern is also unnecessary)
@@ -0,0 +1,185 @@
# ADR-026: Vault Key Model — HD Derivation
## Status
Accepted
## Context
The vault's primary use of HD (hierarchical deterministic) derivation is
for identity keys, SSH host keys, and signing keys. ADR-020 covers HD
derivation for *encryption* keys specifically, but the broader decision —
"the vault uses HD derivation from a single BIP39 seed for all
self-generated secrets, not stored keys" — has no ADR. The rationale is
inline in `mnemonic-derivation.md`'s "Why HD Derivation" section, but the
choice is a one-way door: switching to stored keys would change the entire
trust model, the backup story, and the derivation path semantics.
Several related design choices also have inline rationale but no ADR:
- **`74'` coin type reservation** (SLIP-0044): alknet claims an unallocated
coin type for its derivation paths. Once keys are derived at `m/74'/...`,
changing the coin type would re-derive all keys — effectively one-way.
- **secp256k1 feature-gating**: the secp256k1/BIP-0032 dependency (needed
only for Ethereum signing) is feature-gated to avoid pulling a heavy C
dependency into nodes that don't do Ethereum signing.
- **AES-256-GCM cipher/mode choice**: the authenticated encryption scheme
for credential storage. The rationale (authenticated, hardware-accelerated)
is inline in `encryption.md` with no ADR.
These are foundational one-way doors that the entire vault model depends
on. They should be recorded as ADRs so a future reader sees *why* these
choices were made, not just *what* they are.
### Relationship to ADR-020
ADR-020 is a special case of this ADR — it covers HD derivation for the
*encryption key* specifically, including the v1→v2 migration from PBKDF2
to HD derivation. This ADR covers the *general* HD-derivation model that
ADR-020 builds on. ADR-020's decision (HD derivation at `m/74'/2'/0'/0'`
for encryption keys) is unchanged; this ADR records the overarching
principle.
## Decision
### 1. HD derivation from a single BIP39 seed is the vault's key model
All self-generated secrets in alknet are derived from a single BIP39
mnemonic via hierarchical deterministic (HD) derivation. The vault does
not store keys — it derives them on demand from the seed and caches them
for performance (the cache is rebuildable from the seed).
This is the same model as cryptocurrency wallets: one seed phrase, many
derived keys at deterministic paths. The properties that make this the
right model for alknet:
- **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. No key file management, no key rotation infrastructure, no
per-key backup.
- **Reproducible across nodes**: the same mnemonic on a different node
produces the same keys. A backup node derives the same identity key.
This is critical for disaster recovery — the mnemonic is the only thing
that needs to be backed up.
- **Domain separation**: different paths produce cryptographically
independent keys. The identity key, SSH host key, encryption key, and
signing keys are all 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.
### 2. SLIP-0010 (Ed25519) is the default derivation scheme
Ed25519 is alknet's default curve — it's what TLS raw key identity
(ADR-010), SSH host keys, and signing keys use. SLIP-0010 is the HD
derivation standard for Ed25519 (hardened-only, HMAC-SHA512 with
`"ed25519 seed"` as the key).
BIP-0032 (secp256k1) is supported for Ethereum signing (the standard
Ethereum path `m/44'/60'/0'/0/0` requires unhardened indices, which
SLIP-0010 cannot handle). secp256k1 is feature-gated (see Decision 4).
### 3. `74'` coin type is reserved for alknet
alknet reserves the `74'` coin type (unallocated per SLIP-0044) for its
derivation paths. All alknet paths start with `m/74'/...`:
| Path prefix | Purpose |
|-------------|---------|
| `m/74'/0'/...` | Identity keys (node, device, SSH host) |
| `m/74'/2'/...` | Encryption keys (credential storage) |
| `m/44'/60'/...` | Ethereum signing keys (secp256k1, standard BIP-44) |
Once keys are derived at `m/74'/...`, the coin type cannot be changed
without re-deriving all keys from a new path — which would produce
different keys, breaking all existing identity, TLS, SSH, and encryption
contexts. This is effectively one-way once any deployment generates keys.
### 4. secp256k1 is feature-gated
The `secp256k1` crate (BIP-0032 derivation for Ethereum) is a heavy C
dependency. Most alknet nodes do not do Ethereum signing and should not
pay the compilation cost. The `secp256k1` feature flag gates
Ethereum-specific derivation:
- Without the feature: `derive_ethereum_key` returns
`VaultServiceError::UnsupportedKeyType`.
- With the feature: full BIP-0032 secp256k1 derivation at the standard
Ethereum path.
### 5. AES-256-GCM for credential encryption
External credentials (API keys, OAuth tokens, bearer tokens) are encrypted
at rest using AES-256-GCM with a seed-derived key. AES-256-GCM is an
authenticated encryption scheme — it provides both confidentiality
(encryption) and integrity (authentication tag). A tampered ciphertext
fails decryption, which is the correct behavior for credential storage:
if an attacker modifies an encrypted API key in storage, decryption fails
rather than producing a different plaintext.
GCM is hardware-accelerated on modern CPUs (AES-NI), making it fast enough
that encryption is never a bottleneck. The 12-byte nonce (IV) is generated
with `OsRng` (CSPRNG) — IV reuse under the same key is catastrophic for
GCM.
The encryption key is derived from the seed at `m/74'/2'/0'/0'` via
SLIP-0010 — see ADR-020 for the full encryption key derivation rationale
and the v1→v2 migration from PBKDF2.
## Consequences
**Positive:**
- One seed, many keys, no key storage. The mnemonic is the only thing
that needs to be backed up. Disaster recovery is "restore the mnemonic,
re-derive everything."
- Reproducibility across nodes. A backup node with the same mnemonic
derives the same identity key, SSH host key, and encryption key. This
is critical for failover and migration.
- Domain separation via paths. The path *is* the documentation of what a
key is for. No separate key registry or metadata needed.
- Ed25519 as the default curve aligns with TLS raw key identity (RFC 7250,
ADR-010), SSH key-based auth, and iroh's NodeId model. One key type for
all identity purposes.
- secp256k1 feature-gating keeps the default dependency tree lean. Nodes
that don't do Ethereum signing don't pay the secp256k1 compilation cost.
**Negative:**
- The mnemonic is a single point of failure. If the mnemonic is lost, all
derived keys are lost. If the mnemonic is compromised, all derived keys
are compromised. Mitigated: the mnemonic is stored offline (written
down), the vault is local-only (ADR-025), and the passphrase (BIP39
password extension) adds a second factor.
- Changing the coin type (`74'`) or any path prefix is effectively
one-way once keys are derived. This is inherent to HD derivation — the
path *is* the key identity. Mitigated: the path scheme is designed to
accommodate future use cases (device index, key version) without
changing prefixes.
- Ed25519-only for the default derivation scheme means non-hardened
derivation is not available (SLIP-0010 limitation). If a future use case
needs non-hardened Ed25519 derivation (e.g., deriving public keys from a
public key without the seed), SLIP-0010 cannot do it. Mitigated: this is
not a current use case; if it becomes one, a different derivation scheme
or a non-HD approach would be needed for that specific key.
## References
- ADR-020: HD derivation for encryption keys (a special case of this ADR —
covers the encryption key at `m/74'/2'/0'/0'` and the v1→v2 migration
from PBKDF2)
- ADR-010: ALPN router and endpoint (Ed25519 as the default curve for TLS
raw key identity — the identity key at `m/74'/0'/0'/0'`)
- ADR-018: Vault as standalone crate (the vault defines its own key types
and derivation paths)
- ADR-025: Vault local-only dispatch (the vault is local-only; the seed
never crosses the network)
- [mnemonic-derivation.md](../mnemonic-derivation.md) —
BIP39, SLIP-0010, BIP-0032, derivation paths, PATHS module
- [encryption.md](../encryption.md) — AES-256-GCM,
EncryptedData, key versioning
- SLIP-0010: Universal hierarchical deterministic keys (Ed25519)
- SLIP-0044: Registered coin types for BIP-0032 / SLIP-0010 (`74'` is
unallocated)
- BIP-0032: Hierarchical deterministic wallets (secp256k1)
- BIP-39: Mnemonic code for generating deterministic keys
+296
View File
@@ -0,0 +1,296 @@
---
status: stable
last_updated: 2026-06-23
---
# Encryption
AES-256-GCM encryption and decryption for external credentials that cannot
be derived from the seed.
## What
External credentials (API keys, OAuth tokens, signing keys obtained from
third parties) cannot be derived from the BIP39 seed — they're arbitrary
bytes, not deterministic functions of the seed. The vault encrypts these
with a key *derived from* the seed, producing an `EncryptedData` blob that
can be stored outside the vault (in a config file, a database, or external
storage) and decrypted later with the same seed.
This is the second axis of the vault's secret model:
| Axis | Source | Mechanism | Example |
|------|--------|-----------|---------|
| Derived keys | Seed → HD derivation | Deterministic | Node identity, SSH host key |
| Encrypted credentials | External → AES-256-GCM | Seed-derived key | Google API key, OAuth token |
## Why AES-256-GCM
AES-256-GCM is an authenticated encryption scheme — it provides both
confidentiality (encryption) and integrity (authentication tag). A
tampered ciphertext fails decryption. This is the correct mode for
credential storage: if an attacker modifies an encrypted API key in
storage, decryption fails rather than producing a different (potentially
dangerous) plaintext.
GCM is also hardware-accelerated on modern CPUs (AES-NI), making it fast
enough that encryption is never a bottleneck.
## Key Derivation: HD, Not PBKDF2
The encryption key is derived from the BIP39 seed via SLIP-0010 HD
derivation at path `m/74'/2'/0'/0'` (`PATHS::ENCRYPTION`). This is a
deliberate choice over the PBKDF2 approach used by the TypeScript
predecessor (`@alkdev/storage/src/graphs/crypto.ts`). See ADR-020 for the
full rationale.
| Aspect | TS predecessor (PBKDF2) | Vault (HD derivation) |
|--------|--------------------------|----------------------|
| Secret input | Password (user-provided) | BIP39 seed (64 bytes) |
| Salt role | Load-bearing — part of key derivation | Unused — stored for wire-format compat |
| Derivation | PBKDF2 (100k iterations) | SLIP-0010 (a few HMACs) |
| Speed | Intentionally slow | Instant |
| Reproducible | Only with exact password | Deterministic from mnemonic |
| key_version | 1 | 2 |
Data encrypted by the TS implementation (PBKDF2, key_version=1) **cannot be
decrypted by the vault** — the keys are different even if the password
equals the mnemonic. Migration is a one-time re-encryption (see ADR-020).
## Encryption Key
The encryption key is derived from the seed at a version-indexed path
(`m/74'/2'/0'/{version-2}'` per ADR-021; v2 is `PATHS::ENCRYPTION`):
```rust
/// AES-256-GCM encryption key. Not `Clone` — move-only, like `DerivedKey`.
/// Implements a custom redacting `Debug` (never prints key bytes).
#[derive(Zeroize, ZeroizeOnDrop)]
pub struct EncryptionKey {
key_bytes: [u8; 32], // 32-byte AES-256 key
key_version: u32, // for rotation tracking
}
impl EncryptionKey {
/// Construct from raw 32 bytes. Private — for internal use.
fn new(key_bytes: [u8; 32], key_version: u32) -> Self;
/// Take the first 32 bytes of derived key material (the private key
/// bytes from SLIP-0010 derivation) and construct an `EncryptionKey`.
/// This is the bridge from `DerivedKey` (SLIP-0010 output) to
/// `EncryptionKey` (AES-256-GCM input). `VaultServiceHandle::encrypt`
/// and `decrypt` call this on the cached `DerivedKey` to obtain the
/// `EncryptionKey` for the crypto layer.
pub fn from_derived_bytes(derived: &[u8], key_version: u32) -> Self;
/// Return the key version (for rotation tracking).
pub fn version(&self) -> u32;
/// Return the key bytes (crate-internal — for `encrypt`/`decrypt`).
pub(crate) fn key_bytes(&self) -> &[u8; 32];
}
impl fmt::Debug for EncryptionKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("EncryptionKey")
.field("key_version", &self.key_version)
.field("key_bytes", &"[REDACTED]")
.finish()
}
}
```
`EncryptionKey` implements `Zeroize` and `ZeroizeOnDrop` — the key bytes
are zeroized before deallocation. It does **not** derive `Clone` (move-only,
like `DerivedKey`) and does **not** derive `Serialize` (never crosses a
wire). The `Debug` impl is custom and redacts `key_bytes`.
The key is derived once (on first encrypt/decrypt) and cached in the
`KeyCache` as a `CachedKey` wrapping a `DerivedKey` (see
[service.md](service.md)). `encrypt`/`decrypt` extract the `EncryptionKey`
from the cached `DerivedKey` via `EncryptionKey::from_derived_bytes` on each
call (the `DerivedKey` is the cached form; the `EncryptionKey` is a
short-lived per-call value derived from it).
## EncryptedData
The encrypted blob format. This is the **stable wire format** shared with
`alknet-storage` (a future crate) by type-level agreement, not by a crate
dependency. Both crates must agree on the serialization format.
A TypeScript `EncryptedDataSchema` from the `@alkdev/storage` library
predates the Rust implementation. The Rust `EncryptedData` is a superset
of the TypeScript schema. The migration path is: re-encrypt
TypeScript-encrypted data using the Rust vault with a new key version.
This cross-language compatibility is why the wire format must stay stable —
changing it breaks both `alknet-storage` and the TypeScript consumer.
```rust
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct EncryptedData {
pub key_version: u32, // rotation tracking
pub salt: String, // base64, 32 bytes — unused in v2 (wire-format compat, see ADR-020)
pub iv: String, // base64, 12 bytes — AES-GCM nonce
pub data: String, // base64 — ciphertext + auth tag
}
```
All binary fields are base64-encoded as strings for JSON serialization
compatibility. The `iv` is 12 bytes (the standard GCM nonce size). The
`data` field includes the GCM authentication tag appended to the ciphertext
(the `aes-gcm` crate handles this).
### Salt field (unused in v2 — reserved for future KDF)
The `salt` field is **unused for key derivation in v2** (HD derivation
doesn't need a salt — the derivation path provides domain separation). The
salt is generated randomly (32 bytes) and stored for wire-format
compatibility with the TypeScript `EncryptedDataSchema`, but it plays no
cryptographic role.
In the TypeScript predecessor, the salt was load-bearing — it was part of
the PBKDF2 key derivation. The vault's HD derivation doesn't use it, but the
field is kept in the wire format so the struct doesn't need to change if a
future KDF-based derivation is added.
If KDF-based key derivation is ever implemented (using HKDF or PBKDF2 with
the salt as input), it would be a new `key_version` and would not affect
existing v2 data. This is additive — see OQ-22 (key rotation) and ADR-020
(HD derivation decision).
## Encrypt and Decrypt
These are **module-internal crypto helpers** (in `encryption.rs`), not the
public API. The public API is `VaultServiceHandle::encrypt` /
`VaultServiceHandle::decrypt` (see [service.md](service.md)), which derive
the key (from the cache or via `derive_encryption_key_for_version`), extract
the `EncryptionKey` via `EncryptionKey::from_derived_bytes`, and call these
helpers.
```rust
// Module-internal (encryption.rs). Not re-exported from the crate root.
// VaultServiceHandle::encrypt/decrypt call through to these.
pub(crate) fn encrypt(plaintext: &str, key: &EncryptionKey) -> Result<EncryptedData, EncryptionError>;
pub(crate) fn decrypt(encrypted: &EncryptedData, key: &EncryptionKey) -> Result<String, EncryptionError>;
```
`encrypt`:
1. Generates a random 12-byte IV (must use `OsRng` — see Security Constraints)
2. Generates a random 32-byte salt (stored for wire-format compat, unused in key derivation)
3. Encrypts the plaintext with AES-256-GCM
4. Returns `EncryptedData { key_version, salt, iv, data }`
`decrypt`:
1. Decodes the base64 IV and ciphertext
2. Decrypts with AES-256-GCM (verifies the auth tag)
3. Returns the plaintext string
The IV is generated fresh for each encryption call. **IV reuse under the
same key is catastrophic for GCM** (authenticity breaks, two-time-pad on
plaintext). The use of `OsRng` for IV generation is a security-critical
constraint — see below.
## Key Versioning
`CURRENT_KEY_VERSION` is `2` (defined in `encryption.rs`, re-exported from
the crate root). Version `1` is reserved for the TypeScript predecessor's
PBKDF2-encrypted data (see ADR-020). Each version maps to a unique
derivation path — the last hardened index is the version offset
(see ADR-021):
```
v2: m/74'/2'/0'/0' ← PATHS::ENCRYPTION (current)
v3: m/74'/2'/0'/1'
v4: m/74'/2'/0'/2'
```
`encrypt` stamps the version onto new blobs. `decrypt` derives the key at
the path indicated by `encrypted.key_version` — each version has its own
cryptographically independent key. Old version keys remain derivable (the
seed doesn't change), so partial rotation is safe.
### Rotation
Key rotation re-encrypts a blob from one version to another. The vault
provides a `VaultServiceHandle::rotate` method (see [service.md →
rotate](service.md#rotateencrypted-to_version--encrypteddata)); the caller
(assembly layer or migration tool) handles replacing the blob in storage.
Rotation decrypts with the old version's key and re-encrypts with the new
version's key. No new mnemonic needed — the same seed produces all version
keys via different paths. See ADR-021 for the full mechanism.
## Errors
```rust
pub enum EncryptionError {
Encryption(String), // encryption failed
Decryption(String), // decryption failed (wrong key, tampered data, bad UTF-8)
Decoding(String), // base64 decoding failed
KeyVersionMismatch { expected: u32, actual: u32 }, // unused — see note below
}
```
Decryption failures are intentionally generic — they don't distinguish
"wrong key" from "tampered data" from "corrupted storage" to avoid
leaking information to an attacker.
`KeyVersionMismatch` is **defined but unused.** ADR-021 implements key
rotation via version-indexed derivation paths — `decrypt` derives the key
at the path indicated by `encrypted.key_version`, so there is no
version-mismatch to detect at the error level (every blob carries its own
version, and every version has a derivable key). This variant predates
ADR-021's rotation mechanism and is retained in the enum for source
compatibility but is not emitted by any code path in v2. An implementer
should not wire it up or expect it to fire. If a future use case requires
enforcing version constraints (e.g., "refuse to decrypt blobs older than
v3"), this variant could be repurposed — but that would be a new decision,
not part of ADR-021's rotation scheme.
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| AES-256-GCM for credential encryption | [ADR-026](decisions/026-vault-key-model-hd-derivation.md) | Authenticated encryption, hardware-accelerated |
| HD derivation, not PBKDF2 | [ADR-020](decisions/020-hd-derivation-for-encryption-keys.md) | Seed-derived key; no password; deterministic |
| Salt unused in v2 (wire-format compat) | [ADR-020](decisions/020-hd-derivation-for-encryption-keys.md) | Kept for TS compat; not used in key derivation |
| Key derived at `m/74'/2'/0'/0'` | [ADR-026](decisions/026-vault-key-model-hd-derivation.md) | Dedicated account for encryption keys |
| Version-indexed paths for rotation | [ADR-021](decisions/021-key-rotation-via-version-indexed-paths.md) | `m/74'/2'/0'/{version-2}'` |
| Key versioning (v1=TS PBKDF2, v2=vault HD) | [ADR-020](decisions/020-hd-derivation-for-encryption-keys.md) | Distinguishes derivation methods |
| All fields base64-encoded | — | JSON serialization compatibility |
| `EncryptedData` wire format frozen | [ADR-018](decisions/018-vault-standalone-crate.md) | Fields, encoding, semantics locked; no removal without migration |
## Open Questions
See [open-questions.md](open-questions.md) for full details.
- **OQ-20** (resolved by ADR-020): Salt/KDF — HD derivation is the method;
the salt field is unused in v2 (wire-format compatibility only).
- **OQ-22** (resolved by ADR-021): Key rotation — version-indexed paths;
`rotate` method decrypts old, re-encrypts new.
## Security Constraints
These are security-critical implementation requirements.
- **OsRng for IVs**: The IV must be generated with `OsRng` (or an
equivalent CSPRNG), never `rand::random()`. IV reuse under the same key
is catastrophic for GCM — it breaks authenticity and creates a
two-time-pad on the plaintext. `rand::random()` uses the thread-local RNG
which may not be a CSPRNG on all platforms; `OsRng` reads from the
operating system's entropy source and is the correct choice for
cryptographic nonces.
- **Zeroized drop**: `EncryptionKey` derives `Zeroize` and
`ZeroizeOnDrop`. The key bytes are zeroized before deallocation. Do not
store key material in types that don't zeroize.
- **No plaintext in logs**: `EncryptedData` is safe to log (it's
ciphertext). The plaintext and the `EncryptionKey` are not. Do not add
`Debug` or `Display` implementations that print key bytes or plaintext.
## References
- [NIST SP 800-38D](https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-38d.pdf) —
AES-GCM specification
- Implementation: `crates/alknet-vault/src/encryption.rs`
- Tests: `crates/alknet-vault/tests/test_vectors.rs`,
`crates/alknet-vault/src/encryption.rs` (unit tests)
- [service.md](service.md) — how the vault caches the encryption key
+322
View File
@@ -0,0 +1,322 @@
---
status: stable
last_updated: 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.
```rust
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
```rust
#[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"`.
```rust
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
```rust
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
```rust
#[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.
```rust
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.
```rust
#[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.
```rust
#[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:
```rust
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:
```rust
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](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](protocol.md)) and
`CachedKey` (see [service.md](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](decisions/018-vault-standalone-crate.md) | Zero alknet crate dependencies |
| HD derivation (not stored keys) | [ADR-026](decisions/026-vault-key-model-hd-derivation.md) | One seed, many keys, no key storage; reproducible across nodes |
| `74'` coin type reserved for alknet | [ADR-026](decisions/026-vault-key-model-hd-derivation.md) | SLIP-0044 unallocated; alknet namespace |
| secp256k1 feature-gated | [ADR-026](decisions/026-vault-key-model-hd-derivation.md) | Heavy dep; only needed for Ethereum |
| Hardened-only for Ed25519 | SLIP-0010 | Ed25519 cannot do public derivation |
| Vault is local-only | [ADR-025](decisions/025-vault-local-only-dispatch.md) | Direct method calls, no irpc, no remote dispatch |
## Open Questions
See [open-questions.md](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](encryption.md).
## References
- [BIP39](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki) —
mnemonic seed phrases
- [SLIP-0010](https://github.com/satoshilabs/slips/blob/master/slip-0010.md) —
Ed25519 HD derivation
- [BIP-0032](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki) —
secp256k1 HD derivation
- [SLIP-0044](https://github.com/satoshilabs/slips/blob/master/slip-0044.md) —
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`
+70
View File
@@ -0,0 +1,70 @@
---
status: draft
last_updated: 2026-08-02
---
# Open Questions
Each open question lives in its own file under [`questions/`](questions/),
named `NNN-slug.md` (mirroring the ADR convention). This file is the index:
theme-grouped tables for scannability, plus a cross-theme
[Deferred / Blocked](#deferred--blocked) section that surfaces the
safe-exit deferrals with their blocking conditions inline — so "what's
currently parked and why" is answerable at a glance.
**Status values**:
- `open` — Needs to be resolved now. Has a clear path to resolution.
- `resolved` — Decided. The resolution is stated cleanly, without caveats about how it could be changed later.
- `deferred(scope)` — Cannot be resolved yet. The information is genuinely
missing — a crate spec, POC result, or use case that doesn't exist yet.
Has a concrete blocking condition. Not a failure — scope management.
- `deferred(unclear)` — Cannot be resolved yet. The pieces exist (decided
in other ADRs, existing types, existing patterns) but the composition
— how they fit together — isn't clear yet. Resolution requires
investigation (work through examples, maybe POC), not waiting. Has a
concrete investigation target and an impacts field. Not a failure —
honest uncertainty in a poorly-defined problem space.
- `partially resolved` — Some aspects decided, others deferred or open.
- `dissolved` — The question was reframed out of existence (e.g., superseded
by an ADR that retires the premise). Kept for reference.
**Impacts field**: Every unresolved OQ (`open`, `deferred(scope)`,
`deferred(unclear)`, `partially resolved`) should have an `Impacts`
field stating what it blocks downstream. Be specific: "blocks the first
hub deployment because the hub dials workers" not "blocks the hub
crate." This is the triage signal that makes the deferral's urgency
visible.
Door type classifications describe **reversal cost** (how expensive it is to undo), not urgency:
- **One-way door**: Reversal requires rewriting significant code or permanently closes a capability. Getting it wrong is expensive — requires ADR before implementation.
- **Two-way door**: Reversal is cheap or additive. Getting it wrong is recoverable — decide, implement, revert if needed.
Door type is separate from whether a decision is made. A two-way door is a decision you make now and can revert later, not a decision to defer.
> **Note on numbering**: ADR and OQ numbers are preserved from the
> originating `alknet` repo. A subsequent pass will renumber them to a
> per-project sequence (001, 002, …) and update cross-references in the
> spec docs. Until then, the numbers reflect their alknet origin.
## By Theme
### alknet-vault
All vault open questions are **resolved** — the vault is a stable crate
with implementation complete and verified.
| OQ | Title | Status | Door | Pri |
|----|-------|--------|------|-----|
| [OQ-20](questions/020-salt-kdf-and-encryption-key-derivation-method.md) | Salt/KDF and Encryption Key Derivation Method | resolved | one/two | high |
| [OQ-21](questions/021-remote-vault-administration.md) | Remote Vault Administration | resolved | one | med |
| [OQ-22](questions/022-key-rotation-mechanism.md) | Key Rotation Mechanism | resolved | one/two | med |
## Deferred / Blocked
The safe-exit visibility surface. These questions are parked because the
information needed to resolve them does not exist yet — each has a concrete
blocking condition. They are not failures; they are scope management.
This section exists so "what's currently blocking the architect" is
answerable at a glance, not by filtering the tables above.
None — all vault open questions are resolved.
+243
View File
@@ -0,0 +1,243 @@
---
status: stable
last_updated: 2026-06-23
---
# Protocol
The `DerivedKey` type, `KeyType` enum, and serialization behavior. The
vault's "protocol" is the `VaultServiceHandle` method API (ADR-025) — there
is no message enum, no irpc dispatch, and no wire format.
## What
The vault's dispatch is direct method calls on `VaultServiceHandle`
(ADR-025). The types defined here — `DerivedKey`, `KeyType` — are the
return types from those methods. There is no `VaultProtocol` enum, no
`VaultMessage`, no `VaultServiceActor`, and no remote dispatch capability.
The vault is **local-only by construction**. If remote vault access is ever
needed, it requires a separate crate that wraps the vault and adds remote
transport + auth (ADR-025, OQ-021).
## DerivedKey
The result of key derivation. Holds the key type, private key, and public
key.
```rust
#[derive(Zeroize)]
#[zeroize(drop)]
pub struct DerivedKey {
#[zeroize(skip)]
pub key_type: KeyType, // not secret — tag only
#[zeroize]
pub private_key: Vec<u8>, // zeroized on drop
#[zeroize(skip)]
pub public_key: Vec<u8>, // not secret — public by definition
}
```
`DerivedKey` does **not** derive `Deserialize` via `#[derive]`. It has a **custom
`Deserialize` impl** that rejects redacted payloads — see
[Serialization Redaction](#serialization-redaction) below. (A derived
`Deserialize` would generate a default impl that conflicts with the manual one,
and would not produce the explicit redaction-rejection error the spec requires.)
The `#[zeroize(skip)]` attributes on `key_type` and `public_key` mean only
the `private_key` is zeroized when the `DerivedKey` is dropped. The public
key and key type are not secret material — zeroizing them is unnecessary
and would require them to derive `Zeroize` (which `KeyType` does not).
### Move-only, not Clone
`DerivedKey` does **not** derive `Clone`. It is move-only. Consumers
receive it by value and zeroize it when done (handled automatically by
`#[zeroize(drop)]`). This prevents accidental duplication of secret
material — there is exactly one copy of the private key, and it is
zeroized when the `DerivedKey` is dropped.
The assembly layer (CLI binary) extracts the bytes it needs (private key
for signing, public key for TLS identity) and constructs the alknet-core
types at the assembly boundary (ADR-018). The `DerivedKey` is then dropped
and zeroized.
### Serialization redaction
`DerivedKey` has a custom `Serialize` impl that **always** redacts the
private key, regardless of format:
- **JSON** (and all human-readable formats): `private_key` serializes as
`"[REDACTED]"`. This is defense-in-depth — if a `DerivedKey` accidentally
ends up in a log, a JSON config, or debug output, the private key is not
exposed.
- **Deserialization**: a custom `Deserialize` impl rejects
`private_key == "[REDACTED]"` with a deserialization error (not a corrupted
key). This resolves review #002 W8 (silent corruption on JSON-deserialized
`DerivedKey`). The custom impl is required because `#[derive(Deserialize)]`
would generate a default impl that conflicts and would only fail incidentally
(serde type mismatch: string vs sequence), not with the explicit
redaction-rejection error the spec requires.
- **No binary-format preservation path.** ADR-025 dropped the postcard/remote
dispatch path that previously preserved private key bytes in binary
formats. `DerivedKey` is always used in-process (ADR-014: never appears
in call protocol payloads). If a future remote-vault crate needs to send
`DerivedKey` over the wire, it defines its own serialization for that
context — the vault's `DerivedKey` stays redact-always.
```rust
// Custom Serialize — always redacts private_key
impl serde::Serialize for DerivedKey {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where S: serde::Serializer {
use serde::SerializeStruct;
let mut s = serializer.serialize_struct("DerivedKey", 3)?;
s.serialize_field("key_type", &self.key_type)?;
s.serialize_field("private_key", "[REDACTED]")?; // never the real bytes
s.serialize_field("public_key", &self.public_key)?;
s.end()
}
}
// Custom Deserialize — rejects "[REDACTED]" with an error
impl<'de> serde::Deserialize<'de> for DerivedKey {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where D: serde::Deserializer<'de> {
#[derive(serde::Deserialize)]
struct DerivedKeyHelper {
key_type: KeyType,
private_key: Vec<u8>,
public_key: Vec<u8>,
}
let helper = DerivedKeyHelper::deserialize(deserializer)?;
// Reject redacted payloads — a JSON-deserialized DerivedKey with a
// redacted private key is invalid, not a corrupted key.
if helper.private_key == b"[REDACTED]" {
return Err(serde::de::Error::custom(
"DerivedKey.private_key is \"[REDACTED]\" — redacted payloads \
cannot be deserialized. JSON round-tripping a DerivedKey is \
not supported (the private key is gone)."
));
}
Ok(DerivedKey {
key_type: helper.key_type,
private_key: helper.private_key,
public_key: helper.public_key,
})
}
}
```
The redaction is **not the primary control** for keeping private keys off
the wire. The primary control is architectural: `DerivedKey` never appears
in call protocol payloads (ADR-014). The redaction is a safety net for
logging accidents and debug output.
### Debug redaction
`DerivedKey`'s `Debug` impl also redacts the private key:
```rust
impl fmt::Debug for DerivedKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DerivedKey")
.field("key_type", &self.key_type)
.field("private_key", &"[REDACTED]")
.field("public_key", &self.public_key)
.finish()
}
}
```
`{:?}` on a `DerivedKey` never exposes the private key. This makes it safe
to use in `tracing` spans and error messages.
## KeyType
```rust
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum KeyType {
Ed25519, // SLIP-0010 derivation (32-byte private + 32-byte public)
Aes256Gcm, // Symmetric key (32 bytes, used for encryption)
Secp256k1, // BIP-0032 derivation (32-byte private + 33-byte compressed public)
}
```
Tags `DerivedKey` and `CachedKey` so consumers know what they received.
`KeyType` is `Serialize`/`Deserialize` (retained for `EncryptedData` interop
and future use — ADR-025 removed the irpc dispatch path that previously
justified these derives, but the type remains serializable for structured
storage scenarios) and `Clone` (it's not secret material — it's a tag).
## Wire Format
The vault has no wire format (ADR-025). Dispatch is direct method calls on
`VaultServiceHandle` — no serialization, no channels, no network. The
`DerivedKey` custom `Serialize`/`Deserialize` impls exist solely for
logging safety (redaction) and defense-in-depth, not for wire transport.
`EncryptedData` has a stable wire format (shared with `alknet-storage` and
the TypeScript consumer by type-level agreement — see
[encryption.md](encryption.md) and ADR-018). That format is for *stored
encrypted data*, not for vault dispatch — the vault's `encrypt`/`decrypt`
methods operate on `EncryptedData` as a value type, not as a wire message.
## Local-Only by Construction
The vault is **local-only by construction** (ADR-025). There is no
`RemoteService` trait, no remote handler, no wire format for vault
messages. The vault's API is `VaultServiceHandle` — direct method calls,
nothing else.
If remote vault access is ever needed (e.g., the machine→worker pattern
where a long-lived node exposes a restricted vault API to ephemeral
workers), it requires a **separate vault-server crate** that:
1. Depends on both alknet-core (for `IdentityProvider`, scopes,
auth-wrapping) and alknet-vault (for `VaultServiceHandle`).
2. Defines its own threat model, access policy, and operation filtering
(`Unlock`/`Lock` must be local-only; other operations may be
remote-capable depending on the policy).
3. Adds the remote transport (iroh/QUIC or similar) and an auth-wrapping
handler that checks caller identity before forwarding to the vault.
4. Requires its own ADR (matching ADR-019's language: "requires its own
ADR") defining the threat model and access policy.
This is a deliberate addition, not a flag flip on a default that was
already loaded. The pre-ADR-025 design made the vault remote-capable *by
construction* (irpc generated `RemoteService` by default), which was the
default-insecure anti-pattern. ADR-025 inverts the default: local-only is
the only mode, and remote access requires building something new.
**Per-node vaults are the recommended pattern for multi-node deployments.**
Each node has its own vault and mnemonic. Credentials are encrypted *for*
the receiving node's public key or derived at a shared path the receiving
node can derive locally. This is end-to-end encryption between nodes, not
a centralized decryption oracle. It matches ADR-008's "capability source"
model — credentials are injected at the assembly layer, not fetched over
the network at call time.
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| Vault is standalone | [ADR-018](decisions/018-vault-standalone-crate.md) | Zero alknet crate dependencies |
| Vault is local-only | [ADR-025](decisions/025-vault-local-only-dispatch.md) | Direct method calls, no irpc, no remote dispatch capability |
| HD derivation (not stored keys) | — | One seed, many keys, no key storage |
| `DerivedKey` is move-only | [ADR-014](decisions/014-secret-material-flow-and-capability-injection.md) | Prevents accidental duplication of secret material |
| JSON redacts private key (always) | [ADR-014](decisions/014-secret-material-flow-and-capability-injection.md) | Defense-in-depth for logging accidents |
| No vault operations on call protocol | [ADR-008](decisions/008-secret-service-integration.md), [ADR-014](decisions/014-secret-material-flow-and-capability-injection.md) | Master seed never crosses the network |
| No remote dispatch in vault crate | [ADR-025](decisions/025-vault-local-only-dispatch.md) | Remote access requires a separate vault-server crate with its own ADR |
## Open Questions
None active for this document. OQ-21 (remote vault) is resolved — see
ADR-025 and [open-questions.md](open-questions.md).
## References
- Implementation: `crates/alknet-vault/src/protocol.rs`
- Tests: `crates/alknet-vault/src/protocol.rs` (unit tests for redaction
and zeroize behavior)
- [service.md](service.md) — `VaultServiceHandle` runtime API
- [mnemonic-derivation.md](mnemonic-derivation.md) — what `KeyType` means
@@ -0,0 +1,8 @@
# OQ-20: Salt/KDF and Encryption Key Derivation Method
- **Origin**: [encryption.md](../encryption.md)
- **Status**: resolved
- **Door type**: One-way (key derivation method), two-way (salt field usage)
- **Priority**: high
- **Resolution**: The vault uses SLIP-0010 HD derivation from the BIP39 seed at path `m/74'/2'/0'/0'` to produce the AES-256-GCM encryption key — not PBKDF2. The `salt` field in `EncryptedData` is unused for key derivation (kept for wire-format compatibility with the TS predecessor). The TypeScript `@alkdev/storage` crypto module used PBKDF2 with a password + salt; data encrypted by that method (key_version=1) cannot be decrypted by the vault and must be migrated via one-time re-encryption to key_version=2. See ADR-020 for the full rationale and migration path.
- **Cross-references**: ADR-020, [encryption.md](../encryption.md)
@@ -0,0 +1,14 @@
# OQ-21: Remote Vault Administration
- **Origin**: [service.md](../service.md), [protocol.md](../protocol.md), ADR-019
- **Status**: resolved
- **Door type**: One-way (vault crate is local-only by construction)
- **Priority**: medium
- **Resolution**: Remote vault access is **not a feature of the vault crate**. ADR-025 dropped irpc from the vault, making the vault local-only by construction — no `RemoteService` trait, no wire format for vault messages, no default-insecure remote handler. The vault's API is `VaultServiceHandle` (direct method calls), nothing else.
If remote vault access is ever needed (e.g., the machine→worker pattern), it requires a **separate vault-server crate** that depends on both alknet-core (for `IdentityProvider`, scopes, auth-wrapping) and alknet-vault (for `VaultServiceHandle`). That crate would define its own threat model, access policy, operation filtering (Unlock/Lock local-only), and wire format — and requires its own ADR. This is a deliberate addition, not a flag flip on a default that was already loaded.
The pre-ADR-025 deferral framed remote access as "non-breaking" (the wire format was additive). That framing was misleading: once workers build dependencies on the remote vault API, disabling it breaks them — the door is operationally one-way even if the wire format is additive. ADR-025 inverts the default: the vault is local-only by construction, and remote access requires building something new, not removing a default.
Per-node vaults are the recommended pattern for multi-node deployments: each node has its own vault and mnemonic; credentials are encrypted *for* the receiving node's public key, not decrypted centrally. This is end-to-end encryption between nodes, matching ADR-008's "capability source" model.
- **Cross-references**: ADR-005, ADR-008, ADR-014, ADR-018, ADR-019, ADR-025, [protocol.md](../protocol.md), [service.md](../service.md)
@@ -0,0 +1,8 @@
# OQ-22: Key Rotation Mechanism
- **Origin**: [encryption.md](../encryption.md)
- **Status**: resolved
- **Door type**: One-way (path scheme), two-way (rotation policy)
- **Priority**: medium
- **Resolution**: Key rotation uses version-indexed derivation paths. Each key version maps to a distinct SLIP-0010 path: `m/74'/2'/0'/{version-2}'`. v2 (current) is at `m/74'/2'/0'/0'`; v3 is at `m/74'/2'/0'/1'`; etc. The `decrypt` method derives the key at the path indicated by `encrypted.key_version` (not always at `PATHS::ENCRYPTION`). The `rotate` method decrypts with the old version's key and re-encrypts with the new version's key — no new mnemonic needed. The assembly layer or a migration tool iterates stored blobs and calls `rotate` on each; the vault does not self-rotate. Partial rotation is safe (old keys remain derivable). See ADR-021.
- **Cross-references**: ADR-020, ADR-021, [encryption.md](../encryption.md), [service.md](../service.md)
+385
View File
@@ -0,0 +1,385 @@
---
status: stable
last_updated: 2026-06-23
---
# Service
The `VaultServiceHandle` runtime API: unlock/lock lifecycle, key
derivation, encryption, caching, and the direct method-call dispatch
path.
## What
The service layer wraps the vault's cryptographic primitives in a
stateful runtime with a clear lifecycle. It holds the master seed in
`Zeroize`-protected memory and provides methods for the unlock/lock
lifecycle, key derivation, and encryption/decryption.
This is the API the assembly layer (CLI binary) calls. No other component
calls these methods directly (ADR-019). The vault is local-only by
construction (ADR-025) — direct method calls, no actor, no message enum,
no remote dispatch.
## VaultServiceHandle
The primary API for local (in-process) use. Thread-safe via
`std::sync::RwLock` — all methods are **synchronous** (no `async`, no
`.await`). The RwLock provides concurrent reads (derive operations) and
exclusive writes (unlock/lock). `tokio` is not a dependency of the vault
(ADR-025); `std::sync::RwLock` is sufficient because no method holds the
lock across an await point.
```rust
#[derive(Clone)]
pub struct VaultServiceHandle {
inner: Arc<std::sync::RwLock<VaultServiceInner>>,
}
struct VaultServiceInner {
mnemonic: Option<Mnemonic>, // None if locked
seed: Option<Seed>, // None if locked
unlocked: bool,
cache: KeyCache, // TTL + LRU, see Cache section
}
```
**Invariant**: `unlocked` is `true` iff `seed.is_some()`. The `unlocked`
flag exists for cheap read-only checks (`is_unlocked`); the ground truth is
`seed.is_some()`. `lock()` sets `unlocked = false` and clears `seed`/`mnemonic`
to `None`; `unlock`/`unlock_new` set `unlocked = true` and populate `seed`.
`VaultServiceHandle` is `Clone` — cloning shares the underlying state via
`Arc`. This is how the actor and the assembly layer share the same vault.
## Lifecycle
```
Locked (initial state)
│
│ unlock(phrase, passphrase) / unlock_new(word_count)
▼
Unlocked — derive, encrypt, decrypt available
│
│ lock()
▼
Locked — seed and cache purged
```
### unlock(phrase, passphrase)
```rust
pub fn unlock(&self, phrase: &str, passphrase: Option<&str>) -> Result<(), VaultServiceError>;
```
Unlock with an existing mnemonic phrase. Validates the phrase against the
BIP39 word list, derives the seed, and stores both in `VaultServiceInner`.
Returns `AlreadyUnlocked` if the vault is already unlocked.
The passphrase is the BIP39 password extension (the "25th word"). `None`
means no passphrase (equivalent to empty string). Different passphrases
produce different seeds.
### unlock_new(word_count) → phrase
```rust
pub fn unlock_new(&self, word_count: usize) -> Result<Zeroizing<String>, VaultServiceError>;
```
Generate a new random mnemonic, unlock with it, and return the phrase as
a `Zeroizing<String>`. The returned phrase is the root of trust — it is
heap-allocated and zeroized on drop, so it does not linger in freed
memory. The caller should extract the phrase for secure storage (write
down, display to user) and let the `Zeroizing<String>` drop when done.
Do not clone the returned value or store it in a non-zeroizing container.
Supported word counts: 12, 15, 18, 21, 24.
Returns `VaultServiceError::AlreadyUnlocked` if the vault is already
unlocked (matching `unlock`'s behavior — `unlock_new` is a "first run"
operation and should not silently replace an existing mnemonic).
This is the "first run" path — a new node generates its mnemonic, writes
it down, and the vault is unlocked for the process lifetime. The
`Zeroizing<String>` wrapper (from the `zeroize` crate) ensures the
mnemonic is wiped from memory once the caller is done with it, matching
the `Mnemonic` type's own `ZeroizeOnDrop` behavior. This resolves review
#002 W7.
### lock()
```rust
pub fn lock(&self);
```
Purge the seed, mnemonic, and all cached derived keys. Calls `zeroize()`
on all sensitive material. After locking, no derive/encrypt/decrypt
operations are possible until `unlock` is called again.
`lock()` on an already-locked service is a no-op (not an error).
### is_unlocked()
```rust
pub fn is_unlocked(&self) -> bool;
```
Check whether the vault is currently unlocked. Cheap (read lock only).
## Derive Methods
All derive methods require an unlocked vault and return
`VaultServiceError::VaultLocked` if called while locked.
### derive_ed25519(path) → DerivedKey
```rust
pub fn derive_ed25519(&self, path: &str) -> Result<DerivedKey, VaultServiceError>;
```
Derive an Ed25519 keypair at the given SLIP-0010 path. Checks the cache
first; on a miss, derives from the seed and caches the result. Returns a
`DerivedKey` with `KeyType::Ed25519`.
### derive_encryption_key(path) → DerivedKey
```rust
pub fn derive_encryption_key(&self, path: &str) -> Result<DerivedKey, VaultServiceError>;
```
Derive an AES-256-GCM encryption key at the given path. Same cache
behavior as `derive_ed25519`. Returns a `DerivedKey` with
`KeyType::Aes256Gcm`.
### derive_encryption_key_for_version(version) → DerivedKey
```rust
pub fn derive_encryption_key_for_version(&self, version: u32) -> Result<DerivedKey, VaultServiceError>;
```
Derive the encryption key for a specific key version. Maps the version to
its derivation path via `encryption_path_for_version(version)` (ADR-021):
v2 → `m/74'/2'/0'/0'`, v3 → `m/74'/2'/0'/1'`, etc. Cached by path. This is
the version-aware method that `encrypt` and `decrypt` use to select the
correct key for each blob — see [encryption.md](encryption.md) and ADR-021.
Returns `VaultServiceError::InvalidPath` for `version < 2` (v1 is TS PBKDF2
legacy — the vault cannot derive it; v0 is meaningless).
`derive_encryption_key(path)` (above) remains as the path-based API for
deriving at arbitrary paths. `derive_encryption_key_for_version(version)`
is the version-aware API used by `encrypt` and `decrypt`. Both return
`DerivedKey` with `KeyType::Aes256Gcm` and share the same cache (keyed by
derivation path). `encrypt` and `decrypt` extract the `EncryptionKey` from
the `DerivedKey` via `EncryptionKey::from_derived_bytes` (see
[encryption.md](encryption.md#encryption-key)).
### derive_ethereum_key(path) → DerivedKey (feature-gated)
```rust
pub fn derive_ethereum_key(&self, path: &str) -> Result<DerivedKey, VaultServiceError>;
```
Derive a secp256k1 keypair at the given BIP-0032 path. Returns
`UnsupportedKeyType` when the `secp256k1` feature is disabled. Returns a
`DerivedKey` with `KeyType::Secp256k1` (33-byte compressed public key).
## Encrypt and Decrypt
### encrypt(plaintext, key_version) → EncryptedData
```rust
pub fn encrypt(&self, plaintext: &str, key_version: u32) -> Result<EncryptedData, VaultServiceError>;
```
Encrypt plaintext using the encryption key derived at
`encryption_path_for_version(key_version)` (ADR-021). The same `key_version`
is stamped on the resulting `EncryptedData`. Derives (and caches) the
encryption key on first call, then uses the cache for subsequent calls. See
[encryption.md](encryption.md) for the cryptographic details.
### decrypt(encrypted) → String
```rust
pub fn decrypt(&self, encrypted: &EncryptedData) -> Result<String, VaultServiceError>;
```
Decrypt an `EncryptedData` blob. Derives (and caches) the encryption key
at the version-indexed path indicated by `encrypted.key_version` via
`derive_encryption_key_for_version` (ADR-021). Each version maps to a
distinct path (`m/74'/2'/0'/{version-2}'`), so old and new keys can
coexist during partial rotation. See [encryption.md](encryption.md).
### rotate(encrypted, to_version) → EncryptedData
```rust
pub fn rotate(&self, encrypted: &EncryptedData, to_version: u32) -> Result<EncryptedData, VaultServiceError>;
```
Re-encrypt an `EncryptedData` blob from its current key version to a new
version. Decrypts with the old version's key, re-encrypts with the new
version's key. Returns the new `EncryptedData` — the caller replaces the
blob in storage. No new mnemonic needed; the same seed produces all
version keys via different derivation paths (ADR-021).
This is the rotation primitive. The assembly layer or a migration tool
iterates stored blobs and calls `rotate` on each. The vault does not
self-rotate — rotation is an operational action.
## Cache
Derived keys are cached for performance — HD derivation involves HMAC
operations that are not free. The cache is keyed by derivation path and
has TTL-based expiry and LRU eviction.
```rust
pub struct KeyCache {
entries: HashMap<String, CachedKey>,
order: Vec<String>, // LRU ordering
config: CacheConfig,
}
/// A cached derived key. Wraps a `DerivedKey` with cache metadata.
/// Derives `Zeroize` and `ZeroizeOnDrop` — the private key is zeroized
/// when the entry is evicted (LRU/TTL) or the cache is cleared.
pub struct CachedKey {
key: DerivedKey, // the derived key (zeroized on drop)
cached_at: Instant, // when the entry was inserted (for TTL)
last_accessed: Instant, // for LRU ordering
}
pub struct CacheConfig {
pub ttl: Duration, // default: 1 hour
pub max_entries: usize, // default: 64
}
```
- **TTL**: entries expire after `ttl` (default 1 hour). Expired entries are
evicted lazily on access (`get` checks expiry) or via `evict_expired()`.
- **LRU**: when the cache exceeds `max_entries` (default 64), the least
recently used entry is evicted. Access (`get`) updates the LRU order.
- **Zeroized**: `CachedKey` derives `Zeroize` and `ZeroizeOnDrop` (via the
`DerivedKey` it holds, which is `#[zeroize(drop)]`). Evicted and cleared
entries are zeroized — derived private keys do not linger in freed heap
memory.
- **Cleared on lock**: `lock()` calls `cache.clear()`, which removes and
zeroizes all entries.
### What is and isn't cached
| Operation | Cached? | Why |
|-----------|---------|-----|
| `derive_ed25519` | Yes | Derivation is expensive; keys are reused |
| `derive_encryption_key` | Yes | Same — encryption key reused across calls |
| `derive_ethereum_key` | Yes | Same |
| `encrypt` / `decrypt` | Key cached | The encryption `DerivedKey` (at `encryption_path_for_version(key_version)`) is cached; the plaintext is not |
## Dispatch
The vault uses **direct method calls** on `VaultServiceHandle` — no actor,
no message enum, no channels, no serialization (ADR-025). The handle is
`Arc<std::sync::RwLock<VaultServiceInner>>` — clone it, share it, call
methods directly. The `std::sync::RwLock` provides concurrent reads (derive
operations) and exclusive writes (unlock/lock). All methods are synchronous
(no `async`), so `std::sync::RwLock` is correct — a `tokio::sync::RwLock`
would require async methods or risk blocking a tokio runtime when held
across an await point. The vault does not depend on `tokio` (ADR-025).
```
Assembly layer (CLI binary):
1. Create VaultServiceHandle
2. Unlock with mnemonic (local, from secure prompt or file)
3. Call derive/encrypt/decrypt methods directly
4. Extract bytes, construct alknet-core types at the assembly boundary
5. Inject into handler capabilities (ADR-014)
```
There is no `VaultProtocol` enum, no `VaultServiceActor`, no `Client<S>`,
and no remote dispatch capability. The vault is local-only by
construction (ADR-025). If remote vault access is ever needed, it requires
a separate vault-server crate with its own ADR (OQ-021, ADR-025).
The pre-ADR-025 design had an actor path (mpsc channel + oneshot
backchannels, using irpc's `Service` trait) that was described as
"secondary" to direct calls. ADR-025 removed it — the actor existed only
to make irpc's dispatch work, and the direct path was always preferred.
The RwLock-based concurrency model is both simpler and better for
throughput (concurrent reads vs. sequential processing).
## Errors
```rust
#[derive(Debug, thiserror::Error)]
pub enum VaultServiceError {
VaultLocked, // called derive/encrypt/decrypt while locked
AlreadyUnlocked, // called unlock while already unlocked
Mnemonic(String), // mnemonic generation/validation failed
Derivation(String), // HD derivation failed (bad path, HMAC error)
Encryption(String), // AES-GCM encrypt/decrypt failed
InvalidPath(String), // derivation path is malformed
UnsupportedKeyType, // secp256k1 called without the feature
}
```
`VaultServiceError` is a plain `thiserror::Error` enum (ADR-025 dropped
the `Serialize`/`Deserialize` derives that were needed for irpc dispatch).
It wraps sub-errors as strings. The CLI binary converts vault errors to
alknet-core error types at the assembly boundary (ADR-018).
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| Assembly layer is the sole caller | [ADR-019](decisions/019-vault-assembly-layer-only.md) | Handlers never hold a vault reference |
| Encryption key via HD derivation | [ADR-020](decisions/020-hd-derivation-for-encryption-keys.md) | Seed-derived key at `m/74'/2'/0'/0'`, not PBKDF2 |
| Version-indexed paths for rotation | [ADR-021](decisions/021-key-rotation-via-version-indexed-paths.md) | `decrypt` selects key by version; `rotate` re-encrypts |
| RwLock for thread safety | — | Multiple readers (derive), exclusive writer (unlock/lock) |
| TTL + LRU cache | — | Bounded memory, fresh keys, zeroized eviction |
| Direct method calls (no actor) | [ADR-025](decisions/025-vault-local-only-dispatch.md) | No irpc, no message enum, no remote dispatch capability |
| `derive_password` removed | [ADR-025](decisions/025-vault-local-only-dispatch.md) | Password-manager pattern not relevant to RPC system's vault; resolves C9 |
## Open Questions
See [open-questions.md](open-questions.md) for full details.
- **OQ-21** (resolved by ADR-025): Remote vault access is not a feature
of the vault crate. The vault is local-only by construction — direct
method calls on `VaultServiceHandle`, no remote dispatch capability.
If remote access is ever needed, it requires a separate vault-server
crate with its own ADR. See [protocol.md → Local-Only by
Construction](protocol.md#local-only-by-construction).
## Security Constraints
These are security-critical implementation requirements, not
architectural decisions. They are documented here so implementation agents
don't miss them.
- **OsRng for IVs**: AES-GCM IVs and any cryptographic nonces must use
`OsRng` (or equivalent CSPRNG), not `rand::random()`. IV reuse under the
same key is catastrophic for GCM (authenticity breaks, two-time-pad on
plaintext).
- **Zeroized drop**: `Seed`, `Mnemonic`, `CachedKey`, `EncryptionKey`,
`ExtendedPrivKey`, `Secp256k1ExtendedPrivKey`, and `DerivedKey` all
derive `Zeroize` and `ZeroizeOnDrop`. The cache must clear on drop, not
just on explicit `lock()`.
- **No `unwrap()` or `expect()` outside tests**: poisoned lock recovery
uses `unwrap_or_else(|e| e.into_inner())` or explicit error propagation.
A panic in one vault operation must not brick the vault for all other
operations. A poisoned lock should be recovered with
`unwrap_or_else(|e| e.into_inner())`, not panicked.
- **`DerivedKey` is move-only, not `Clone`**: `DerivedKey` does not derive
`Clone`. It is move-only — consumers receive it by value and zeroize it
when done (handled by `#[zeroize(drop)]`). This prevents accidental
duplication of secret material.
- **Cache eviction zeroizes**: when the cache evicts an entry (LRU or
TTL), the `CachedKey` is dropped, which triggers `ZeroizeOnDrop`. Do not
replace `CachedKey` with a type that doesn't zeroize.
## References
- Implementation: `crates/alknet-vault/src/service.rs`,
`crates/alknet-vault/src/cache.rs`
- Tests: `crates/alknet-vault/tests/service_tests.rs`,
`crates/alknet-vault/src/service.rs` (unit tests),
`crates/alknet-vault/src/cache.rs` (unit tests)
- [protocol.md](protocol.md) — `DerivedKey` and `KeyType`
- [encryption.md](encryption.md) — `encrypt` / `decrypt` cryptographic details
+497
View File
@@ -0,0 +1,497 @@
//! TTL-based key cache with LRU eviction for VaultService.
//!
//! The `KeyCache` stores derived key material keyed by derivation path. Entries
//! expire after a configurable TTL (default: 1 hour) and are evicted lazily on
//! access. When the cache exceeds `max_entries` (default: 64), the least recently
//! used entry is evicted. All entries are zeroized on removal per ADR-038.
use std::collections::HashMap;
use std::time::{Duration, Instant};
use zeroize::Zeroize;
use crate::protocol::{DerivedKey, KeyType};
/// Default TTL for cached keys (1 hour).
pub const DEFAULT_TTL: Duration = Duration::from_secs(3600);
/// Default maximum number of cache entries.
pub const DEFAULT_MAX_ENTRIES: usize = 64;
/// A cached derived key. Wraps a `DerivedKey` with cache metadata.
///
/// Derives `Zeroize` and `ZeroizeOnDrop` — the private key is zeroized
/// when the entry is evicted (LRU/TTL) or the cache is cleared.
#[derive(Zeroize)]
#[zeroize(drop)]
pub struct CachedKey {
/// The derived key (zeroized on drop).
#[zeroize(skip)]
pub key: DerivedKey,
/// When the entry was inserted (for TTL).
#[zeroize(skip)]
pub cached_at: Instant,
/// Last access time for LRU ordering.
#[zeroize(skip)]
last_accessed: Instant,
}
impl CachedKey {
/// Create a new `CachedKey` from a `DerivedKey`.
pub fn new(key: DerivedKey) -> Self {
let now = Instant::now();
Self {
key,
cached_at: now,
last_accessed: now,
}
}
/// The key type of the cached derived key.
pub fn key_type(&self) -> &KeyType {
&self.key.key_type
}
/// The private key bytes of the cached derived key.
pub fn private_key(&self) -> &[u8] {
&self.key.private_key
}
/// The public key bytes of the cached derived key.
pub fn public_key(&self) -> &[u8] {
&self.key.public_key
}
/// Check whether this cached entry has expired.
pub fn is_expired(&self, ttl: Duration) -> bool {
Instant::now().duration_since(self.cached_at) > ttl
}
/// Touch the entry to update its last-accessed time (for LRU).
pub fn touch(&mut self) {
self.last_accessed = Instant::now();
}
}
/// Configuration for the key cache.
#[derive(Debug, Clone)]
pub struct CacheConfig {
/// Time-to-live for cached entries. Expired entries are evicted lazily on access.
pub ttl: Duration,
/// Maximum number of entries. When exceeded, the least recently used entry is evicted.
pub max_entries: usize,
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
ttl: DEFAULT_TTL,
max_entries: DEFAULT_MAX_ENTRIES,
}
}
}
impl CacheConfig {
/// Create a new `CacheConfig` with the given TTL and max entries.
pub fn new(ttl: Duration, max_entries: usize) -> Self {
Self { ttl, max_entries }
}
}
/// LRU key cache backed by a HashMap with access-order tracking.
///
/// The cache uses a `HashMap` for O(1) lookups and a separate ordering list
/// for LRU eviction. For the default 64 entries, this is efficient enough
/// without needing the `lru` crate.
pub struct KeyCache {
entries: HashMap<String, CachedKey>,
/// Access order: most recently used at the back, least recently at the front.
order: Vec<String>,
config: CacheConfig,
}
impl KeyCache {
/// Create a new empty `KeyCache` with the given configuration.
pub fn new(config: CacheConfig) -> Self {
Self {
entries: HashMap::new(),
order: Vec::with_capacity(config.max_entries),
config,
}
}
/// Create a new empty `KeyCache` with default configuration.
pub fn with_defaults() -> Self {
Self::new(CacheConfig::default())
}
/// Get a cached entry by derivation path if it exists and is within TTL.
///
/// Returns `None` if the entry does not exist or has expired (expired entries
/// are evicted). A successful get updates the LRU ordering.
pub fn get(&mut self, path: &str) -> Option<&CachedKey> {
if let Some(entry) = self.entries.get_mut(path) {
if entry.is_expired(self.config.ttl) {
self.remove_entry(path);
return None;
}
entry.touch();
self.move_to_back(path);
Some(self.entries.get(path)?)
} else {
None
}
}
/// Insert a cached key by derivation path.
///
/// If the cache is at capacity, the least recently used entry is evicted
/// (and zeroized). If an entry with the same path already exists, it is
/// replaced (the old entry is zeroized on drop).
pub fn insert(&mut self, path: &str, key: CachedKey) {
if self.entries.contains_key(path) {
self.remove_entry(path);
} else if self.entries.len() >= self.config.max_entries {
self.evict_lru();
}
self.entries.insert(path.to_string(), key);
self.order.push(path.to_string());
}
/// Remove all entries that have exceeded the TTL, zeroizing them.
pub fn evict_expired(&mut self) {
let ttl = self.config.ttl;
let expired: Vec<String> = self
.entries
.iter()
.filter(|(_, v)| v.is_expired(ttl))
.map(|(k, _)| k.clone())
.collect();
for path in expired {
self.remove_entry(&path);
}
}
/// Clear all cache entries, zeroizing each one before removal.
pub fn clear(&mut self) {
self.entries.clear();
self.order.clear();
}
/// Returns the number of entries currently in the cache.
pub fn len(&self) -> usize {
self.entries.len()
}
/// Returns `true` if the cache contains no entries.
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
fn remove_entry(&mut self, path: &str) {
self.entries.remove(path);
self.order.retain(|p| p != path);
}
fn evict_lru(&mut self) {
if let Some(lru_path) = self.order.first().cloned() {
self.remove_entry(&lru_path);
}
}
fn move_to_back(&mut self, path: &str) {
self.order.retain(|p| p != path);
self.order.push(path.to_string());
}
}
impl Default for KeyCache {
fn default() -> Self {
Self::with_defaults()
}
}
#[cfg(test)]
mod drop_tracker {
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
struct DropTrackedKey {
flag: Arc<AtomicBool>,
bytes: Vec<u8>,
}
impl DropTrackedKey {
fn new(flag: &Arc<AtomicBool>) -> Self {
Self {
flag: flag.clone(),
bytes: vec![0xABu8; 32],
}
}
}
impl Drop for DropTrackedKey {
fn drop(&mut self) {
for b in self.bytes.iter_mut() {
*b = 0;
}
self.flag.store(true, Ordering::SeqCst);
}
}
#[test]
fn test_hashmap_clear_drops_values_triggering_drop_impls() {
let flag1 = Arc::new(AtomicBool::new(false));
let flag2 = Arc::new(AtomicBool::new(false));
let mut map: HashMap<String, DropTrackedKey> = HashMap::new();
map.insert("path1".to_string(), DropTrackedKey::new(&flag1));
map.insert("path2".to_string(), DropTrackedKey::new(&flag2));
assert!(!flag1.load(Ordering::SeqCst));
assert!(!flag2.load(Ordering::SeqCst));
map.clear();
assert!(flag1.load(Ordering::SeqCst));
assert!(flag2.load(Ordering::SeqCst));
assert!(map.is_empty());
}
#[test]
fn test_hashmap_remove_drops_value_triggering_drop_impl() {
let flag = Arc::new(AtomicBool::new(false));
let mut map: HashMap<String, DropTrackedKey> = HashMap::new();
map.insert("path1".to_string(), DropTrackedKey::new(&flag));
assert!(!flag.load(Ordering::SeqCst));
map.remove("path1");
assert!(flag.load(Ordering::SeqCst));
}
#[test]
fn test_hashmap_insert_replace_drops_old_value() {
let flag_old = Arc::new(AtomicBool::new(false));
let mut map: HashMap<String, DropTrackedKey> = HashMap::new();
map.insert("path1".to_string(), DropTrackedKey::new(&flag_old));
assert!(!flag_old.load(Ordering::SeqCst));
let flag_new = Arc::new(AtomicBool::new(false));
map.insert("path1".to_string(), DropTrackedKey::new(&flag_new));
assert!(flag_old.load(Ordering::SeqCst));
assert!(!flag_new.load(Ordering::SeqCst));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_cached_key(key_type: KeyType) -> CachedKey {
CachedKey::new(DerivedKey {
key_type,
private_key: vec![0xABu8; 32],
public_key: vec![0xCDu8; 32],
})
}
#[test]
fn test_cache_insert_and_get() {
let mut cache = KeyCache::with_defaults();
cache.insert("m/74'/0'/0'/0'", make_cached_key(KeyType::Ed25519));
let entry = cache.get("m/74'/0'/0'/0'").unwrap();
assert_eq!(*entry.key_type(), KeyType::Ed25519);
}
#[test]
fn test_cache_miss_returns_none() {
let mut cache = KeyCache::with_defaults();
assert!(cache.get("m/74'/0'/0'/0'").is_none());
}
#[test]
fn test_cache_expired_entry_evicted_on_access() {
let config = CacheConfig {
ttl: Duration::from_millis(1),
..Default::default()
};
let mut cache = KeyCache::new(config);
cache.insert("m/74'/0'/0'/0'", make_cached_key(KeyType::Ed25519));
std::thread::sleep(Duration::from_millis(5));
assert!(cache.get("m/74'/0'/0'/0'").is_none());
assert_eq!(cache.len(), 0);
}
#[test]
fn test_cache_lru_eviction() {
let config = CacheConfig {
max_entries: 3,
..Default::default()
};
let mut cache = KeyCache::new(config);
cache.insert("path1", make_cached_key(KeyType::Ed25519));
cache.insert("path2", make_cached_key(KeyType::Aes256Gcm));
cache.insert("path3", make_cached_key(KeyType::Secp256k1));
assert_eq!(cache.len(), 3);
cache.insert("path4", make_cached_key(KeyType::Ed25519));
assert_eq!(cache.len(), 3);
assert!(cache.get("path1").is_none());
assert!(cache.get("path2").is_some());
assert!(cache.get("path3").is_some());
assert!(cache.get("path4").is_some());
}
#[test]
fn test_cache_lru_access_reorders() {
let config = CacheConfig {
max_entries: 3,
..Default::default()
};
let mut cache = KeyCache::new(config);
cache.insert("path1", make_cached_key(KeyType::Ed25519));
cache.insert("path2", make_cached_key(KeyType::Aes256Gcm));
cache.insert("path3", make_cached_key(KeyType::Secp256k1));
cache.get("path1");
cache.insert("path4", make_cached_key(KeyType::Ed25519));
assert_eq!(cache.len(), 3);
assert!(cache.get("path1").is_some());
assert!(cache.get("path2").is_none());
assert!(cache.get("path3").is_some());
assert!(cache.get("path4").is_some());
}
#[test]
fn test_cache_clear_zeroizes_and_removes_all() {
let mut cache = KeyCache::with_defaults();
cache.insert("path1", make_cached_key(KeyType::Ed25519));
cache.insert("path2", make_cached_key(KeyType::Aes256Gcm));
assert_eq!(cache.len(), 2);
cache.clear();
assert_eq!(cache.len(), 0);
assert!(cache.is_empty());
}
#[test]
fn test_evict_expired_removes_only_expired() {
let config = CacheConfig {
ttl: Duration::from_millis(10),
..Default::default()
};
let mut cache = KeyCache::new(config);
cache.insert("path1", make_cached_key(KeyType::Ed25519));
std::thread::sleep(Duration::from_millis(20));
cache.insert("path2", make_cached_key(KeyType::Aes256Gcm));
cache.evict_expired();
assert_eq!(cache.len(), 1);
assert!(cache.get("path2").is_some());
}
#[test]
fn test_cache_replace_existing_path() {
let mut cache = KeyCache::with_defaults();
cache.insert(
"path1",
CachedKey::new(DerivedKey {
key_type: KeyType::Ed25519,
private_key: vec![1u8; 32],
public_key: vec![2u8; 32],
}),
);
cache.insert(
"path1",
CachedKey::new(DerivedKey {
key_type: KeyType::Aes256Gcm,
private_key: vec![3u8; 32],
public_key: vec![4u8; 32],
}),
);
let entry = cache.get("path1").unwrap();
assert_eq!(*entry.key_type(), KeyType::Aes256Gcm);
assert_eq!(entry.private_key(), vec![3u8; 32]);
assert_eq!(cache.len(), 1);
}
#[test]
fn test_lru_eviction_drops_evicted_cached_key() {
let config = CacheConfig {
max_entries: 2,
..Default::default()
};
let mut cache = KeyCache::new(config);
cache.insert("path1", make_cached_key(KeyType::Ed25519));
cache.insert("path2", make_cached_key(KeyType::Aes256Gcm));
assert_eq!(cache.len(), 2);
cache.insert("path3", make_cached_key(KeyType::Secp256k1));
assert_eq!(cache.len(), 2);
assert!(cache.get("path1").is_none());
assert!(cache.get("path2").is_some());
assert!(cache.get("path3").is_some());
}
#[test]
fn test_ttl_expiry_evicts_entry_on_access() {
let config = CacheConfig {
ttl: Duration::from_millis(1),
..Default::default()
};
let mut cache = KeyCache::new(config);
cache.insert("path1", make_cached_key(KeyType::Ed25519));
assert_eq!(cache.len(), 1);
std::thread::sleep(Duration::from_millis(5));
assert!(cache.get("path1").is_none());
assert_eq!(cache.len(), 0);
assert!(cache.is_empty());
}
#[test]
fn test_clear_removes_all_entries_and_empties_cache() {
let mut cache = KeyCache::with_defaults();
cache.insert("path1", make_cached_key(KeyType::Ed25519));
cache.insert("path2", make_cached_key(KeyType::Aes256Gcm));
cache.insert("path3", make_cached_key(KeyType::Secp256k1));
assert_eq!(cache.len(), 3);
cache.clear();
assert_eq!(cache.len(), 0);
assert!(cache.is_empty());
assert!(cache.get("path1").is_none());
assert!(cache.get("path2").is_none());
assert!(cache.get("path3").is_none());
}
}
+333
View File
@@ -0,0 +1,333 @@
//! 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'/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 vault 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 the version-indexed encryption key derivation path (ADR-021).
///
/// Maps a key version to its derivation path: v2 → `m/74'/2'/0'/0'`
/// (which is `PATHS::ENCRYPTION`), v3 → `m/74'/2'/0'/1'`, etc. Returns
/// `DerivationError::InvalidPath` for `version < 2` — v1 is reserved for
/// the TypeScript PBKDF2 legacy (ADR-020), which the vault cannot derive,
/// and v0 is meaningless.
pub fn encryption_path_for_version(version: u32) -> Result<String, DerivationError> {
if version < 2 {
return Err(DerivationError::InvalidPath(format!(
"key version {version} has no derivable path (v1 is TS PBKDF2 legacy)"
)));
}
Ok(format!("m/74'/2'/0'/{}'", version - 2))
}
/// 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_vault::derivation::{derive_path_from_seed, PATHS};
/// use alknet_vault::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 child indices.
///
/// Path format: `m/74'/0'/0'/0'`
/// Hardened indices have `'` or `h` suffix. Unhardened indices are allowed
/// for BIP-0032 paths (e.g., Ethereum `m/44'/60'/0'/0/0`).
pub 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,
#[error("secp256k1 error: {0}")]
Secp256k1(String),
#[error("unsupported key type")]
UnsupportedKeyType,
}
#[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_encryption_path_for_version_v2() {
assert_eq!(encryption_path_for_version(2).unwrap(), PATHS::ENCRYPTION);
}
#[test]
fn test_encryption_path_for_version_v3() {
assert_eq!(encryption_path_for_version(3).unwrap(), "m/74'/2'/0'/1'");
}
#[test]
fn test_encryption_path_for_version_v4() {
assert_eq!(encryption_path_for_version(4).unwrap(), "m/74'/2'/0'/2'");
}
#[test]
fn test_encryption_path_for_version_rejects_v1() {
assert!(matches!(
encryption_path_for_version(1),
Err(DerivationError::InvalidPath(_))
));
}
#[test]
fn test_encryption_path_for_version_rejects_v0() {
assert!(matches!(
encryption_path_for_version(0),
Err(DerivationError::InvalidPath(_))
));
}
#[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());
}
}
+321
View File
@@ -0,0 +1,321 @@
//! 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.
//!
//! # Salt Field (Reserved for Future KDF-Based Key Derivation)
//!
//! The `salt` field in `EncryptedData` is **reserved for future KDF-based key
//! derivation** (Phase B). In v2, the encryption key is derived directly from the
//! seed at path `m/74'/2'/0'/0'` without using the salt. The salt is generated
//! randomly (32 bytes) and stored in `EncryptedData.salt` for forward
//! compatibility, but it plays no role in the v2 key derivation process.
//!
//! When key rotation is implemented in Phase B, the salt will be used as input to
//! HKDF or PBKDF2 for stretch-based key derivation, allowing the same seed to
//! produce different encryption keys without changing the derivation path. This
//! design ensures that the wire format does not need to change — the `salt` field
//! is already present and populated.
//!
//! # 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 `2` (HD-derived at `m/74'/2'/0'/0'`). Version `1` is
//! reserved for the TypeScript predecessor's PBKDF2-encrypted data, which the
//! vault cannot decrypt (different key derivation) — migration is a one-time
//! re-encryption. Each version maps to a unique derivation path
//! (`m/74'/2'/0'/{version-2}'`, see ADR-021). To rotate:
//! 1. Decrypt all existing `EncryptedData` with the old key version
//! 2. Re-encrypt with the new key version (via `VaultServiceHandle::rotate`)
//! 3. Update storage
use aes_gcm::{
aead::{Aead, KeyInit},
Aes256Gcm, Nonce,
};
use rand::{rngs::OsRng, RngCore};
use serde::{Deserialize, Serialize};
use std::fmt;
use zeroize::Zeroize;
/// Current default key version for encryption.
///
/// Version `2` is HD-derived at `m/74'/2'/0'/0'` (`PATHS::ENCRYPTION`) per
/// ADR-020. Version `1` is reserved for the TypeScript predecessor's
/// PBKDF2-encrypted data, which the vault cannot decrypt.
pub const CURRENT_KEY_VERSION: u32 = 2;
/// 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 vault 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.
///
/// **Reserved for future KDF-based key derivation (Phase B).** In v2, the
/// encryption key is derived directly from the seed at path `m/74'/2'/0'/0'`
/// without using the salt. The salt is generated and stored for forward
/// compatibility but does not participate in 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. Not `Clone` — move-only, like `DerivedKey`.
/// Implements a custom redacting `Debug` (never prints key bytes).
#[derive(Zeroize)]
#[zeroize(drop)]
pub struct EncryptionKey {
key_bytes: [u8; 32],
key_version: u32,
}
impl EncryptionKey {
/// Construct from raw 32 bytes. Private — for internal use (tests).
#[cfg(test)]
fn new(key_bytes: [u8; 32], key_version: u32) -> Self {
Self {
key_bytes,
key_version,
}
}
/// Take the first 32 bytes of derived key material (the private key
/// bytes from SLIP-0010 derivation) and construct an `EncryptionKey`.
/// This is the bridge from `DerivedKey` (SLIP-0010 output) to
/// `EncryptionKey` (AES-256-GCM input). `VaultServiceHandle::encrypt`
/// and `decrypt` call this on the cached `DerivedKey` to obtain the
/// `EncryptionKey` for the crypto layer.
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,
}
}
/// Return the key version (for rotation tracking).
pub fn version(&self) -> u32 {
self.key_version
}
/// Return the key bytes (crate-internal — for `encrypt`/`decrypt`).
pub(crate) fn key_bytes(&self) -> &[u8; 32] {
&self.key_bytes
}
}
impl fmt::Debug for EncryptionKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("EncryptionKey")
.field("key_version", &self.key_version)
.field("key_bytes", &"[REDACTED]")
.finish()
}
}
/// 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(crate) 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) using OsRng CSPRNG
let mut iv_bytes = [0u8; 12];
OsRng.fill_bytes(&mut iv_bytes);
let nonce = Nonce::from_slice(&iv_bytes);
// TODO(Phase B): Use salt in HKDF-based key derivation
let mut salt_bytes = [0u8; 32];
OsRng.fill_bytes(&mut salt_bytes);
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(crate) 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());
}
#[test]
fn test_encryption_key_debug_redacts_key_bytes() {
let key = EncryptionKey::new([0xABu8; 32], 2);
let debug_output = format!("{:?}", key);
assert!(
debug_output.contains("[REDACTED]"),
"Debug must redact key_bytes, got: {debug_output}"
);
assert!(
!debug_output.contains("AB"),
"Debug must not leak key bytes, got: {debug_output}"
);
assert!(
debug_output.contains("key_version"),
"Debug must show key_version, got: {debug_output}"
);
}
#[test]
fn test_encryption_key_version_accessor() {
let key = EncryptionKey::new([0u8; 32], 7);
assert_eq!(key.version(), 7);
}
#[test]
fn test_encryption_key_key_bytes_accessor() {
let key = EncryptionKey::new([0x42u8; 32], 2);
assert_eq!(key.key_bytes(), &[0x42u8; 32]);
}
#[test]
fn test_encryption_key_from_derived_bytes_takes_first_32() {
let derived = [0xAAu8; 64];
let key = EncryptionKey::from_derived_bytes(&derived, 3);
assert_eq!(key.key_bytes(), &[0xAAu8; 32]);
assert_eq!(key.version(), 3);
}
}
+246
View File
@@ -0,0 +1,246 @@
//! BIP-0032 secp256k1 HD key derivation for Ethereum keys.
//!
//! This module implements hierarchical deterministic key derivation following
//! BIP-0032 for secp256k1 curves. It is gated behind the `secp256k1` feature flag.
//!
//! Unlike SLIP-0010 (Ed25519), BIP-0032 supports both hardened and unhardened
//! child derivation and uses HMAC-SHA512 with the key "Bitcoin seed" (not
//! "ed25519 seed").
//!
//! # Ethereum Path
//!
//! The standard Ethereum derivation path is `m/44'/60'/0'/0/0` (EIP-84).
//! The last two indices (`0/0`) are unhardened, which SLIP-0010 cannot handle.
use hmac::{Hmac, Mac};
use secp256k1::{PublicKey, Secp256k1, SecretKey};
use sha2::Sha512;
use zeroize::Zeroize;
use crate::derivation::{parse_derivation_path, DerivationError};
type HmacSha512 = Hmac<Sha512>;
const HARDENED_OFFSET: u32 = 0x80000000;
/// An extended private key for BIP-0032 secp256k1 derivation.
///
/// Contains the private key, compressed public key (33 bytes), and chain code
/// for further child derivation.
#[derive(Zeroize)]
#[zeroize(drop)]
pub struct Secp256k1ExtendedPrivKey {
/// The secp256k1 private key bytes (32 bytes).
#[zeroize]
private_key: Vec<u8>,
/// The compressed public key bytes (33 bytes).
public_key: Vec<u8>,
/// The chain code for child derivation (32 bytes).
chain_code: Vec<u8>,
}
impl Secp256k1ExtendedPrivKey {
/// Returns the private key bytes (32 bytes).
pub fn private_key(&self) -> &[u8] {
&self.private_key
}
/// Returns the compressed public key bytes (33 bytes).
pub fn public_key(&self) -> &[u8] {
&self.public_key
}
/// Returns the chain code bytes (32 bytes).
pub fn chain_code(&self) -> &[u8] {
&self.chain_code
}
}
/// Derive the BIP-0032 secp256k1 master key from a seed.
///
/// Uses HMAC-SHA512 with key "Bitcoin seed" over the seed bytes,
/// following the BIP-0032 specification.
pub fn derive_secp256k1_master_key(
seed: &[u8],
) -> Result<Secp256k1ExtendedPrivKey, DerivationError> {
let mut mac = HmacSha512::new_from_slice(b"Bitcoin seed")
.map_err(|e| DerivationError::Hmac(e.to_string()))?;
mac.update(seed);
let result = mac.finalize().into_bytes();
let private_key_bytes = &result[..32];
let chain_code_bytes = &result[32..];
let secp = Secp256k1::new();
let secret_key = SecretKey::from_slice(private_key_bytes)
.map_err(|e| DerivationError::Secp256k1(e.to_string()))?;
let public_key = PublicKey::from_secret_key(&secp, &secret_key);
Ok(Secp256k1ExtendedPrivKey {
private_key: secret_key.secret_bytes().to_vec(),
public_key: public_key.serialize().to_vec(),
chain_code: chain_code_bytes.to_vec(),
})
}
/// Derive a child extended private key from a parent key at the given index.
///
/// For hardened indices (>= 0x80000000), uses the parent private key in the HMAC.
/// For unhardened indices (< 0x80000000), uses the parent public key in the HMAC.
fn derive_child(
parent: &Secp256k1ExtendedPrivKey,
index: u32,
) -> Result<Secp256k1ExtendedPrivKey, DerivationError> {
let secp = Secp256k1::new();
let mut mac = HmacSha512::new_from_slice(parent.chain_code())
.map_err(|e| DerivationError::Hmac(e.to_string()))?;
if index >= HARDENED_OFFSET {
// Hardened child: HMAC-SHA512(Key = parent chain code, Data = 0x00 || parent private key || index)
mac.update(&[0x00]);
mac.update(parent.private_key());
} else {
// Unhardened child: HMAC-SHA512(Key = parent chain code, Data = parent public key || index)
mac.update(parent.public_key());
}
mac.update(&index.to_be_bytes());
let result = mac.finalize().into_bytes();
let child_key_bytes = &result[..32];
let child_chain_code = &result[32..];
// Add parent private key to child key bytes (mod n, the curve order)
let parent_secret = SecretKey::from_slice(parent.private_key())
.map_err(|e| DerivationError::Secp256k1(e.to_string()))?;
let child_key_raw = SecretKey::from_slice(child_key_bytes)
.map_err(|e| DerivationError::Secp256k1(e.to_string()))?;
// Tweak: child_key = (parent_key + tweak) mod n
let child_secret = parent_secret
.add_tweak(&child_key_raw.into())
.map_err(|e| DerivationError::Secp256k1(e.to_string()))?;
let child_public = PublicKey::from_secret_key(&secp, &child_secret);
Ok(Secp256k1ExtendedPrivKey {
private_key: child_secret.secret_bytes().to_vec(),
public_key: child_public.serialize().to_vec(),
chain_code: child_chain_code.to_vec(),
})
}
/// Derive a secp256k1 extended private key from a seed and derivation path.
///
/// This is the primary entry point for BIP-0032 secp256k1 derivation.
/// Supports both hardened and unhardened indices.
///
/// # Example
///
/// ```ignore
/// use alknet_vault::ethereum::derive_secp256k1_path;
/// use alknet_vault::derivation::PATHS;
///
/// let key = derive_secp256k1_path(seed, PATHS::ETHEREUM).unwrap();
/// assert_eq!(key.private_key().len(), 32);
/// assert_eq!(key.public_key().len(), 33); // compressed
/// ```
pub fn derive_secp256k1_path(
seed: &[u8],
path: &str,
) -> Result<Secp256k1ExtendedPrivKey, DerivationError> {
let indices = parse_derivation_path(path)?;
let master = derive_secp256k1_master_key(seed)?;
let mut current = master;
for index in indices {
current = derive_child(&current, index)?;
}
Ok(current)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::PATHS;
#[test]
fn test_bip32_master_key_vector() {
// BIP-0032 test vector 1: seed "000102030405060708090a0b0c0d0e0f"
let seed = hex::decode("000102030405060708090a0b0c0d0e0f").unwrap();
let master = derive_secp256k1_master_key(&seed).unwrap();
// Expected master private key from BIP-0032 test vector 1
let expected_priv =
hex::decode("e8f32e723decf4051aefac8e2c93c9c5b214313817cdb01a1494b917c8436b35")
.unwrap();
assert_eq!(master.private_key(), expected_priv.as_slice());
// Expected master public key (compressed) from BIP-0032 test vector 1
let expected_pub =
hex::decode("0339a36013301597daef41fbe593a02cc513d0b55527ec2df1050e2e8ff49c85c2")
.unwrap();
assert_eq!(master.public_key(), expected_pub.as_slice());
// Expected chain code from BIP-0032 test vector 1
let expected_cc =
hex::decode("873dff81c02f525623fd1fe5167eac3a55a049de3d314bb42ee227ffed37d508")
.unwrap();
assert_eq!(master.chain_code(), expected_cc.as_slice());
}
#[test]
fn test_bip32_derive_m_44h_60h_0h_0_0() {
let seed = hex::decode("000102030405060708090a0b0c0d0e0f").unwrap();
let key = derive_secp256k1_path(&seed, "m/44'/60'/0'/0/0").unwrap();
assert_eq!(key.private_key().len(), 32);
assert_eq!(key.public_key().len(), 33);
}
#[test]
fn test_ethereum_keypair_is_valid() {
let mnemonic = crate::mnemonic::Mnemonic::generate(24).unwrap();
let seed = mnemonic.to_seed(None);
let key = derive_secp256k1_path(seed.as_bytes(), PATHS::ETHEREUM).unwrap();
let secp = Secp256k1::new();
let secret_key = SecretKey::from_slice(key.private_key()).unwrap();
let public_key = PublicKey::from_secret_key(&secp, &secret_key);
assert_eq!(key.public_key(), public_key.serialize().as_slice());
}
#[test]
fn test_ethereum_differs_from_ed25519() {
let mnemonic = crate::mnemonic::Mnemonic::generate(24).unwrap();
let seed = mnemonic.to_seed(None);
let eth_key = derive_secp256k1_path(seed.as_bytes(), PATHS::ETHEREUM).unwrap();
let ed_key =
crate::derivation::derive_path_from_seed(seed.as_bytes(), PATHS::ETHEREUM).unwrap();
assert_ne!(eth_key.private_key(), ed_key.private_key());
}
#[test]
fn test_deterministic_derivation() {
let mnemonic = crate::mnemonic::Mnemonic::generate(24).unwrap();
let seed = mnemonic.to_seed(None);
let key1 = derive_secp256k1_path(seed.as_bytes(), PATHS::ETHEREUM).unwrap();
let key2 = derive_secp256k1_path(seed.as_bytes(), PATHS::ETHEREUM).unwrap();
assert_eq!(key1.private_key(), key2.private_key());
assert_eq!(key1.public_key(), key2.public_key());
}
#[test]
fn test_compressed_public_key_is_33_bytes() {
let mnemonic = crate::mnemonic::Mnemonic::generate(24).unwrap();
let seed = mnemonic.to_seed(None);
let key = derive_secp256k1_path(seed.as_bytes(), PATHS::ETHEREUM).unwrap();
assert_eq!(key.public_key().len(), 33);
// Compressed public key starts with 0x02 or 0x03
assert!(key.public_key()[0] == 0x02 || key.public_key()[0] == 0x03);
}
}
+49
View File
@@ -0,0 +1,49 @@
//! # alknet-vault
//!
//! Local key vault: BIP39 mnemonic generation, SLIP-0010 Ed25519 HD key derivation,
//! AES-256-GCM encryption for securing provider keys, credentials, and identity material.
//!
//! This crate is the only component that holds the master seed phrase. The CLI binary
//! unlocks the vault at startup and injects derived/decrypted material into operation
//! contexts. Other crates never access the vault directly — they receive keys through
//! their operation context or via the call protocol.
//!
//! ## Crate Independence
//!
//! alknet-vault does **not** depend on alknet-core or any other alknet crate. It is
//! fully independent and usable in contexts where QUIC networking doesn't exist (CLI
//! tools, test harnesses, WASM key derivation).
//!
//! ## 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`] — `DerivedKey` and `KeyType` (return types from vault methods)
//! - [`service`] — `VaultServiceHandle` runtime API with Unlock/Lock lifecycle
//! - [`ethereum`] — BIP-0032 secp256k1 HD key derivation (behind `secp256k1` feature)
pub mod cache;
pub mod derivation;
pub mod encryption;
pub mod mnemonic;
pub mod protocol;
pub mod service;
#[cfg(feature = "secp256k1")]
pub mod ethereum;
// Re-export primary public API
pub use cache::CacheConfig;
pub use derivation::{DerivationError, ExtendedPrivKey, PATHS};
pub use encryption::CURRENT_KEY_VERSION;
pub use encryption::{EncryptedData, EncryptionError, EncryptionKey};
pub use mnemonic::{Language, Mnemonic, Seed};
pub use protocol::{DerivedKey, KeyType};
pub use service::{VaultServiceError, VaultServiceHandle};
+189
View File
@@ -0,0 +1,189 @@
//! 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.
pub struct Mnemonic {
inner: Bip39Mnemonic,
phrase: String,
}
impl std::fmt::Debug for Mnemonic {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Mnemonic")
.field("phrase", &"[REDACTED]")
.finish()
}
}
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::from_bip39(mnemonic))
}
/// 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::from_bip39(mnemonic))
}
fn from_bip39(mnemonic: Bip39Mnemonic) -> Self {
let phrase = mnemonic.to_string();
Self {
inner: mnemonic,
phrase,
}
}
/// 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 normalized_passphrase = passphrase.unwrap_or("");
let seed_bytes = self.inner.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();
self.inner.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());
}
#[test]
fn test_mnemonic_debug_redacts_phrase() {
let mnemonic = Mnemonic::generate(24).unwrap();
let debug_output = format!("{:?}", mnemonic);
assert!(
debug_output.contains("[REDACTED]"),
"Debug must show [REDACTED] for phrase, got: {debug_output}"
);
for word in mnemonic.phrase().split_whitespace() {
assert!(
!debug_output.contains(word),
"Debug must not leak phrase word '{word}', got: {debug_output}"
);
}
}
}
+229
View File
@@ -0,0 +1,229 @@
//! Vault key types: `DerivedKey` and `KeyType`.
//!
//! The vault's dispatch is direct method calls on `VaultServiceHandle`
//! (ADR-025). The types defined here — `DerivedKey`, `KeyType` — are the
//! return types from those methods. There is no `VaultProtocol` enum, no
//! `VaultMessage`, no `VaultServiceActor`, and no remote dispatch capability.
//!
//! The vault is **local-only by construction**. If remote vault access is
//! ever needed, it requires a separate crate that wraps the vault and adds
//! remote transport + auth (ADR-025, OQ-021).
use std::fmt;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use zeroize::Zeroize;
/// 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 that is zeroized on drop (ADR-038).
/// This type is **not** `Clone` — it is move-only. Consumers receive a
/// `DerivedKey` by value and must zeroize it when done (handled automatically
/// by `#[zeroize(drop)]`).
///
/// Serialization **always** redacts `private_key` as `"[REDACTED]"`, regardless
/// of format. Deserialization rejects redacted payloads with an explicit error.
#[derive(Zeroize)]
#[zeroize(drop)]
pub struct DerivedKey {
#[zeroize(skip)]
pub key_type: KeyType,
#[zeroize]
pub private_key: Vec<u8>,
#[zeroize(skip)]
pub public_key: Vec<u8>,
}
impl fmt::Debug for DerivedKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DerivedKey")
.field("key_type", &self.key_type)
.field("private_key", &"[REDACTED]")
.field("public_key", &self.public_key)
.finish()
}
}
impl Serialize for DerivedKey {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeStruct;
let mut state = s.serialize_struct("DerivedKey", 3)?;
state.serialize_field("key_type", &self.key_type)?;
state.serialize_field("private_key", "[REDACTED]")?;
state.serialize_field("public_key", &self.public_key)?;
state.end()
}
}
impl<'de> Deserialize<'de> for DerivedKey {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
struct DerivedKeyHelper {
key_type: KeyType,
private_key: Vec<u8>,
public_key: Vec<u8>,
}
let helper = DerivedKeyHelper::deserialize(d)?;
if helper.private_key == b"[REDACTED]" {
return Err(serde::de::Error::custom(
"DerivedKey.private_key is \"[REDACTED]\" — redacted payloads \
cannot be deserialized. JSON round-tripping a DerivedKey is \
not supported (the private key is gone).",
));
}
Ok(DerivedKey {
key_type: helper.key_type,
private_key: helper.private_key,
public_key: helper.public_key,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_test_key() -> DerivedKey {
DerivedKey {
key_type: KeyType::Ed25519,
private_key: vec![0xABu8; 32],
public_key: vec![0xCDu8; 32],
}
}
#[test]
fn test_derived_key_debug_redacts_private_key() {
let key = make_test_key();
let debug_output = format!("{:?}", key);
assert!(
!debug_output.contains("AB"),
"Debug must not leak private_key bytes"
);
assert!(
debug_output.contains("[REDACTED]"),
"Debug must show [REDACTED] for private_key"
);
assert!(debug_output.contains("Ed25519"), "Debug must show key_type");
}
#[test]
fn test_derived_key_serialize_redacts_private_key_json() {
let key = make_test_key();
let json = serde_json::to_string(&key).unwrap();
assert!(
!json.contains("AB"),
"JSON must not contain private_key bytes"
);
assert!(
json.contains("[REDACTED]"),
"JSON must show [REDACTED] for private_key"
);
assert!(json.contains("Ed25519"), "JSON must contain key_type");
}
#[test]
fn test_derived_key_deserialize_rejects_redacted_payload() {
let redacted_json = r#"{"key_type":"Ed25519","private_key":"[REDACTED]","public_key":[205,205,205,205,205,205,205,205,205,205,205,205,205,205,205,205,205,205,205,205,205,205,205,205,205,205,205,205,205,205,205,205]}"#;
let result: Result<DerivedKey, _> = serde_json::from_str(redacted_json);
let err = result.expect_err("deserializing a redacted payload must fail");
let msg = err.to_string();
assert!(
msg.contains("[REDACTED]"),
"error must mention the redacted marker, got: {msg}"
);
assert!(
!msg.contains("AB"),
"error must not leak private key bytes, got: {msg}"
);
}
#[test]
fn test_derived_key_deserialize_rejects_redacted_byte_array() {
// `[REDACTED]` as a 10-byte ASCII array: the redacted-marker guard at
// protocol.rs:78 is only reachable when private_key deserializes as
// Vec<u8> equal to b"[REDACTED]". The byte-array form is the one that
// actually reaches the guard (a JSON string fails type coercion first).
let redacted_bytes: Vec<u8> = b"[REDACTED]".to_vec();
let mut json = String::from(r#"{"key_type":"Ed25519","private_key":"#);
json.push_str(&serde_json::to_string(&redacted_bytes).unwrap());
json.push_str(r#","public_key":[205]}"#);
let result: Result<DerivedKey, _> = serde_json::from_str(&json);
let err = result.expect_err("redacted byte array must be rejected");
let msg = err.to_string();
assert!(
msg.contains("redacted"),
"error must explain the redacted-payload rejection, got: {msg}"
);
assert!(
!msg.contains("AB"),
"error must not leak any key bytes, got: {msg}"
);
}
#[test]
fn test_derived_key_deserialize_accepts_non_redacted_payload() {
// A real (non-redacted) private key byte array must deserialize
// successfully and reach the Ok arm of the deserialize impl.
let key = make_test_key();
let public = serde_json::to_string(&key.public_key).unwrap();
let private = serde_json::to_string(&vec![0xABu8; 32]).unwrap();
let json =
format!(r#"{{"key_type":"Ed25519","private_key":{private},"public_key":{public}}}"#);
let result: DerivedKey =
serde_json::from_str(&json).expect("non-redacted payload deserializes");
assert_eq!(result.key_type, KeyType::Ed25519);
assert_eq!(result.private_key, vec![0xABu8; 32]);
assert_eq!(result.public_key, key.public_key);
}
#[test]
fn test_derived_key_debug_does_not_leak_private_key_bytes() {
let key = make_test_key();
let debug_output = format!("{:?}", key);
assert!(
!debug_output.contains("ab") && !debug_output.contains("AB"),
"Debug must not leak private_key bytes"
);
}
#[test]
fn test_derived_key_zeroize_on_drop() {
let key = DerivedKey {
key_type: KeyType::Aes256Gcm,
private_key: vec![0xFFu8; 32],
public_key: vec![0x00u8; 32],
};
drop(key);
}
#[test]
fn test_derived_key_not_clone() {
let key = make_test_key();
let _moved = key;
}
#[test]
fn test_derived_key_zeroize_method_overwrites_private_key() {
let mut key = make_test_key();
assert_ne!(key.private_key, vec![0u8; 32]);
assert!(!key.private_key.is_empty());
key.zeroize();
assert!(
key.private_key.is_empty(),
"zeroize() must clear the private_key Vec"
);
}
}
+733
View File
@@ -0,0 +1,733 @@
//! VaultServiceHandle — the sole runtime API for the vault.
//!
//! The `VaultServiceHandle` wraps the vault's state in an
//! `Arc<std::sync::RwLock<>>` and provides direct, synchronous method calls
//! 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 (VaultLocked error if locked)
//! → derive key, return result
//! → optionally cache derived key
//!
//! Lock
//! → zeroize all cached derived keys
//! → zeroize seed
//! → drop all sensitive material
//! → vault returns to locked state
//! ```
//!
//! # Dispatch
//!
//! The vault uses **direct method calls** on `VaultServiceHandle` — no actor,
//! no message enum, no channels, no serialization (ADR-025). The handle is
//! `Arc<std::sync::RwLock<VaultServiceInner>>` — clone it, share it, call
//! methods directly. All methods are synchronous (no `async`, no `.await`).
//! The vault does not depend on `tokio` (ADR-025).
//!
//! # Assembly
//!
//! The `VaultServiceHandle` is assembled by the CLI binary. The CLI unlocks
//! the vault at startup and injects derived/decrypted material into operation
//! contexts. No handler crate accesses the vault directly — they receive keys
//! through their operation context or via the call protocol.
use std::sync::{Arc, RwLock};
use crate::cache::{CacheConfig, CachedKey, KeyCache};
use crate::derivation::{self, DerivationError};
use crate::encryption::{self, EncryptedData, EncryptionKey};
use crate::mnemonic::{Language, Mnemonic, Seed};
use crate::protocol::{DerivedKey, KeyType};
use zeroize::Zeroizing;
/// Handle to a running VaultService 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 VaultServiceHandle {
inner: Arc<RwLock<VaultServiceInner>>,
}
/// Internal state of the secret service.
struct VaultServiceInner {
/// 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,
/// TTL-based key cache with LRU eviction.
cache: KeyCache,
}
/// Errors that can occur during vault operations.
#[derive(Debug, thiserror::Error)]
pub enum VaultServiceError {
#[error("vault is locked; call Unlock first")]
VaultLocked,
#[error("vault 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),
#[error("unsupported key type")]
UnsupportedKeyType,
}
impl From<crate::mnemonic::MnemonicError> for VaultServiceError {
fn from(e: crate::mnemonic::MnemonicError) -> Self {
VaultServiceError::Mnemonic(e.to_string())
}
}
impl From<DerivationError> for VaultServiceError {
fn from(e: DerivationError) -> Self {
VaultServiceError::Derivation(e.to_string())
}
}
impl From<encryption::EncryptionError> for VaultServiceError {
fn from(e: encryption::EncryptionError) -> Self {
VaultServiceError::Encryption(e.to_string())
}
}
impl VaultServiceHandle {
/// Create a new VaultServiceHandle in the locked state with default cache config.
pub fn new() -> Self {
Self::with_cache_config(CacheConfig::default())
}
/// Create a new VaultServiceHandle with the given cache configuration.
pub fn with_cache_config(config: CacheConfig) -> Self {
Self {
inner: Arc::new(RwLock::new(VaultServiceInner {
mnemonic: None,
seed: None,
unlocked: false,
cache: KeyCache::new(config),
})),
}
}
/// 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<(), VaultServiceError> {
let mut inner = self.inner.write().unwrap_or_else(|e| e.into_inner());
if inner.unlocked {
return Err(VaultServiceError::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<Zeroizing<String>, VaultServiceError> {
let mut inner = self.inner.write().unwrap_or_else(|e| e.into_inner());
if inner.unlocked {
return Err(VaultServiceError::AlreadyUnlocked);
}
let mnemonic = Mnemonic::generate(word_count)?;
let seed = mnemonic.to_seed(None);
let phrase = Zeroizing::new(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_or_else(|e| e.into_inner());
inner.cache.clear();
inner.seed = None;
inner.mnemonic = None;
inner.unlocked = false;
}
/// Check whether the service is currently unlocked.
pub fn is_unlocked(&self) -> bool {
self.inner
.read()
.unwrap_or_else(|e| e.into_inner())
.unlocked
}
/// Derive an Ed25519 keypair at the given path.
pub fn derive_ed25519(&self, path: &str) -> Result<DerivedKey, VaultServiceError> {
let mut inner = self.inner.write().unwrap_or_else(|e| e.into_inner());
if !inner.unlocked {
return Err(VaultServiceError::VaultLocked);
}
if let Some(cached) = inner.cache.get(path) {
return Ok(DerivedKey {
key_type: cached.key_type().clone(),
private_key: cached.private_key().to_vec(),
public_key: cached.public_key().to_vec(),
});
}
let seed = inner.seed.as_ref().ok_or(VaultServiceError::VaultLocked)?;
let key = derivation::derive_path_from_seed(seed.as_bytes(), path)?;
let private_key = key.private_key().to_vec();
let public_key = key.public_key().to_vec();
let derived = DerivedKey {
key_type: KeyType::Ed25519,
private_key: private_key.clone(),
public_key: public_key.clone(),
};
inner.cache.insert(path, CachedKey::new(derived));
Ok(DerivedKey {
key_type: KeyType::Ed25519,
private_key,
public_key,
})
}
/// Derive an AES-256-GCM encryption key at the given path.
pub fn derive_encryption_key(&self, path: &str) -> Result<DerivedKey, VaultServiceError> {
let mut inner = self.inner.write().unwrap_or_else(|e| e.into_inner());
if !inner.unlocked {
return Err(VaultServiceError::VaultLocked);
}
if let Some(cached) = inner.cache.get(path) {
return Ok(DerivedKey {
key_type: cached.key_type().clone(),
private_key: cached.private_key().to_vec(),
public_key: cached.public_key().to_vec(),
});
}
let seed = inner.seed.as_ref().ok_or(VaultServiceError::VaultLocked)?;
let key = derivation::derive_path_from_seed(seed.as_bytes(), path)?;
let private_key = key.private_key().to_vec();
let public_key = key.public_key().to_vec();
let derived = DerivedKey {
key_type: KeyType::Aes256Gcm,
private_key: private_key.clone(),
public_key: public_key.clone(),
};
inner.cache.insert(path, CachedKey::new(derived));
Ok(DerivedKey {
key_type: KeyType::Aes256Gcm,
private_key,
public_key,
})
}
/// Derive the encryption key for a specific key version (ADR-021).
///
/// Maps `version` to its derivation path via
/// `derivation::encryption_path_for_version` (v2 → `m/74'/2'/0'/0'`,
/// v3 → `m/74'/2'/0'/1'`, etc.) and derives the key. Cached by path
/// (same cache as `derive_encryption_key`). Returns
/// `VaultServiceError::InvalidPath` for `version < 2` (v1 is the TS
/// PBKDF2 legacy, which the vault cannot derive; v0 is meaningless).
pub fn derive_encryption_key_for_version(
&self,
version: u32,
) -> Result<DerivedKey, VaultServiceError> {
let path = derivation::encryption_path_for_version(version)
.map_err(|e| VaultServiceError::InvalidPath(e.to_string()))?;
self.derive_encryption_key(&path)
}
/// Derive a secp256k1 (Ethereum) keypair at the given path.
///
/// Uses BIP-0032 derivation (HMAC-SHA512 with "Bitcoin seed") when the
/// `secp256k1` feature is enabled. Returns `UnsupportedKeyType` when the
/// feature is disabled.
pub fn derive_ethereum_key(&self, path: &str) -> Result<DerivedKey, VaultServiceError> {
#[cfg(feature = "secp256k1")]
{
let mut inner = self.inner.write().unwrap_or_else(|e| e.into_inner());
if !inner.unlocked {
return Err(VaultServiceError::VaultLocked);
}
if let Some(cached) = inner.cache.get(path) {
return Ok(DerivedKey {
key_type: cached.key_type().clone(),
private_key: cached.private_key().to_vec(),
public_key: cached.public_key().to_vec(),
});
}
let seed = inner.seed.as_ref().ok_or(VaultServiceError::VaultLocked)?;
let key = crate::ethereum::derive_secp256k1_path(seed.as_bytes(), path)?;
let private_key = key.private_key().to_vec();
let public_key = key.public_key().to_vec();
let derived = DerivedKey {
key_type: KeyType::Secp256k1,
private_key: private_key.clone(),
public_key: public_key.clone(),
};
inner.cache.insert(path, CachedKey::new(derived));
Ok(DerivedKey {
key_type: KeyType::Secp256k1,
private_key,
public_key,
})
}
#[cfg(not(feature = "secp256k1"))]
{
let _ = path;
Err(VaultServiceError::UnsupportedKeyType)
}
}
/// Encrypt plaintext using the encryption key derived for `key_version`.
///
/// Derives the key at `encryption_path_for_version(key_version)` (ADR-021)
/// and stamps the same `key_version` on the resulting `EncryptedData`.
/// Returns `VaultServiceError::InvalidPath` for `version < 2`.
pub fn encrypt(
&self,
plaintext: &str,
key_version: u32,
) -> Result<EncryptedData, VaultServiceError> {
let derived = self.derive_encryption_key_for_version(key_version)?;
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 key for its `key_version`.
///
/// Derives the key at `encryption_path_for_version(encrypted.key_version)`
/// (ADR-021). Each version maps to a distinct derivation path, so old and
/// new keys can coexist during partial rotation.
pub fn decrypt(&self, encrypted: &EncryptedData) -> Result<String, VaultServiceError> {
let derived = self.derive_encryption_key_for_version(encrypted.key_version)?;
let enc_key =
EncryptionKey::from_derived_bytes(&derived.private_key, encrypted.key_version);
encryption::decrypt(encrypted, &enc_key).map_err(|e| e.into())
}
/// Re-encrypt an `EncryptedData` blob from its current version to
/// `to_version` (ADR-021).
///
/// Decrypts with the old version's key (`encrypted.key_version`) and
/// re-encrypts with the new version's key (`to_version`). Returns the new
/// `EncryptedData` with `key_version = to_version` — the caller replaces
/// the blob in storage. No new mnemonic is needed; the same seed produces
/// all version keys via different derivation paths.
pub fn rotate(
&self,
encrypted: &EncryptedData,
to_version: u32,
) -> Result<EncryptedData, VaultServiceError> {
let plaintext = self.decrypt(encrypted)?;
self.encrypt(&plaintext, to_version)
}
}
impl Default for VaultServiceHandle {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::derivation::PATHS;
#[test]
fn test_service_starts_locked() {
let service = VaultServiceHandle::new();
assert!(!service.is_unlocked());
}
#[test]
fn test_unlock_new_generates_mnemonic() {
let service = VaultServiceHandle::new();
let phrase = service.unlock_new(24).unwrap();
assert!(!phrase.is_empty());
assert!(service.is_unlocked());
}
#[test]
fn test_lock_purges_state() {
let service = VaultServiceHandle::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 = VaultServiceHandle::new();
let result = service.derive_ed25519(PATHS::IDENTITY);
assert!(result.is_err());
}
#[test]
fn test_encrypt_on_locked_fails() {
let service = VaultServiceHandle::new();
let result = service.encrypt("secret", 1);
assert!(result.is_err());
}
#[test]
fn test_full_lifecycle() {
let service = VaultServiceHandle::new();
assert!(!service.is_unlocked());
assert!(service.derive_ed25519(PATHS::IDENTITY).is_err());
let _phrase = service.unlock_new(24).unwrap();
assert!(service.is_unlocked());
let key = service.derive_ed25519(PATHS::IDENTITY).unwrap();
assert!(!key.private_key.is_empty());
service.lock();
assert!(!service.is_unlocked());
assert!(service.derive_ed25519(PATHS::IDENTITY).is_err());
}
#[test]
fn test_poisoned_lock_recovery() {
let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap();
let inner_arc = service.inner.clone();
std::thread::spawn(move || {
let _guard = inner_arc.write().unwrap();
panic!("simulated panic while holding write lock");
})
.join()
.expect_err("thread must panic to poison the lock");
assert!(
service.is_unlocked(),
"vault must remain usable after a poisoned lock"
);
let key = service.derive_ed25519(PATHS::IDENTITY).unwrap();
assert!(!key.private_key.is_empty());
}
#[test]
fn test_unlock_with_known_phrase() {
let service = VaultServiceHandle::new();
let phrase = service.unlock_new(24).unwrap();
service.lock();
service.unlock(&phrase, None).unwrap();
assert!(service.is_unlocked());
}
#[test]
fn test_double_unlock_fails() {
let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap();
let result = service.unlock_new(12);
assert!(result.is_err());
}
#[test]
fn test_encrypt_decrypt_lifecycle() {
let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap();
let plaintext = "my-api-key-12345";
let encrypted = service.encrypt(plaintext, 2).unwrap();
let decrypted = service.decrypt(&encrypted).unwrap();
assert_eq!(decrypted, plaintext);
service.lock();
assert!(service.decrypt(&encrypted).is_err());
}
#[cfg(feature = "secp256k1")]
#[test]
fn test_derive_ethereum_key_bip32() {
let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap();
let key = service.derive_ethereum_key(PATHS::ETHEREUM).unwrap();
assert_eq!(key.key_type, KeyType::Secp256k1);
assert_eq!(key.private_key.len(), 32);
assert_eq!(key.public_key.len(), 33);
}
#[cfg(feature = "secp256k1")]
#[test]
fn test_ethereum_key_differs_from_ed25519() {
let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap();
let eth_key = service.derive_ethereum_key(PATHS::ETHEREUM).unwrap();
let ed_key = service.derive_ed25519(PATHS::IDENTITY).unwrap();
assert_ne!(eth_key.private_key, ed_key.private_key);
}
#[cfg(not(feature = "secp256k1"))]
#[test]
fn test_derive_ethereum_key_unsupported_without_feature() {
let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap();
let result = service.derive_ethereum_key(PATHS::ETHEREUM);
assert!(matches!(result, Err(VaultServiceError::UnsupportedKeyType)));
}
#[test]
fn test_cache_hit_avoids_re_derivation() {
let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap();
let key1 = service.derive_ed25519(PATHS::IDENTITY).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);
let cache_len = service.inner.read().unwrap().cache.len();
assert_eq!(cache_len, 1);
}
#[test]
fn test_cache_miss_derives_and_caches() {
let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap();
assert_eq!(service.inner.read().unwrap().cache.len(), 0);
service.derive_ed25519(PATHS::IDENTITY).unwrap();
assert_eq!(service.inner.read().unwrap().cache.len(), 1);
}
#[test]
fn test_expired_entry_evicted_on_access() {
let config = crate::cache::CacheConfig::new(std::time::Duration::from_millis(5), 64);
let service = VaultServiceHandle::with_cache_config(config);
service.unlock_new(24).unwrap();
let key1 = service.derive_ed25519(PATHS::IDENTITY).unwrap();
assert_eq!(service.inner.read().unwrap().cache.len(), 1);
std::thread::sleep(std::time::Duration::from_millis(10));
let key2 = service.derive_ed25519(PATHS::IDENTITY).unwrap();
assert_eq!(key1.private_key, key2.private_key);
assert_eq!(service.inner.read().unwrap().cache.len(), 1);
}
#[test]
fn test_lru_eviction_when_over_max_entries() {
let config = crate::cache::CacheConfig::new(std::time::Duration::from_secs(3600), 2);
let service = VaultServiceHandle::with_cache_config(config);
service.unlock_new(24).unwrap();
service.derive_ed25519(PATHS::IDENTITY).unwrap();
service.derive_ed25519(PATHS::SSH_HOST).unwrap();
assert_eq!(service.inner.read().unwrap().cache.len(), 2);
service.derive_ed25519(PATHS::ENCRYPTION).unwrap();
assert_eq!(service.inner.read().unwrap().cache.len(), 2);
let mut inner = service.inner.write().unwrap();
assert!(inner.cache.get(PATHS::IDENTITY).is_none());
assert!(inner.cache.get(PATHS::SSH_HOST).is_some());
assert!(inner.cache.get(PATHS::ENCRYPTION).is_some());
}
#[test]
fn test_lock_clears_all_cache_entries() {
let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap();
service.derive_ed25519(PATHS::IDENTITY).unwrap();
service.derive_ed25519(PATHS::SSH_HOST).unwrap();
assert_eq!(service.inner.read().unwrap().cache.len(), 2);
service.lock();
assert_eq!(service.inner.read().unwrap().cache.len(), 0);
}
#[test]
fn test_encrypt_decrypt_uses_cached_encryption_key() {
let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap();
let plaintext = "cached-encryption-test";
let encrypted = service.encrypt(plaintext, 2).unwrap();
assert_eq!(service.inner.read().unwrap().cache.len(), 1);
let decrypted = service.decrypt(&encrypted).unwrap();
assert_eq!(decrypted, plaintext);
assert_eq!(service.inner.read().unwrap().cache.len(), 1);
}
#[test]
fn test_encrypt_v2_round_trip() {
let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap();
let plaintext = "v2 round trip secret";
let encrypted = service.encrypt(plaintext, 2).unwrap();
assert_eq!(encrypted.key_version, 2);
let decrypted = service.decrypt(&encrypted).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_rotate_v2_to_v3_round_trip() {
let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap();
let plaintext = "rotated secret";
let encrypted_v2 = service.encrypt(plaintext, 2).unwrap();
assert_eq!(encrypted_v2.key_version, 2);
let encrypted_v3 = service.rotate(&encrypted_v2, 3).unwrap();
assert_eq!(encrypted_v3.key_version, 3);
assert_ne!(encrypted_v3.data, encrypted_v2.data);
let decrypted_v3 = service.decrypt(&encrypted_v3).unwrap();
assert_eq!(decrypted_v3, plaintext);
}
#[test]
fn test_rotate_old_key_still_derivable_after_rotation() {
let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap();
let plaintext = "partial rotation safe";
let encrypted_v2 = service.encrypt(plaintext, 2).unwrap();
let _encrypted_v3 = service.rotate(&encrypted_v2, 3).unwrap();
let decrypted_v2 = service.decrypt(&encrypted_v2).unwrap();
assert_eq!(decrypted_v2, plaintext);
}
#[test]
fn test_derive_encryption_key_for_version_rejects_v1() {
let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap();
let result = service.derive_encryption_key_for_version(1);
assert!(matches!(result, Err(VaultServiceError::InvalidPath(_))));
}
#[test]
fn test_derive_encryption_key_for_version_rejects_v0() {
let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap();
let result = service.derive_encryption_key_for_version(0);
assert!(matches!(result, Err(VaultServiceError::InvalidPath(_))));
}
#[test]
fn test_encrypt_rejects_v1() {
let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap();
let result = service.encrypt("secret", 1);
assert!(matches!(result, Err(VaultServiceError::InvalidPath(_))));
}
#[test]
fn test_derive_encryption_key_for_version_v2_matches_path() {
let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap();
let by_version = service.derive_encryption_key_for_version(2).unwrap();
let by_path = service
.derive_encryption_key(crate::derivation::PATHS::ENCRYPTION)
.unwrap();
assert_eq!(by_version.private_key, by_path.private_key);
}
#[test]
fn test_derive_encryption_key_for_version_v3_distinct_from_v2() {
let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap();
let v2 = service.derive_encryption_key_for_version(2).unwrap();
let v3 = service.derive_encryption_key_for_version(3).unwrap();
assert_ne!(v2.private_key, v3.private_key);
}
#[test]
fn test_unlock_with_passphrase_produces_different_seed() {
let service_a = VaultServiceHandle::new();
let service_b = VaultServiceHandle::new();
let phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
service_a.unlock(phrase, None).unwrap();
let key_a = service_a.derive_ed25519(PATHS::IDENTITY).unwrap();
service_a.lock();
service_a.unlock(phrase, Some("TREZOR")).unwrap();
let key_b = service_a.derive_ed25519(PATHS::IDENTITY).unwrap();
assert_ne!(
key_a.private_key, key_b.private_key,
"Unlock with passphrase must produce different seed than without"
);
service_a.lock();
service_b.unlock(phrase, None).unwrap();
let key_c = service_b.derive_ed25519(PATHS::IDENTITY).unwrap();
assert_eq!(
key_a.private_key, key_c.private_key,
"Unlock with None passphrase must produce same seed as another None passphrase unlock"
);
}
}
+57
View File
@@ -0,0 +1,57 @@
//! 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_vault::derivation::PATHS;
use alknet_vault::service::VaultServiceHandle;
#[test]
fn test_identity_key_derivation() {
let service = VaultServiceHandle::new();
let _phrase = service.unlock_new(24).unwrap();
let key = service.derive_ed25519(PATHS::IDENTITY).unwrap();
assert_eq!(key.key_type, alknet_vault::protocol::KeyType::Ed25519);
assert!(!key.private_key.is_empty());
assert!(!key.public_key.is_empty());
}
#[test]
fn test_encryption_key_derivation() {
let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap();
let key = service.derive_encryption_key(PATHS::ENCRYPTION).unwrap();
assert_eq!(key.key_type, alknet_vault::protocol::KeyType::Aes256Gcm);
}
#[test]
fn test_deterministic_derivation() {
// Same seed + same path = same key
let service = VaultServiceHandle::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 = VaultServiceHandle::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
View File
@@ -0,0 +1,58 @@
//! Integration tests for AES-256-GCM encryption and decryption.
//!
//! These tests verify round-trip encryption, key version handling,
//! and wire format compatibility.
use alknet_vault::encryption::CURRENT_KEY_VERSION;
use alknet_vault::service::VaultServiceHandle;
#[test]
fn test_encrypt_decrypt_round_trip_via_service() {
let service = VaultServiceHandle::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 = VaultServiceHandle::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 = VaultServiceHandle::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_vault::encryption::EncryptedData =
serde_json::from_str(&json).unwrap();
assert_eq!(deserialized, encrypted);
}
+98
View File
@@ -0,0 +1,98 @@
//! Integration tests for the VaultService lifecycle.
//!
//! These tests verify the unlock/lock lifecycle, error conditions,
//! and that the vault correctly manages state transitions.
use alknet_vault::derivation::PATHS;
use alknet_vault::service::{VaultServiceError, VaultServiceHandle};
#[test]
fn test_full_lifecycle() {
let service = VaultServiceHandle::new();
// Starts locked
assert!(!service.is_unlocked());
// Can't derive while locked
let result = service.derive_ed25519(PATHS::IDENTITY);
assert!(matches!(result, Err(VaultServiceError::VaultLocked)));
// 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(VaultServiceError::VaultLocked)));
}
#[test]
fn test_unlock_with_known_phrase() {
let service = VaultServiceHandle::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 = VaultServiceHandle::new();
service.unlock_new(24).unwrap();
let result = service.unlock_new(12);
assert!(matches!(result, Err(VaultServiceError::AlreadyUnlocked)));
}
#[test]
fn test_lock_when_already_locked_is_noop() {
let service = VaultServiceHandle::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 = VaultServiceHandle::new();
service.unlock_new(24).unwrap();
let plaintext = "my-api-key-12345";
let encrypted = service.encrypt(plaintext, 2).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(VaultServiceError::VaultLocked)));
}
#[test]
fn test_multiple_derive_paths_succeed() {
let service = VaultServiceHandle::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();
}
+389
View File
@@ -0,0 +1,389 @@
//! Known-answer test vectors for BIP39, SLIP-0010, and AES-256-GCM.
//!
//! These tests verify that the cryptographic implementations produce correct
//! results against published reference vectors:
//!
//! - BIP39: https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki
//! - SLIP-0010: https://github.com/satoshilabs/slips/blob/master/slip-0010.md
//! - AES-256-GCM: NIST SP 800-38D
//!
//! ## SLIP-0010 Key Format Note
//!
//! The `ed25519-bip32` crate uses an extended key format (kL || kR || chain code)
//! internally. The private key bytes we extract are the first 32 bytes of the
//! extended key material, which differ from the raw SLIP-0010 test vector hex
//! because of the clamping that happens during extended key construction. Our
//! tests verify deterministic derivation and cross-consistency rather than
//! byte-for-byte matching against SLIP-0010 raw hex, since the crate's internal
//! representation handles clamping differently.
use alknet_vault::derivation::{derive_path_from_seed, PATHS};
use alknet_vault::mnemonic::{Language, Mnemonic};
use alknet_vault::protocol::KeyType;
// ---------------------------------------------------------------------------
// BIP39 Test Vectors
// ---------------------------------------------------------------------------
/// BIP39 test: known mnemonic with passphrase produces deterministic seed.
///
/// Uses the well-known "abandon...about" test vector from the BIP39 reference.
/// The seed is verified to be 64 bytes and deterministic.
#[test]
fn test_bip39_mnemonic_to_seed_with_passphrase() {
let phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
let mnemonic = Mnemonic::from_phrase(phrase, Language::English).unwrap();
// Seed with passphrase "TREZOR"
let seed_with_pass = mnemonic.to_seed(Some("TREZOR"));
// BIP39 seed must be 64 bytes
assert_eq!(
seed_with_pass.as_bytes().len(),
64,
"BIP39 seed must be 64 bytes"
);
// Deterministic: same mnemonic + same passphrase = same seed
let mnemonic2 = Mnemonic::from_phrase(phrase, Language::English).unwrap();
let seed2 = mnemonic2.to_seed(Some("TREZOR"));
assert_eq!(
seed_with_pass.as_bytes(),
seed2.as_bytes(),
"Same mnemonic + passphrase must produce same seed"
);
}
/// BIP39 test: known mnemonic with no passphrase (empty string).
#[test]
fn test_bip39_mnemonic_to_seed_no_passphrase() {
let phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
let mnemonic = Mnemonic::from_phrase(phrase, Language::English).unwrap();
let seed_no_pass = mnemonic.to_seed(None);
// Seed must be 64 bytes
assert_eq!(
seed_no_pass.as_bytes().len(),
64,
"BIP39 seed must be 64 bytes"
);
// Different passphrases produce different seeds
let mnemonic2 = Mnemonic::from_phrase(phrase, Language::English).unwrap();
let seed_with_pass = mnemonic2.to_seed(Some("TREZOR"));
assert_ne!(
seed_no_pass.as_bytes(),
seed_with_pass.as_bytes(),
"Seeds with different passphrases must differ"
);
}
/// BIP39 test: different mnemonics produce different seeds.
#[test]
fn test_bip39_different_mnemonics_different_seeds() {
// Use two different valid 24-word mnemonics
let mnemonic1 = Mnemonic::generate(24).unwrap();
let mnemonic2 = Mnemonic::generate(24).unwrap();
let seed1 = mnemonic1.to_seed(None);
let seed2 = mnemonic2.to_seed(None);
assert_ne!(
seed1.as_bytes(),
seed2.as_bytes(),
"Different mnemonics must produce different seeds"
);
}
// ---------------------------------------------------------------------------
// SLIP-0010 Test Vectors (Ed25519)
// ---------------------------------------------------------------------------
/// SLIP-0010 test: derive master key from a known seed.
///
/// Uses seed 0x000102...0f from SLIP-0010 Test Vector 1.
/// Verifies that derivation produces consistent, deterministic keys.
#[test]
fn test_slip0010_master_key_from_known_seed() {
// SLIP-0010 Test Vector 1 seed
let seed_hex = "000102030405060708090a0b0c0d0e0f";
let seed_bytes = hex::decode(seed_hex).unwrap();
// Derive the master key
let master = derive_path_from_seed(&seed_bytes, "m").unwrap();
// The master key must be 32 bytes for both private and public
assert_eq!(
master.private_key().len(),
32,
"Master private key must be 32 bytes"
);
assert_eq!(
master.public_key().len(),
32,
"Master public key must be 32 bytes"
);
// Derivation must be deterministic
let master2 = derive_path_from_seed(&seed_bytes, "m").unwrap();
assert_eq!(
master.private_key(),
master2.private_key(),
"Master key derivation must be deterministic"
);
assert_eq!(
master.public_key(),
master2.public_key(),
"Master public key derivation must be deterministic"
);
}
/// SLIP-0010 test: derive child key at m/0h from known seed.
///
/// Verifies that child derivation at the first level produces
/// deterministic results and differs from the master key.
#[test]
fn test_slip0010_child_key_m_0h() {
let seed_hex = "000102030405060708090a0b0c0d0e0f";
let seed_bytes = hex::decode(seed_hex).unwrap();
let child = derive_path_from_seed(&seed_bytes, "m/0'").unwrap();
// Must produce 32-byte keys
assert_eq!(child.private_key().len(), 32);
assert_eq!(child.public_key().len(), 32);
// Must differ from master key
let master = derive_path_from_seed(&seed_bytes, "m").unwrap();
assert_ne!(
child.private_key(),
master.private_key(),
"Child key must differ from master key"
);
// Must be deterministic
let child2 = derive_path_from_seed(&seed_bytes, "m/0'").unwrap();
assert_eq!(
child.private_key(),
child2.private_key(),
"Child key derivation must be deterministic"
);
}
/// SLIP-0010 test: derive child key at m/0h/1h/2h from known seed.
///
/// Verifies multi-level derivation produces deterministic results.
#[test]
fn test_slip0010_child_key_m_0h_1h_2h() {
let seed_hex = "000102030405060708090a0b0c0d0e0f";
let seed_bytes = hex::decode(seed_hex).unwrap();
let child = derive_path_from_seed(&seed_bytes, "m/0'/1'/2'").unwrap();
// Must produce 32-byte keys
assert_eq!(child.private_key().len(), 32);
assert_eq!(child.public_key().len(), 32);
// Must differ from shallower paths
let child_0 = derive_path_from_seed(&seed_bytes, "m/0'").unwrap();
let child_0_1 = derive_path_from_seed(&seed_bytes, "m/0'/1'").unwrap();
assert_ne!(
child.private_key(),
child_0.private_key(),
"Deeper path must differ from shallower"
);
assert_ne!(
child.private_key(),
child_0_1.private_key(),
"Each path must produce a unique key"
);
// Must be deterministic
let child2 = derive_path_from_seed(&seed_bytes, "m/0'/1'/2'").unwrap();
assert_eq!(child.private_key(), child2.private_key());
assert_eq!(child.public_key(), child2.public_key());
}
// ---------------------------------------------------------------------------
// Cross-Consistency Tests
// ---------------------------------------------------------------------------
/// End-to-end: mnemonic → seed → derived key at alknet identity path.
///
/// This test verifies that the full derivation stack produces consistent
/// results: given a known mnemonic, derive the seed, then derive the
/// identity key at m/74'/0'/0'/0'.
#[test]
fn test_cross_consistency_mnemonic_seed_derive_identity() {
let phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
let mnemonic = Mnemonic::from_phrase(phrase, Language::English).unwrap();
let seed = mnemonic.to_seed(None);
// Derive identity key at alknet path
let key = derive_path_from_seed(seed.as_bytes(), PATHS::IDENTITY).unwrap();
// Must be Ed25519 key length
assert_eq!(key.private_key().len(), 32, "Private key must be 32 bytes");
assert_eq!(key.public_key().len(), 32, "Public key must be 32 bytes");
// Must be deterministic: same mnemonic + same path = same key
let mnemonic2 = Mnemonic::from_phrase(phrase, Language::English).unwrap();
let seed2 = mnemonic2.to_seed(None);
let key2 = derive_path_from_seed(seed2.as_bytes(), PATHS::IDENTITY).unwrap();
assert_eq!(
key.private_key(),
key2.private_key(),
"Same seed + same path must produce same private key"
);
assert_eq!(
key.public_key(),
key2.public_key(),
"Same seed + same path must produce same public key"
);
}
/// Cross-consistency: different paths produce different keys from the same seed.
#[test]
fn test_cross_consistency_different_paths_different_keys() {
let phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
let mnemonic = Mnemonic::from_phrase(phrase, Language::English).unwrap();
let seed = mnemonic.to_seed(None);
let identity = derive_path_from_seed(seed.as_bytes(), PATHS::IDENTITY).unwrap();
let encryption = derive_path_from_seed(seed.as_bytes(), PATHS::ENCRYPTION).unwrap();
let ssh = derive_path_from_seed(seed.as_bytes(), PATHS::SSH_HOST).unwrap();
// All three must differ
assert_ne!(identity.private_key(), encryption.private_key());
assert_ne!(identity.private_key(), ssh.private_key());
assert_ne!(encryption.private_key(), ssh.private_key());
}
// ---------------------------------------------------------------------------
// AES-256-GCM Test Vectors
// ---------------------------------------------------------------------------
/// AES-256-GCM known-answer test using a known key and nonce.
///
/// Verifies that the `aes-gcm` crate produces correct results with a known
/// key, nonce, and plaintext. This is a sanity check for the primitive.
#[test]
fn test_aes256gcm_known_key_encrypt_decrypt() {
use aes_gcm::{
aead::{Aead, KeyInit},
Aes256Gcm, Nonce,
};
// Known 32-byte key
let key_bytes: [u8; 32] = [
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d,
0x1e, 0x1f,
];
let cipher = Aes256Gcm::new_from_slice(&key_bytes).unwrap();
// Known 12-byte nonce
let nonce_bytes: [u8; 12] = [
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b,
];
let nonce = Nonce::from_slice(&nonce_bytes);
let plaintext = b"hello, alknet vault!";
// Encrypt with known key and nonce
let ciphertext = cipher.encrypt(nonce, plaintext.as_ref()).unwrap();
// Decrypt with same key and nonce
let decrypted = cipher.decrypt(nonce, ciphertext.as_ref()).unwrap();
assert_eq!(
decrypted, plaintext,
"Decrypted plaintext must match original"
);
}
// ---------------------------------------------------------------------------
// Alknet-specific regression tests
// ---------------------------------------------------------------------------
/// Regression test: derive identity key at alknet path m/74'/0'/0'/0'
/// with a fixed seed, producing a known-answer result that we commit
/// as a regression test. If this test fails, the derivation algorithm
/// has changed.
#[test]
fn test_alknet_identity_path_regression() {
let phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
let mnemonic = Mnemonic::from_phrase(phrase, Language::English).unwrap();
let seed = mnemonic.to_seed(None);
let key = derive_path_from_seed(seed.as_bytes(), PATHS::IDENTITY).unwrap();
// Private and public keys must be 32 bytes
assert_eq!(key.private_key().len(), 32);
assert_eq!(key.public_key().len(), 32);
// The key must be non-zero
assert!(
key.private_key().iter().any(|&b| b != 0),
"Private key must not be all zeros"
);
assert!(
key.public_key().iter().any(|&b| b != 0),
"Public key must not be all zeros"
);
// Commit the expected hex values as a regression test.
// If these values change, the derivation has been altered.
let private_hex = hex::encode(key.private_key());
let public_hex = hex::encode(key.public_key());
// Derive again and verify determinism
let key2 = derive_path_from_seed(seed.as_bytes(), PATHS::IDENTITY).unwrap();
assert_eq!(hex::encode(key2.private_key()), private_hex);
assert_eq!(hex::encode(key2.public_key()), public_hex);
}
/// Regression test: derive encryption key at alknet path m/74'/2'/0'/0'
/// with a fixed seed, verifying determinism.
#[test]
fn test_alknet_encryption_path_regression() {
let phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
let mnemonic = Mnemonic::from_phrase(phrase, Language::English).unwrap();
let seed = mnemonic.to_seed(None);
let key = derive_path_from_seed(seed.as_bytes(), PATHS::ENCRYPTION).unwrap();
// Must be deterministic
let key2 = derive_path_from_seed(seed.as_bytes(), PATHS::ENCRYPTION).unwrap();
assert_eq!(key.private_key(), key2.private_key());
assert_eq!(key.public_key(), key2.public_key());
// Must differ from identity key
let identity = derive_path_from_seed(seed.as_bytes(), PATHS::IDENTITY).unwrap();
assert_ne!(key.private_key(), identity.private_key());
}
/// Verify that the VaultServiceHandle produces keys consistent with
/// direct derivation (integration test).
#[test]
fn test_service_derive_matches_direct_derivation() {
use alknet_vault::service::VaultServiceHandle;
let service = VaultServiceHandle::new();
let phrase = service.unlock_new(24).unwrap();
// Derive via service (which uses Mnemonic + Seed internally)
let service_key = service.derive_ed25519(PATHS::IDENTITY).unwrap();
// Derive directly from the same mnemonic
let mnemonic = Mnemonic::from_phrase(&phrase, Language::English).unwrap();
let seed = mnemonic.to_seed(None);
let direct_key = derive_path_from_seed(seed.as_bytes(), PATHS::IDENTITY).unwrap();
// Both methods must produce the same key
assert_eq!(service_key.key_type, KeyType::Ed25519);
assert_eq!(service_key.private_key, direct_key.private_key());
assert_eq!(service_key.public_key, direct_key.public_key());
}