Port alknet-vault crate from alknet

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

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

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

No files matched your search

@@ -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