docs(architecture): add alknet-vault spec, ADR-018, ADR-019, OQ-20/21/22

Spec the vault crate from its existing implementation. The vault is
stable (implementation exists); this spec documents what IS so the
implementation-sync agent can reconcile source drift.

New spec documents (crates/vault/):
- README.md — crate index, security constraints, public API
- mnemonic-derivation.md — BIP39, SLIP-0010, BIP-0032, derivation paths
- encryption.md — AES-256-GCM, EncryptedData, key versioning, salt
- service.md — VaultServiceHandle lifecycle, actor dispatch, cache
- protocol.md — VaultProtocol irpc messages, DerivedKey redaction

New ADRs:
- ADR-018: Vault as standalone crate (zero alknet deps; own types/errors)
- ADR-019: Vault assembly-layer-only access (CLI is sole caller)

New open questions:
- OQ-20: Salt/KDF Phase B (open, low priority — salt field reserved)
- OQ-21: Remote vault administration (deferred — needs ADR if ever needed)
- OQ-22: Key rotation mechanism (open, low priority — workflow not specced)

Spec-vs-source drift explicitly flagged (for the sync agent):
- rand::random() used for IVs instead of OsRng (security-critical)
- unwrap() on every RwLock acquisition (must use unwrap_or_else)
- ADR-038 / OQ-SVC-03 references in source comments are stale (old numbering)
- VaultServiceActor::spawn returns a non-functional second actor (source bug)
- KeyVersionMismatch error variant is defined but unused in v1
This commit is contained in:
2026-06-19 09:23:47 +00:00
parent 40f6468e18
commit dd1ca1de70
10 changed files with 1564 additions and 8 deletions

View File

