Port alknet-vault crate from alknet
Copy the local key vault (src/, tests/) verbatim from alknet/crates/alknet-vault and create a standalone Cargo.toml (workspace-inherited fields inlined). Port the architecture docs (specs, ADRs 018-026, OQs 020-022) from alknet's nested multi-crate layout to a flat single-crate layout, fixing relative link paths. ADR and OQ numbers are preserved from alknet; a subsequent pass will renumber them to a per-project sequence (001, 002, ...) and rebrand alknet-vault -> alkvault (crate name, lib name, prose), updating the cross-references to non-vault alknet ADRs (003, 005, 008, 010, 014, 064) that are referenced in the copied docs but not copied over. Build, 108 tests, and clippy all pass clean.
This commit is contained in:
1 parent
9a137b5a21
commit
8fd3428544
30 files changed
+6684
No files matched your search
@@ -0,0 +1,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
|
||||
Reference in new issue
Block a user