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:
1 parent
9a137b5a21
commit
8fd3428544
30 files changed
+6684
No files matched your search
@@ -0,0 +1,3 @@
|
||||
target/
|
||||
node_modules/
|
||||
.worktrees/
|
||||
Generated
+575
@@ -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
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(¤t, 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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
Reference in new issue
Block a user