@@ -0,0 +1,131 @@
---
status: draft
last_updated: 2026-06-19
---
# 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). 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, and no operations registered in the call
protocol (ADR-008, ADR-014). The master seed and derived private keys never
cross the network.
## Documents
| Document | Status | Description |
|----------|--------|-------------|
| [mnemonic-derivation.md](mnemonic-derivation.md) | draft | BIP39, SLIP-0010, BIP-0032, derivation paths, key types |
| [encryption.md](encryption.md) | draft | AES-256-GCM, EncryptedData, key versioning, salt (Phase B reserved) |
| [service.md](service.md) | draft | VaultServiceHandle lifecycle, actor dispatch, cache, error model |
| [protocol.md](protocol.md) | draft | VaultProtocol irpc messages, DerivedKey redaction, serialization |
## Applicable ADRs
| ADR | Title | Relevance |
|-----|-------|-----------|
| [003](../../decisions/003-crate-decomposition.md) | Crate Decomposition | alknet-vault's standalone position |
| [005](../../decisions/005-irpc-as-call-protocol-foundation.md) | irpc as Call Protocol Foundation | VaultProtocol uses irpc directly |
| [008](../../decisions/008-secret-service-integration.md) | Vault Integration Point | CLI-embedded, capability source |
| [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 |
## Relevant Open Questions
| OQ | Title | Status | Relevance |
|----|-------|--------|-----------|
| OQ-20 | Salt/KDF Phase B | open | Salt field is reserved; v1 does not use it |
| OQ-21 | Remote vault administration | deferred | Network unlock not supported; needs ADR if ever needed |
| OQ-22 | Key rotation mechanism | open | Key versioning is in place; rotation workflow is not specced |
## Key Design Principles
1. **Standalone**: The vault depends on no alknet crate. It defines its own
types, errors, and protocol. 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. **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.
4. **Deterministic derivation**: The same mnemonic + passphrase + path
always produces the same key. Derivation is reproducible across runs
and across nodes.
5. **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.
6. **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()`. The
current source uses `rand::random()` — this is a known drift from the
spec and must be corrected during implementation sync.
- **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. The
current source uses `unwrap()` in `VaultServiceHandle` methods — this
is a known drift and must be corrected.
- **DerivedKey redaction in JSON**: `DerivedKey` serializes the
`private_key` as `"[REDACTED]"` in human-readable formats (JSON) and as
raw bytes in binary formats (postcard). The redaction is a defense-in-
depth measure, 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};
// Encryption
pub use encryption::{EncryptedData, EncryptionError};
// Protocol (irpc messages)
pub use protocol::{DerivedKey, KeyType, VaultMessage, VaultProtocol};
// Service (runtime)
pub use service::{VaultService, VaultServiceActor, VaultServiceError, VaultServiceHandle};
// Cache
pub use cache::CacheConfig;
```
The `secp256k1` feature flag gates Ethereum (BIP-0032) derivation:
```rust
#[cfg(feature = "secp256k1")]
pub mod ethereum;
```

View File

@@ -0,0 +1,215 @@
---
status: draft
last_updated: 2026-06-19
---
# 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.
## Encryption Key
The encryption key is derived from the seed at path `m/74'/2'/0'/0'`
(`PATHS::ENCRYPTION`):
```rust
pub struct EncryptionKey {
key_bytes: [u8; 32], // 32-byte AES-256 key
key_version: u32, // for rotation tracking
}
```
- `new(key_bytes, key_version)`: Construct from raw bytes.
- `from_derived_bytes(bytes, key_version)`: Take the first 32 bytes of
derived key material (the private key bytes from SLIP-0010 derivation).
- `version()`: Return the key version (for rotation).
`EncryptionKey` implements `Zeroize` and `ZeroizeOnDrop` — the key bytes
are zeroized before deallocation.
The key is derived once (at unlock time or on first encrypt/decrypt) and
cached in the `KeyCache` (see [service.md](service.md)). Subsequent
encrypt/decrypt operations use the cached key.
## 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 — reserved for Phase B (see OQ-20)
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 (reserved for Phase B)
The `salt` field is **reserved for future KDF-based key derivation** (Phase
B, OQ-20). In v1, 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 v1 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. The wire format does not need to change — the `salt`
field is already present and populated.
This is a deliberate forward-compatibility decision: the field exists in
v1 so that v2 can use it without a format migration. The cost is 32 extra
bytes per `EncryptedData`; the benefit is no future format break.
## Encrypt and Decrypt
```rust
pub fn encrypt(plaintext: &str, key: &EncryptionKey) -> Result<EncryptedData, EncryptionError>;
pub 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, not used in v1)
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 `1`. Key versioning allows re-encryption when the
encryption key is rotated:
1. Derive a new key from a new derivation path or new seed
2. Decrypt all existing `EncryptedData` with key version 1
3. Re-encrypt with key version 2
4. Update storage
The key version is stored in `EncryptedData.key_version` so decryption can
select the right key. The rotation workflow itself is not specced — see
OQ-22.
## 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 }, // reserved for Phase B
}
```
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 in v1** — neither `encrypt()`
nor `decrypt()` returns it. It is reserved for Phase B key rotation (OQ-22),
where the vault may enforce version matching before decrypting. In v1, the
`key_version` is stamped onto `EncryptedData` and `EncryptionKey` for
forward compatibility but does not gate decryption. An implementer should
not expect this variant to fire in v1.
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| AES-256-GCM for credential encryption | — | Authenticated encryption, hardware-accelerated |
| Salt reserved for Phase B (OQ-20) | — | Forward-compatible wire format; v1 doesn't use salt |
| Key derived at `m/74'/2'/0'/0'` | — | Dedicated account for encryption keys |
| Key versioning | — | Rotation support without format break |
| All fields base64-encoded | — | JSON serialization compatibility |
## Open Questions
See [open-questions.md](../../open-questions.md) for full details.
- **OQ-20** (open): Salt/KDF Phase B — when and how to use the reserved
`salt` field for KDF-based key derivation.
- **OQ-22** (open): Key rotation mechanism — the key versioning is in place,
but the rotation workflow (re-encrypt all data, update storage) is not
specced.
## 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. **The current source uses
`rand::random()` for IV generation (`encryption.rs` line 133) — this is a
known drift from the spec and must be corrected during implementation
sync.** `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

View File

@@ -0,0 +1,301 @@
---
status: draft
last_updated: 2026-06-19
---
# 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.
## 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.
## 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.
### 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 site_password_path(site_hash: &str) -> String; // m/74'/1'/0'/{site_hash}'
```
### 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'/1'/0'/{hash}'` | Site-specific deterministic password | Ed25519 bytes | Per-site passwords (not cached) |
| `m/74'/2'/0'/0'` | Encryption key for external credentials | AES-256-GCM | Credential encryption (see [encryption.md](encryption.md)) |
| `m/44'/60'/0'/0/0` | Ethereum signing key | secp256k1 | Ethereum signing (feature-gated) |
### Path namespace discipline
The `74'` coin type is alknet's namespace. Sub-paths follow a convention:
| Account (`/X'`) | Purpose |
|-----------------|---------|
| `0'` | Identity keys (node, devices, SSH) |
| `1'` | Deterministic passwords |
| `2'` | Encryption keys (external credentials) |
New key purposes should allocate a new account index, not reuse an
existing one. This prevents cross-purpose key collisions.
### The `74'` coin type is a one-way door
The `74'` coin type is **committed** — once keys are derived at `m/74'/...`,
changing the coin type breaks every existing key. Every node's identity,
SSH host key, and encryption key is derived at a `74'`-rooted path. This is
effectively a one-way door per ADR-009: reversal requires re-deriving every
key from the seed at a new coin type and re-deploying all nodes. The
reservation is documented inline rather than in a separate ADR because it
is a single, self-evident commitment (the coin type is the alknet
namespace; there is no alternative to evaluate). The SLIP-0044 registry
lists `74'` as unallocated, so there is no collision risk with other
projects.
## Key Types
```rust
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum KeyType {
Ed25519, // SLIP-0010 derivation
Aes256Gcm, // Symmetric key (derived from seed, used for encryption)
Secp256k1, // BIP-0032 derivation (Ethereum, feature-gated)
}
```
`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) | — | One seed, many keys, no key storage |
| `74'` coin type reserved for alknet | — | SLIP-0044 unallocated; alknet namespace |
| secp256k1 feature-gated | — | Heavy dep; only needed for Ethereum |
| Hardened-only for Ed25519 | SLIP-0010 | Ed25519 cannot do public derivation |
## Open Questions
See [open-questions.md](../../open-questions.md) for full details.
- **OQ-20** (open): Salt/KDF Phase B — the `EncryptedData.salt` field is
reserved; v1 does not use it. 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`

View File

@@ -0,0 +1,177 @@
---
status: draft
last_updated: 2026-06-19
---
# Protocol
The `VaultProtocol` irpc message enum, `DerivedKey` type, and serialization
behavior.
## What
The protocol layer defines the message enum that the irpc dispatch
infrastructure uses (ADR-005) and the `DerivedKey` type that derivation
methods return. This is the vault's internal dispatch protocol — not the
alknet call protocol (the vault has no ALPN, ADR-008).
## VaultProtocol
The irpc message enum. The `#[rpc_requests]` macro generates the
`VaultMessage` enum (with `WithChannels` wrappers), `Channels` impls,
`From` impls, and `Service`/`RemoteService` traits for remote dispatch.
```rust
#[rpc_requests(message = VaultMessage, no_spans)]
#[derive(Debug, Serialize, Deserialize)]
pub enum VaultProtocol {
DeriveEd25519 { path: String },
DeriveEncryptionKey { path: String },
DeriveEthereumKey { path: String },
DerivePassword { path: String, length: usize },
Encrypt { plaintext: String, key_version: u32 },
Decrypt { encrypted: EncryptedData },
Lock,
Unlock { mnemonic: String, passphrase: Option<String> },
}
```
Each variant is a vault operation. The `tx` channel type for each variant
is `oneshot::Sender<Result<T, VaultServiceError>>`, where `T` is the
operation's return type (`DerivedKey`, `Vec<u8>`, `EncryptedData`, `String`,
or `()`).
### State requirements
All operations except `Unlock` require the vault to be **unlocked**.
Calling derive/encrypt/decrypt on a locked vault returns
`VaultServiceError::VaultLocked` (not a panic, not a channel close).
### Dispatch
The `VaultServiceActor` (see [service.md](service.md)) processes
`VaultMessage` variants and dispatches to `VaultServiceHandle` methods.
For local in-process use, prefer `VaultServiceHandle` directly — no
channel overhead.
## DerivedKey
The result of key derivation. Holds the key type, private key, and public
key.
```rust
#[derive(Zeroize, Deserialize)]
#[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
}
```
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 redacts the private key in
human-readable formats:
- **JSON** (human-readable): `private_key` serializes as `"[REDACTED]"`.
This is defense-in-depth — if a `DerivedKey` accidentally ends up in a
log or a JSON config, the private key is not exposed.
- **postcard** (binary, used by irpc): `private_key` serializes as the
actual bytes. This is required for in-cluster irpc dispatch to work —
the remote side needs the actual key bytes.
- **Deserialization**: always reads the full bytes, regardless of format.
A JSON-deserialized `DerivedKey` will have `"[REDACTED]"` as its
`private_key` string — this is expected; JSON round-tripping a
`DerivedKey` is not a supported use case (the private key is gone).
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` (it's part of the irpc protocol) and
`Clone` (it's not secret material — it's a tag).
## Wire Format
For local (in-process) calls, the protocol uses tokio channels directly —
no serialization. For remote (in-cluster) calls, the protocol is serialized
with postcard (binary, compact). For cross-node (call protocol) exposure,
the vault is wrapped in an operation that serializes to JSON — but **no
vault operations are exposed over the call protocol** (ADR-014). The JSON
serialization path exists only for the `DerivedKey` redaction safety net.
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| irpc for vault dispatch | [ADR-005](../../decisions/005-irpc-as-call-protocol-foundation.md) | In-process type-safe dispatch |
| `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 | [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Defense-in-depth for logging accidents |
| postcard preserves private key | — | Required for in-cluster irpc dispatch |
| 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 |
## Open Questions
None active for this document.
## 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) — how the actor dispatches `VaultMessage`
- [mnemonic-derivation.md](mnemonic-derivation.md) — what `KeyType` means

View File

@@ -0,0 +1,361 @@
---
status: draft
last_updated: 2026-06-19
---
# Service
The `VaultServiceHandle` runtime API: unlock/lock lifecycle, key
derivation, encryption, caching, and the actor 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).
## VaultServiceHandle
The primary API for local (in-process) use. Thread-safe via
`Arc<RwLock<VaultServiceInner>>`.
```rust
#[derive(Clone)]
pub struct VaultServiceHandle {
inner: Arc<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
}
```
`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<String, VaultServiceError>;
```
Generate a new random mnemonic, unlock with it, and return the phrase.
Store the returned phrase securely — it is the root of trust. Supported
word counts: 12, 15, 18, 21, 24.
This is the "first run" path — a new node generates its mnemonic, writes
it down, and the vault is unlocked for the process lifetime.
### 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_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).
### derive_password(path, length) → Vec<u8>
```rust
pub fn derive_password(&self, path: &str, length: usize) -> Result<Vec<u8>, VaultServiceError>;
pub fn derive_password_string(&self, path: &str, length: usize) -> Result<String, VaultServiceError>;
```
Derive deterministic password bytes at the given path, truncated to
`length`. This is **not cached** — password derivation is cheap and
passwords are typically one-shot (derive, use, discard). The string
variant base64url-encodes the bytes (URL-safe, no padding).
`derive_password` is the mechanism for per-site deterministic passwords:
the same seed + path always produces the same password. The path includes
a site hash (`site_password_path(site_hash)`) so different sites get
different passwords.
## 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 `PATHS::ENCRYPTION`.
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
`PATHS::ENCRYPTION` if not already cached. The `encrypted.key_version` is
stamped onto the `EncryptionKey` for forward compatibility but **does not
select a different derivation path in v1** — the same key (at
`m/74'/2'/0'/0'`) decrypts any version. Path-per-version routing is a Phase
B concern (OQ-22). See [encryption.md](encryption.md).
## 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,
}
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`. 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 |
| `derive_password` | No | Cheap derivation; passwords are one-shot |
| `encrypt` / `decrypt` | Key cached | The encryption key (at `PATHS::ENCRYPTION`) is cached; the plaintext is not |
`derive_password` does not cache because it's a truncation of derived
bytes, not a keypair that's reused. Caching it would grow the cache with
unique paths (one per site hash) for no reuse benefit.
## Actor Dispatch
The `VaultServiceActor` processes `VaultMessage` variants from an mpsc
channel and dispatches to `VaultServiceHandle` methods. This is the irpc
dispatch mechanism (ADR-005) — the in-process actor pattern that irpc
services use.
```rust
pub struct VaultServiceActor {
handle: VaultServiceHandle,
}
impl VaultServiceActor {
pub fn new(handle: VaultServiceHandle) -> Self;
pub async fn run(mut self, mut rx: mpsc::Receiver<VaultMessage>);
pub fn spawn(handle: VaultServiceHandle) -> (Client<VaultProtocol>, VaultServiceActor);
}
```
- `run(rx)`: Message loop. Each `VaultMessage` variant is dispatched to the
corresponding handle method, and the response is sent through the oneshot
channel embedded in the message. Consumes `self`.
- `spawn(handle)`: Spawn the actor as a `tokio::task` and return a
`Client<VaultProtocol>` for sending messages. **Source bug: the current
`spawn` implementation returns a fresh, unspawned `VaultServiceActor` as
the second tuple element (the spawned actor is consumed by `run`). The
returned actor has no channel and is non-functional. This should be
corrected during implementation sync — either drop the second return
value (return only `Client<VaultProtocol>`) or restructure the API so
the returned actor is the one that was spawned.**
The actor pattern is the irpc dispatch mechanism (ADR-005). For local
in-process use, prefer `VaultServiceHandle` directly — no channel, no
serialization. The actor exists for irpc service dispatch, which is an
in-process pattern (the actor and the handle share state via `Arc`).
### Dispatch paths
| Path | Type | Serialization | Use case |
|------|------|---------------|----------|
| Direct (in-process) | `VaultServiceHandle` method calls | None | CLI binary at startup (the supported path) |
| Actor (in-process) | `VaultMessage` over mpsc | None (channel) | irpc service dispatch (in-process) |
Remote (in-cluster) vault dispatch — where the vault runs as a sidecar
and other processes send `VaultMessage` over a network — is **not
supported** (ADR-019, OQ-21). The irpc `RemoteService` trait infrastructure
exists in the library, but exposing the vault over the network would
require its own ADR with an explicit threat model (the master seed must
never cross the network). The dispatch table above lists only the
supported paths.
The assembly layer (CLI binary) uses the direct path. The actor path
exists for in-process irpc dispatch but is not used by the assembly layer
— it's available for test harnesses and future in-process service
patterns. Neither path is on the alknet call protocol (ADR-008, ADR-014).
## Errors
```rust
#[derive(Debug, thiserror::Error, Serialize, Deserialize)]
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 `Serialize`/`Deserialize` (for irpc dispatch) and
wraps sub-errors as strings. It does not implement `From` for alknet-core
error types — the CLI binary converts 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 |
| RwLock for thread safety | — | Multiple readers (derive), exclusive writer (unlock/lock) |
| TTL + LRU cache | — | Bounded memory, fresh keys, zeroized eviction |
| Actor for in-cluster dispatch | [ADR-005](../../decisions/005-irpc-as-call-protocol-foundation.md) | irpc message dispatch; not on the call protocol |
| `derive_password` not cached | — | One-shot; caching grows cache with no reuse |
## Open Questions
See [open-questions.md](../../open-questions.md) for full details.
- **OQ-21** (deferred): Remote vault administration — network unlock is not
supported; needs an ADR if ever needed.
## 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). **The current source uses `rand::random()` for IV generation
in `encryption::encrypt()` — this is a known drift and must be corrected
during implementation sync.**
- **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()`. **The current `KeyCache::clear()` removes
entries but relies on `CachedKey`'s `Drop` impl for zeroization —
verify that `HashMap::clear()` actually drops the values (it does, but
this is worth a test).**
- **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. **The current source uses `unwrap()` on every `RwLock`
acquisition in `VaultServiceHandle` (lines 142, 161, 182, 191, 196, 227,
264, 307, 340, 367) — this is a known drift and must be corrected. 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. **The current source does not derive
`Clone` on `DerivedKey` — this is correct.**
- **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) — `VaultMessage` and `DerivedKey`
- [encryption.md](encryption.md) — `encrypt` / `decrypt` cryptographic details