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

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

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

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

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

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

View File

@@ -1,15 +1,15 @@
---
status: draft
last_updated: 2026-06-21
last_updated: 2026-06-19
---
# Alknet Architecture
## Current State
**Pre-implementation.** The project has completed a pivot from a three-layer model to an ALPN-as-service model. The greenfield workspace contains only `alknet-vault` (stable) and research/reference material. Foundational ADRs (001017) are in place, including the BiStream type definition (ADR-007), vault integration (ADR-008), ALPN router/endpoint (ADR-010), AuthContext structure (ADR-011), call protocol stream model (ADR-012), Rust as canonical implementation language (ADR-013), secret material flow with capability injection (ADR-014), privilege model with authority context (ADR-015), abort cascade for nested calls (ADR-016), and call protocol client and adapter contract (ADR-017). The alknet-core and alknet-call crate specs are in draft.
**Pre-implementation.** The project has completed a pivot from a three-layer model to an ALPN-as-service model. The greenfield workspace contains only `alknet-vault` (stable — implementation exists) and research/reference material. Foundational ADRs (001019) are in place, including the BiStream type definition (ADR-007), vault integration (ADR-008), ALPN router/endpoint (ADR-010), AuthContext structure (ADR-011), call protocol stream model (ADR-012), Rust as canonical implementation language (ADR-013), secret material flow with capability injection (ADR-014), privilege model with authority context (ADR-015), abort cascade for nested calls (ADR-016), call protocol client and adapter contract (ADR-017), vault standalone crate (ADR-018), and vault assembly-layer-only access (ADR-019). The alknet-core, alknet-call, and alknet-vault crate specs are in draft.
**Next step**: Review alknet-call spec documents, then begin implementation. All open questions are resolved.
**Next step**: Review the vault spec documents (newly added), then begin implementation. All open questions for the core and call crates are resolved; the vault crate has three open/deferred OQs (OQ-20, OQ-21, OQ-22) that do not block implementation.
## Architecture Documents
@@ -25,6 +25,11 @@ last_updated: 2026-06-21
| [crates/call/README.md](crates/call/README.md) | draft | alknet-call crate index |
| [crates/call/call-protocol.md](crates/call/call-protocol.md) | draft | CallAdapter, EventEnvelope framing, stream model, PendingRequestMap, bidirectional calls, streaming subscribe example |
| [crates/call/operation-registry.md](crates/call/operation-registry.md) | draft | OperationSpec, Handler, OperationRegistry, AccessControl, capability injection, service discovery, irpc integration |
| [crates/vault/README.md](crates/vault/README.md) | draft | alknet-vault crate index |
| [crates/vault/mnemonic-derivation.md](crates/vault/mnemonic-derivation.md) | draft | BIP39, SLIP-0010, BIP-0032, derivation paths, key types |
| [crates/vault/encryption.md](crates/vault/encryption.md) | draft | AES-256-GCM, EncryptedData, key versioning, salt (Phase B reserved) |
| [crates/vault/service.md](crates/vault/service.md) | draft | VaultServiceHandle lifecycle, actor dispatch, cache, error model |
| [crates/vault/protocol.md](crates/vault/protocol.md) | draft | VaultProtocol irpc messages, DerivedKey redaction, serialization |
## ADR Table
@@ -47,6 +52,8 @@ last_updated: 2026-06-21
| [015](decisions/015-privilege-model-and-authority-context.md) | Privilege Model and Authority Context | Accepted |
| [016](decisions/016-abort-cascade-for-nested-calls.md) | Abort Cascade for Nested Calls | Accepted |
| [017](decisions/017-call-protocol-client-and-adapter-contract.md) | Call Protocol Client and Adapter Contract | Accepted |
| [018](decisions/018-vault-standalone-crate.md) | Vault as Standalone Crate | Accepted |
| [019](decisions/019-vault-assembly-layer-only.md) | Vault Assembly-Layer-Only Access | Accepted |
## Open Questions
@@ -73,11 +80,14 @@ See [open-questions.md](open-questions.md) for the full tracker.
- **OQ-14**: Batch operation semantics — multiple correlated `call.requested` events is the correct protocol design, not a simplification
- **OQ-19**: Session-scoped registries — agent-written operations via `OperationEnv` trait layering; protocol doesn't need changes; `OperationEnv` must remain a trait
**Open (low priority, does not block implementation):**
- **OQ-20**: Salt/KDF Phase B — the `EncryptedData.salt` field is reserved; v1 does not use it. Two-way door.
- **OQ-22**: Key rotation mechanism — key versioning is in place; the rotation workflow is not specced. Two-way door.
**Deferred (not active):**
- **OQ-09**: WASM target boundaries — design constraint, not deliverable
- **OQ-10**: Git adapter scope — start with smart protocol, add ERC721 later
**All open questions are resolved.** No open one-way or two-way doors remain. The architecture is ready for review.
- **OQ-21**: Remote vault administration — network unlock not supported; needs ADR if ever needed
## Document Lifecycle

View File

@@ -0,0 +1,131 @@
---
status: draft
last_updated: 2026-06-19
---
# alknet-vault
Local key vault: BIP39 mnemonic generation, SLIP-0010 Ed25519 HD key
derivation, BIP-0032 secp256k1 derivation (feature-gated), and AES-256-GCM
encryption. Holds the master seed — the root of trust for all derived keys
and encrypted credentials in the alknet system.
## What This Crate Is
alknet-vault is a **standalone crate** with zero alknet crate dependencies
(ADR-018). It provides the cryptographic primitives and runtime API for
managing the root of trust. The CLI binary (the `alknet` crate) is the sole
component that talks to the vault directly (ADR-019) — handlers receive
derived/decrypted material through capabilities, never through a vault
reference.
The vault is **not a network service**. It has no ALPN, no
`ProtocolHandler` implementation, and no operations registered in the call
protocol (ADR-008, ADR-014). The master seed and derived private keys never
cross the network.
## Documents
| Document | Status | Description |
|----------|--------|-------------|
| [mnemonic-derivation.md](mnemonic-derivation.md) | draft | BIP39, SLIP-0010, BIP-0032, derivation paths, key types |
| [encryption.md](encryption.md) | draft | AES-256-GCM, EncryptedData, key versioning, salt (Phase B reserved) |
| [service.md](service.md) | draft | VaultServiceHandle lifecycle, actor dispatch, cache, error model |
| [protocol.md](protocol.md) | draft | VaultProtocol irpc messages, DerivedKey redaction, serialization |
## Applicable ADRs
| ADR | Title | Relevance |
|-----|-------|-----------|
| [003](../../decisions/003-crate-decomposition.md) | Crate Decomposition | alknet-vault's standalone position |
| [005](../../decisions/005-irpc-as-call-protocol-foundation.md) | irpc as Call Protocol Foundation | VaultProtocol uses irpc directly |
| [008](../../decisions/008-secret-service-integration.md) | Vault Integration Point | CLI-embedded, capability source |
| [014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Secret Material Flow and Capability Injection | Capabilities carry vault-derived material |
| [018](../../decisions/018-vault-standalone-crate.md) | Vault as Standalone Crate | Zero alknet crate dependencies |
| [019](../../decisions/019-vault-assembly-layer-only.md) | Vault Assembly-Layer-Only Access | The assembly layer is the sole caller |
## Relevant Open Questions
| OQ | Title | Status | Relevance |
|----|-------|--------|-----------|
| OQ-20 | Salt/KDF Phase B | open | Salt field is reserved; v1 does not use it |
| OQ-21 | Remote vault administration | deferred | Network unlock not supported; needs ADR if ever needed |
| OQ-22 | Key rotation mechanism | open | Key versioning is in place; rotation workflow is not specced |
## Key Design Principles
1. **Standalone**: The vault depends on no alknet crate. It defines its own
types, errors, and protocol. External crates depend on the vault; the
vault depends on nothing in alknet.
2. **Assembly-layer only**: The vault's API is consumed by the CLI binary,
not by handlers. Handlers receive material through capabilities
(ADR-014). The vault is not on the wire.
3. **Zeroize everything sensitive**: The mnemonic, seed, derived private
keys, encryption keys, and cached keys all implement `Zeroize` and
`ZeroizeOnDrop`. Secret material does not linger in freed heap memory.
4. **Deterministic derivation**: The same mnemonic + passphrase + path
always produces the same key. Derivation is reproducible across runs
and across nodes.
5. **OsRng for nonces**: AES-GCM IVs and any cryptographic nonces use
`OsRng` (or equivalent CSPRNG), never `rand::random()`. IV reuse under
the same key is catastrophic for GCM.
6. **No `unwrap()` or `expect()` outside tests**: vault operations
propagate errors. A poisoned lock is recovered with
`unwrap_or_else(|e| e.into_inner())`, not `unwrap()`. A panic in one
vault operation must not brick the vault for all other operations.
## Security Constraints
These are security-critical implementation requirements, not architectural
decisions (the architecture is locked by the ADRs above). They are
documented here so implementation agents don't miss them. See
[service.md → Security Constraints](service.md#security-constraints) for
the full list.
- **OsRng for IVs**: AES-GCM IVs must use `OsRng`, not `rand::random()`. The
current source uses `rand::random()` — this is a known drift from the
spec and must be corrected during implementation sync.
- **Zeroized drop**: `Seed`, `Mnemonic`, `ExtendedPrivKey`,
`Secp256k1ExtendedPrivKey`, `EncryptionKey`, `CachedKey`, and
`DerivedKey` all derive `Zeroize` and `ZeroizeOnDrop`. The cache must
clear on drop, not just on explicit `lock()`.
- **No `unwrap()` outside tests**: poisoned lock recovery uses
`unwrap_or_else(|e| e.into_inner())` or explicit error propagation. The
current source uses `unwrap()` in `VaultServiceHandle` methods — this
is a known drift and must be corrected.
- **DerivedKey redaction in JSON**: `DerivedKey` serializes the
`private_key` as `"[REDACTED]"` in human-readable formats (JSON) and as
raw bytes in binary formats (postcard). The redaction is a defense-in-
depth measure, not the primary control — the primary control is that
`DerivedKey` never crosses the call protocol wire (ADR-014).
## Public API
The vault re-exports its primary types from the crate root:
```rust
// Mnemonic and seed
pub use mnemonic::{Language, Mnemonic, Seed};
// Derivation
pub use derivation::{DerivationError, ExtendedPrivKey, PATHS};
// Encryption
pub use encryption::{EncryptedData, EncryptionError};
// Protocol (irpc messages)
pub use protocol::{DerivedKey, KeyType, VaultMessage, VaultProtocol};
// Service (runtime)
pub use service::{VaultService, VaultServiceActor, VaultServiceError, VaultServiceHandle};
// Cache
pub use cache::CacheConfig;
```
The `secp256k1` feature flag gates Ethereum (BIP-0032) derivation:
```rust
#[cfg(feature = "secp256k1")]
pub mod ethereum;
```

View File

@@ -0,0 +1,215 @@
---
status: draft
last_updated: 2026-06-19
---
# Encryption
AES-256-GCM encryption and decryption for external credentials that cannot
be derived from the seed.
## What
External credentials (API keys, OAuth tokens, signing keys obtained from
third parties) cannot be derived from the BIP39 seed — they're arbitrary
bytes, not deterministic functions of the seed. The vault encrypts these
with a key *derived from* the seed, producing an `EncryptedData` blob that
can be stored outside the vault (in a config file, a database, or external
storage) and decrypted later with the same seed.
This is the second axis of the vault's secret model:
| Axis | Source | Mechanism | Example |
|------|--------|-----------|---------|
| Derived keys | Seed → HD derivation | Deterministic | Node identity, SSH host key |
| Encrypted credentials | External → AES-256-GCM | Seed-derived key | Google API key, OAuth token |
## Why AES-256-GCM
AES-256-GCM is an authenticated encryption scheme — it provides both
confidentiality (encryption) and integrity (authentication tag). A
tampered ciphertext fails decryption. This is the correct mode for
credential storage: if an attacker modifies an encrypted API key in
storage, decryption fails rather than producing a different (potentially
dangerous) plaintext.
GCM is also hardware-accelerated on modern CPUs (AES-NI), making it fast
enough that encryption is never a bottleneck.
## Encryption Key
The encryption key is derived from the seed at path `m/74'/2'/0'/0'`
(`PATHS::ENCRYPTION`):
```rust
pub struct EncryptionKey {
key_bytes: [u8; 32], // 32-byte AES-256 key
key_version: u32, // for rotation tracking
}
```
- `new(key_bytes, key_version)`: Construct from raw bytes.
- `from_derived_bytes(bytes, key_version)`: Take the first 32 bytes of
derived key material (the private key bytes from SLIP-0010 derivation).
- `version()`: Return the key version (for rotation).
`EncryptionKey` implements `Zeroize` and `ZeroizeOnDrop` — the key bytes
are zeroized before deallocation.
The key is derived once (at unlock time or on first encrypt/decrypt) and
cached in the `KeyCache` (see [service.md](service.md)). Subsequent
encrypt/decrypt operations use the cached key.
## EncryptedData
The encrypted blob format. This is the **stable wire format** shared with
`alknet-storage` (a future crate) by type-level agreement, not by a crate
dependency. Both crates must agree on the serialization format.
A TypeScript `EncryptedDataSchema` from the `@alkdev/storage` library
predates the Rust implementation. The Rust `EncryptedData` is a superset
of the TypeScript schema. The migration path is: re-encrypt
TypeScript-encrypted data using the Rust vault with a new key version.
This cross-language compatibility is why the wire format must stay stable —
changing it breaks both `alknet-storage` and the TypeScript consumer.
```rust
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct EncryptedData {
pub key_version: u32, // rotation tracking
pub salt: String, // base64, 32 bytes — reserved for Phase B (see OQ-20)
pub iv: String, // base64, 12 bytes — AES-GCM nonce
pub data: String, // base64 — ciphertext + auth tag
}
```
All binary fields are base64-encoded as strings for JSON serialization
compatibility. The `iv` is 12 bytes (the standard GCM nonce size). The
`data` field includes the GCM authentication tag appended to the ciphertext
(the `aes-gcm` crate handles this).
### Salt field (reserved for Phase B)
The `salt` field is **reserved for future KDF-based key derivation** (Phase
B, OQ-20). In v1, the encryption key is derived directly from the seed at
path `m/74'/2'/0'/0'` **without using the salt**. The salt is generated
randomly (32 bytes) and stored in `EncryptedData.salt` for forward
compatibility, but it plays no role in the v1 key derivation process.
When key rotation is implemented in Phase B, the salt will be used as
input to HKDF or PBKDF2 for stretch-based key derivation, allowing the
same seed to produce different encryption keys without changing the
derivation path. The wire format does not need to change — the `salt`
field is already present and populated.
This is a deliberate forward-compatibility decision: the field exists in
v1 so that v2 can use it without a format migration. The cost is 32 extra
bytes per `EncryptedData`; the benefit is no future format break.
## Encrypt and Decrypt
```rust
pub fn encrypt(plaintext: &str, key: &EncryptionKey) -> Result<EncryptedData, EncryptionError>;
pub fn decrypt(encrypted: &EncryptedData, key: &EncryptionKey) -> Result<String, EncryptionError>;
```
`encrypt`:
1. Generates a random 12-byte IV (must use `OsRng` — see Security Constraints)
2. Generates a random 32-byte salt (stored, not used in v1)
3. Encrypts the plaintext with AES-256-GCM
4. Returns `EncryptedData { key_version, salt, iv, data }`
`decrypt`:
1. Decodes the base64 IV and ciphertext
2. Decrypts with AES-256-GCM (verifies the auth tag)
3. Returns the plaintext string
The IV is generated fresh for each encryption call. **IV reuse under the
same key is catastrophic for GCM** (authenticity breaks, two-time-pad on
plaintext). The use of `OsRng` for IV generation is a security-critical
constraint — see below.
## Key Versioning
`CURRENT_KEY_VERSION` is `1`. Key versioning allows re-encryption when the
encryption key is rotated:
1. Derive a new key from a new derivation path or new seed
2. Decrypt all existing `EncryptedData` with key version 1
3. Re-encrypt with key version 2
4. Update storage
The key version is stored in `EncryptedData.key_version` so decryption can
select the right key. The rotation workflow itself is not specced — see
OQ-22.
## Errors
```rust
pub enum EncryptionError {
Encryption(String), // encryption failed
Decryption(String), // decryption failed (wrong key, tampered data, bad UTF-8)
Decoding(String), // base64 decoding failed
KeyVersionMismatch { expected: u32, actual: u32 }, // reserved for Phase B
}
```
Decryption failures are intentionally generic — they don't distinguish
"wrong key" from "tampered data" from "corrupted storage" to avoid
leaking information to an attacker.
`KeyVersionMismatch` is **defined but unused in v1** — neither `encrypt()`
nor `decrypt()` returns it. It is reserved for Phase B key rotation (OQ-22),
where the vault may enforce version matching before decrypting. In v1, the
`key_version` is stamped onto `EncryptedData` and `EncryptionKey` for
forward compatibility but does not gate decryption. An implementer should
not expect this variant to fire in v1.
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| AES-256-GCM for credential encryption | — | Authenticated encryption, hardware-accelerated |
| Salt reserved for Phase B (OQ-20) | — | Forward-compatible wire format; v1 doesn't use salt |
| Key derived at `m/74'/2'/0'/0'` | — | Dedicated account for encryption keys |
| Key versioning | — | Rotation support without format break |
| All fields base64-encoded | — | JSON serialization compatibility |
## Open Questions
See [open-questions.md](../../open-questions.md) for full details.
- **OQ-20** (open): Salt/KDF Phase B — when and how to use the reserved
`salt` field for KDF-based key derivation.
- **OQ-22** (open): Key rotation mechanism — the key versioning is in place,
but the rotation workflow (re-encrypt all data, update storage) is not
specced.
## Security Constraints
These are security-critical implementation requirements.
- **OsRng for IVs**: The IV must be generated with `OsRng` (or an
equivalent CSPRNG), never `rand::random()`. IV reuse under the same key
is catastrophic for GCM — it breaks authenticity and creates a
two-time-pad on the plaintext. **The current source uses
`rand::random()` for IV generation (`encryption.rs` line 133) — this is a
known drift from the spec and must be corrected during implementation
sync.** `rand::random()` uses the thread-local RNG which may not be a
CSPRNG on all platforms; `OsRng` reads from the operating system's
entropy source and is the correct choice for cryptographic nonces.
- **Zeroized drop**: `EncryptionKey` derives `Zeroize` and
`ZeroizeOnDrop`. The key bytes are zeroized before deallocation. Do not
store key material in types that don't zeroize.
- **No plaintext in logs**: `EncryptedData` is safe to log (it's
ciphertext). The plaintext and the `EncryptionKey` are not. Do not add
`Debug` or `Display` implementations that print key bytes or plaintext.
## References
- [NIST SP 800-38D](https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-38d.pdf) —
AES-GCM specification
- Implementation: `crates/alknet-vault/src/encryption.rs`
- Tests: `crates/alknet-vault/tests/test_vectors.rs`,
`crates/alknet-vault/src/encryption.rs` (unit tests)
- [service.md](service.md) — how the vault caches the encryption key

View File

@@ -0,0 +1,301 @@
---
status: draft
last_updated: 2026-06-19
---
# Mnemonic and Key Derivation
BIP39 mnemonic generation, SLIP-0010 Ed25519 HD key derivation, BIP-0032
secp256k1 derivation (feature-gated), and the derivation path constants that
alknet uses.
## What
The vault derives keys from a single root: a BIP39 mnemonic. From one
mnemonic, all self-generated secrets are derived on demand via
hierarchical deterministic (HD) derivation. This is the same model as
cryptocurrency wallets — one seed phrase, many derived keys.
Two derivation schemes are supported:
| Scheme | Curve | Standard | Paths | Feature |
|--------|-------|----------|-------|---------|
| SLIP-0010 | Ed25519 | HMAC-SHA512 with `"ed25519 seed"` | Hardened only | default |
| BIP-0032 | secp256k1 | HMAC-SHA512 with `"Bitcoin seed"` | Hardened + unhardened | `secp256k1` |
Ed25519 is the default — it's what alknet's TLS identity (ADR-010), SSH
host keys, and signing keys use. secp256k1 is feature-gated for Ethereum
signing (the standard Ethereum path `m/44'/60'/0'/0/0` requires
unhardened indices, which SLIP-0010 cannot handle).
## Why HD Derivation
HD derivation lets one seed produce an unlimited number of keys at
deterministic paths. This means:
- **No key storage**: keys are derived on demand, not stored. The vault
caches derived keys for performance, but the cache is rebuildable from
the seed.
- **Reproducible across nodes**: the same mnemonic on a different node
produces the same keys. A backup node derives the same identity key.
- **Domain separation**: different paths produce different keys. The
identity key, SSH host key, encryption key, and signing keys are all
cryptographically independent despite coming from one seed.
- **Auditable derivation**: the path records what a key is for.
`m/74'/0'/0'/0'` is the identity key; `m/74'/0'/1'/0'` is the SSH host
key. The path is the documentation.
## BIP39 Mnemonic
The root of trust is a BIP39 mnemonic seed phrase. The vault generates,
validates, and derives seeds from mnemonics.
```rust
pub struct Mnemonic {
phrase: String, // zeroized on drop
}
impl Mnemonic {
pub fn generate(word_count: usize) -> Result<Self, MnemonicError>;
pub fn from_phrase(phrase: &str, language: Language) -> Result<Self, MnemonicError>;
pub fn to_seed(&self, passphrase: Option<&str>) -> Seed;
pub fn phrase(&self) -> &str;
}
```
- `generate(word_count)`: Generate a new random mnemonic. Supported word
counts: 12, 15, 18, 21, 24. The mnemonic is the root of trust — store it
securely.
- `from_phrase(phrase, language)`: Restore from an existing phrase.
Validates against the BIP39 word list and checksum.
- `to_seed(passphrase)`: Derive the 64-byte master seed. The passphrase is
the optional BIP39 password extension (the "25th word"). Different
passphrases produce different seeds.
- `phrase()`: Return the phrase string. Handle with care — this is the
root of trust.
`Mnemonic` implements `Zeroize` and `Drop` — the phrase is zeroized
before deallocation. Only English is supported (matching the BIP39
reference and the majority of wallet software).
### Seed
```rust
#[derive(Clone, Zeroize)]
#[zeroize(drop)]
pub struct Seed {
bytes: Vec<u8>, // 64 bytes, zeroized on drop
}
```
The 64-byte seed from which all HD keys are derived. Zeroized on drop.
This is the input to SLIP-0010 / BIP-0032 master key derivation.
## SLIP-0010 Ed25519 Derivation
The default derivation scheme. SLIP-0010 specifies Ed25519 HD key
derivation using HMAC-SHA512 with the key `"ed25519 seed"`.
```rust
pub fn derive_path_from_seed(seed: &[u8], path: &str) -> Result<ExtendedPrivKey, DerivationError>;
```
### Master key derivation
The master key is derived from the seed via HMAC-SHA512:
```
HMAC-SHA512(key = "ed25519 seed", data = seed)
→ first 32 bytes: private key (kL)
→ next 32 bytes: chain code
```
The `ed25519-bip32` crate handles the extended key format (kL || kR ||
chain code). The vault extracts the first 32 bytes as the private key and
the public key (32 bytes) via `XPrv::public()`.
### Child derivation
SLIP-0010 Ed25519 supports **hardened child derivation only**. Every child
index must have the `'` (or `h`) suffix, meaning `index + 0x80000000`.
Unhardened indices are rejected by the derivation logic (Ed25519 cannot
support them because public key derivation is not possible without the
private key).
### Path parsing
```rust
pub fn parse_derivation_path(path: &str) -> Result<Vec<u32>, DerivationError>;
```
Parses paths like `m/74'/0'/0'/0'` into child indices. The `m` prefix is
required. Hardened indices have `'` or `h` suffix; unhardened indices are
allowed in the parser (for BIP-0032 paths) but Ed25519 derivation will
fail on them.
### ExtendedPrivKey
```rust
#[derive(Clone, Zeroize)]
#[zeroize(drop)]
pub struct ExtendedPrivKey {
private_key: Vec<u8>, // 32 bytes
public_key: Vec<u8>, // 32 bytes
chain_code: Vec<u8>, // 32 bytes
path: String, // the path that produced this key
}
```
The result of SLIP-0010 derivation. Zeroized on drop. Accessors return
slices — the caller copies what it needs.
## BIP-0032 secp256k1 Derivation (Ethereum)
Feature-gated behind `secp256k1`. Implements BIP-0032 HD key derivation for
the secp256k1 curve, used for Ethereum signing keys.
```rust
#[cfg(feature = "secp256k1")]
pub fn derive_secp256k1_path(seed: &[u8], path: &str) -> Result<Secp256k1ExtendedPrivKey, DerivationError>;
```
Unlike SLIP-0010 (Ed25519), BIP-0032 supports both hardened and
unhardened child derivation. The standard Ethereum path
`m/44'/60'/0'/0/0` uses unhardened indices for the last two levels.
### Why a separate module
SLIP-0010 and BIP-0032 differ in:
| Aspect | SLIP-0010 (Ed25519) | BIP-0032 (secp256k1) |
|--------|---------------------|----------------------|
| HMAC key | `"ed25519 seed"` | `"Bitcoin seed"` |
| Child derivation | Hardened only | Hardened + unhardened |
| Public key size | 32 bytes | 33 bytes (compressed) |
| Public derivation | Not possible | Possible (unhardened) |
The `secp256k1` crate is a heavy dependency (it includes a C library for
curve operations). Feature-gating it keeps the default vault lightweight —
nodes that don't need Ethereum signing don't pay the cost.
When the feature is disabled, `derive_ethereum_key` returns
`VaultServiceError::UnsupportedKeyType`.
## Derivation Paths
alknet reserves the `74'` coin type (unallocated per SLIP-0044) for its
keys. Well-known paths are constants in the `PATHS` module:
```rust
pub mod PATHS {
pub const IDENTITY: &str = "m/74'/0'/0'/0'"; // Primary identity keypair
pub const DEVICE_PREFIX: &str = "m/74'/0'/0'"; // Worker/device identity prefix
pub const SSH_HOST: &str = "m/74'/0'/1'/0'"; // SSH host key
pub const ENCRYPTION: &str = "m/74'/2'/0'/0'"; // AES-256-GCM encryption key
pub const ETHEREUM: &str = "m/44'/60'/0'/0/0"; // Ethereum signing key (secp256k1)
}
```
Helper functions construct parameterized paths:
```rust
pub fn device_path(index: u32) -> String; // m/74'/0'/0'/{index}'
pub fn site_password_path(site_hash: &str) -> String; // m/74'/1'/0'/{site_hash}'
```
### Path semantics
| Path | Purpose | Key type | Used by |
|------|---------|----------|---------|
| `m/74'/0'/0'/0'` | Primary node identity (Ed25519) | Ed25519 | TLS raw key (ADR-010), node identity |
| `m/74'/0'/0'/{n}'` | Worker/device identity | Ed25519 | Multi-device nodes, workers |
| `m/74'/0'/1'/0'` | SSH host key | Ed25519 | SSH handler |
| `m/74'/1'/0'/{hash}'` | Site-specific deterministic password | Ed25519 bytes | Per-site passwords (not cached) |
| `m/74'/2'/0'/0'` | Encryption key for external credentials | AES-256-GCM | Credential encryption (see [encryption.md](encryption.md)) |
| `m/44'/60'/0'/0/0` | Ethereum signing key | secp256k1 | Ethereum signing (feature-gated) |
### Path namespace discipline
The `74'` coin type is alknet's namespace. Sub-paths follow a convention:
| Account (`/X'`) | Purpose |
|-----------------|---------|
| `0'` | Identity keys (node, devices, SSH) |
| `1'` | Deterministic passwords |
| `2'` | Encryption keys (external credentials) |
New key purposes should allocate a new account index, not reuse an
existing one. This prevents cross-purpose key collisions.
### The `74'` coin type is a one-way door
The `74'` coin type is **committed** — once keys are derived at `m/74'/...`,
changing the coin type breaks every existing key. Every node's identity,
SSH host key, and encryption key is derived at a `74'`-rooted path. This is
effectively a one-way door per ADR-009: reversal requires re-deriving every
key from the seed at a new coin type and re-deploying all nodes. The
reservation is documented inline rather than in a separate ADR because it
is a single, self-evident commitment (the coin type is the alknet
namespace; there is no alternative to evaluate). The SLIP-0044 registry
lists `74'` as unallocated, so there is no collision risk with other
projects.
## Key Types
```rust
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum KeyType {
Ed25519, // SLIP-0010 derivation
Aes256Gcm, // Symmetric key (derived from seed, used for encryption)
Secp256k1, // BIP-0032 derivation (Ethereum, feature-gated)
}
```
`KeyType` tags `DerivedKey` (see [protocol.md](protocol.md)) and
`CachedKey` (see [service.md](service.md)) so consumers know what they
received without inspecting byte lengths.
## Determinism
Derivation is deterministic: the same mnemonic + passphrase + path
always produces the same key. This is verified by regression tests in
`tests/test_vectors.rs` against the BIP39 "abandon...about" test vector.
### Passphrase sensitivity
Different passphrases produce different seeds and therefore different
keys. The passphrase is a legitimate access-control mechanism: two
operators with the same mnemonic but different passphrases get different
keysets. The vault does not enforce a passphrase policy — that's an
assembly-layer concern.
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| Vault is standalone | [ADR-018](../../decisions/018-vault-standalone-crate.md) | Zero alknet crate dependencies |
| HD derivation (not stored keys) | — | One seed, many keys, no key storage |
| `74'` coin type reserved for alknet | — | SLIP-0044 unallocated; alknet namespace |
| secp256k1 feature-gated | — | Heavy dep; only needed for Ethereum |
| Hardened-only for Ed25519 | SLIP-0010 | Ed25519 cannot do public derivation |
## Open Questions
See [open-questions.md](../../open-questions.md) for full details.
- **OQ-20** (open): Salt/KDF Phase B — the `EncryptedData.salt` field is
reserved; v1 does not use it. See [encryption.md](encryption.md).
## References
- [BIP39](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki) —
mnemonic seed phrases
- [SLIP-0010](https://github.com/satoshilabs/slips/blob/master/slip-0010.md) —
Ed25519 HD derivation
- [BIP-0032](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki) —
secp256k1 HD derivation
- [SLIP-0044](https://github.com/satoshilabs/slips/blob/master/slip-0044.md) —
registered coin types (74' is unallocated)
- Implementation: `crates/alknet-vault/src/mnemonic.rs`,
`crates/alknet-vault/src/derivation.rs`, `crates/alknet-vault/src/ethereum.rs`
- Test vectors: `crates/alknet-vault/tests/test_vectors.rs`

View File

@@ -0,0 +1,177 @@
---
status: draft
last_updated: 2026-06-19
---
# Protocol
The `VaultProtocol` irpc message enum, `DerivedKey` type, and serialization
behavior.
## What
The protocol layer defines the message enum that the irpc dispatch
infrastructure uses (ADR-005) and the `DerivedKey` type that derivation
methods return. This is the vault's internal dispatch protocol — not the
alknet call protocol (the vault has no ALPN, ADR-008).
## VaultProtocol
The irpc message enum. The `#[rpc_requests]` macro generates the
`VaultMessage` enum (with `WithChannels` wrappers), `Channels` impls,
`From` impls, and `Service`/`RemoteService` traits for remote dispatch.
```rust
#[rpc_requests(message = VaultMessage, no_spans)]
#[derive(Debug, Serialize, Deserialize)]
pub enum VaultProtocol {
DeriveEd25519 { path: String },
DeriveEncryptionKey { path: String },
DeriveEthereumKey { path: String },
DerivePassword { path: String, length: usize },
Encrypt { plaintext: String, key_version: u32 },
Decrypt { encrypted: EncryptedData },
Lock,
Unlock { mnemonic: String, passphrase: Option<String> },
}
```
Each variant is a vault operation. The `tx` channel type for each variant
is `oneshot::Sender<Result<T, VaultServiceError>>`, where `T` is the
operation's return type (`DerivedKey`, `Vec<u8>`, `EncryptedData`, `String`,
or `()`).
### State requirements
All operations except `Unlock` require the vault to be **unlocked**.
Calling derive/encrypt/decrypt on a locked vault returns
`VaultServiceError::VaultLocked` (not a panic, not a channel close).
### Dispatch
The `VaultServiceActor` (see [service.md](service.md)) processes
`VaultMessage` variants and dispatches to `VaultServiceHandle` methods.
For local in-process use, prefer `VaultServiceHandle` directly — no
channel overhead.
## DerivedKey
The result of key derivation. Holds the key type, private key, and public
key.
```rust
#[derive(Zeroize, Deserialize)]
#[zeroize(drop)]
pub struct DerivedKey {
#[zeroize(skip)]
pub key_type: KeyType, // not secret — tag only
#[zeroize]
pub private_key: Vec<u8>, // zeroized on drop
#[zeroize(skip)]
pub public_key: Vec<u8>, // not secret — public by definition
}
```
The `#[zeroize(skip)]` attributes on `key_type` and `public_key` mean only
the `private_key` is zeroized when the `DerivedKey` is dropped. The public
key and key type are not secret material — zeroizing them is unnecessary
and would require them to derive `Zeroize` (which `KeyType` does not).
### Move-only, not Clone
`DerivedKey` does **not** derive `Clone`. It is move-only. Consumers
receive it by value and zeroize it when done (handled automatically by
`#[zeroize(drop)]`). This prevents accidental duplication of secret
material — there is exactly one copy of the private key, and it is
zeroized when the `DerivedKey` is dropped.
The assembly layer (CLI binary) extracts the bytes it needs (private key
for signing, public key for TLS identity) and constructs the alknet-core
types at the assembly boundary (ADR-018). The `DerivedKey` is then dropped
and zeroized.
### Serialization redaction
`DerivedKey` has a custom `Serialize` impl that redacts the private key in
human-readable formats:
- **JSON** (human-readable): `private_key` serializes as `"[REDACTED]"`.
This is defense-in-depth — if a `DerivedKey` accidentally ends up in a
log or a JSON config, the private key is not exposed.
- **postcard** (binary, used by irpc): `private_key` serializes as the
actual bytes. This is required for in-cluster irpc dispatch to work —
the remote side needs the actual key bytes.
- **Deserialization**: always reads the full bytes, regardless of format.
A JSON-deserialized `DerivedKey` will have `"[REDACTED]"` as its
`private_key` string — this is expected; JSON round-tripping a
`DerivedKey` is not a supported use case (the private key is gone).
The redaction is **not the primary control** for keeping private keys off
the wire. The primary control is architectural: `DerivedKey` never appears
in call protocol payloads (ADR-014). The redaction is a safety net for
logging accidents and debug output.
### Debug redaction
`DerivedKey`'s `Debug` impl also redacts the private key:
```rust
impl fmt::Debug for DerivedKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DerivedKey")
.field("key_type", &self.key_type)
.field("private_key", &"[REDACTED]")
.field("public_key", &self.public_key)
.finish()
}
}
```
`{:?}` on a `DerivedKey` never exposes the private key. This makes it safe
to use in `tracing` spans and error messages.
## KeyType
```rust
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum KeyType {
Ed25519, // SLIP-0010 derivation (32-byte private + 32-byte public)
Aes256Gcm, // Symmetric key (32 bytes, used for encryption)
Secp256k1, // BIP-0032 derivation (32-byte private + 33-byte compressed public)
}
```
Tags `DerivedKey` and `CachedKey` so consumers know what they received.
`KeyType` is `Serialize`/`Deserialize` (it's part of the irpc protocol) and
`Clone` (it's not secret material — it's a tag).
## Wire Format
For local (in-process) calls, the protocol uses tokio channels directly —
no serialization. For remote (in-cluster) calls, the protocol is serialized
with postcard (binary, compact). For cross-node (call protocol) exposure,
the vault is wrapped in an operation that serializes to JSON — but **no
vault operations are exposed over the call protocol** (ADR-014). The JSON
serialization path exists only for the `DerivedKey` redaction safety net.
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| irpc for vault dispatch | [ADR-005](../../decisions/005-irpc-as-call-protocol-foundation.md) | In-process type-safe dispatch |
| `DerivedKey` is move-only | [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Prevents accidental duplication of secret material |
| JSON redacts private key | [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Defense-in-depth for logging accidents |
| postcard preserves private key | — | Required for in-cluster irpc dispatch |
| No vault operations on call protocol | [ADR-008](../../decisions/008-secret-service-integration.md), [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Master seed never crosses the network |
## Open Questions
None active for this document.
## References
- Implementation: `crates/alknet-vault/src/protocol.rs`
- Tests: `crates/alknet-vault/src/protocol.rs` (unit tests for redaction
and zeroize behavior)
- [service.md](service.md) — how the actor dispatches `VaultMessage`
- [mnemonic-derivation.md](mnemonic-derivation.md) — what `KeyType` means

View File

@@ -0,0 +1,361 @@
---
status: draft
last_updated: 2026-06-19
---
# Service
The `VaultServiceHandle` runtime API: unlock/lock lifecycle, key
derivation, encryption, caching, and the actor dispatch path.
## What
The service layer wraps the vault's cryptographic primitives in a
stateful runtime with a clear lifecycle. It holds the master seed in
`Zeroize`-protected memory and provides methods for the unlock/lock
lifecycle, key derivation, and encryption/decryption.
This is the API the assembly layer (CLI binary) calls. No other component
calls these methods directly (ADR-019).
## VaultServiceHandle
The primary API for local (in-process) use. Thread-safe via
`Arc<RwLock<VaultServiceInner>>`.
```rust
#[derive(Clone)]
pub struct VaultServiceHandle {
inner: Arc<RwLock<VaultServiceInner>>,
}
struct VaultServiceInner {
mnemonic: Option<Mnemonic>, // None if locked
seed: Option<Seed>, // None if locked
unlocked: bool,
cache: KeyCache, // TTL + LRU, see Cache section
}
```
`VaultServiceHandle` is `Clone` — cloning shares the underlying state via
`Arc`. This is how the actor and the assembly layer share the same vault.
## Lifecycle
```
Locked (initial state)
│ unlock(phrase, passphrase) / unlock_new(word_count)
Unlocked — derive, encrypt, decrypt available
│ lock()
Locked — seed and cache purged
```
### unlock(phrase, passphrase)
```rust
pub fn unlock(&self, phrase: &str, passphrase: Option<&str>) -> Result<(), VaultServiceError>;
```
Unlock with an existing mnemonic phrase. Validates the phrase against the
BIP39 word list, derives the seed, and stores both in `VaultServiceInner`.
Returns `AlreadyUnlocked` if the vault is already unlocked.
The passphrase is the BIP39 password extension (the "25th word"). `None`
means no passphrase (equivalent to empty string). Different passphrases
produce different seeds.
### unlock_new(word_count) → phrase
```rust
pub fn unlock_new(&self, word_count: usize) -> Result<String, VaultServiceError>;
```
Generate a new random mnemonic, unlock with it, and return the phrase.
Store the returned phrase securely — it is the root of trust. Supported
word counts: 12, 15, 18, 21, 24.
This is the "first run" path — a new node generates its mnemonic, writes
it down, and the vault is unlocked for the process lifetime.
### lock()
```rust
pub fn lock(&self);
```
Purge the seed, mnemonic, and all cached derived keys. Calls `zeroize()`
on all sensitive material. After locking, no derive/encrypt/decrypt
operations are possible until `unlock` is called again.
`lock()` on an already-locked service is a no-op (not an error).
### is_unlocked()
```rust
pub fn is_unlocked(&self) -> bool;
```
Check whether the vault is currently unlocked. Cheap (read lock only).
## Derive Methods
All derive methods require an unlocked vault and return
`VaultServiceError::VaultLocked` if called while locked.
### derive_ed25519(path) → DerivedKey
```rust
pub fn derive_ed25519(&self, path: &str) -> Result<DerivedKey, VaultServiceError>;
```
Derive an Ed25519 keypair at the given SLIP-0010 path. Checks the cache
first; on a miss, derives from the seed and caches the result. Returns a
`DerivedKey` with `KeyType::Ed25519`.
### derive_encryption_key(path) → DerivedKey
```rust
pub fn derive_encryption_key(&self, path: &str) -> Result<DerivedKey, VaultServiceError>;
```
Derive an AES-256-GCM encryption key at the given path. Same cache
behavior as `derive_ed25519`. Returns a `DerivedKey` with
`KeyType::Aes256Gcm`.
### derive_ethereum_key(path) → DerivedKey (feature-gated)
```rust
pub fn derive_ethereum_key(&self, path: &str) -> Result<DerivedKey, VaultServiceError>;
```
Derive a secp256k1 keypair at the given BIP-0032 path. Returns
`UnsupportedKeyType` when the `secp256k1` feature is disabled. Returns a
`DerivedKey` with `KeyType::Secp256k1` (33-byte compressed public key).
### derive_password(path, length) → Vec<u8>
```rust
pub fn derive_password(&self, path: &str, length: usize) -> Result<Vec<u8>, VaultServiceError>;
pub fn derive_password_string(&self, path: &str, length: usize) -> Result<String, VaultServiceError>;
```
Derive deterministic password bytes at the given path, truncated to
`length`. This is **not cached** — password derivation is cheap and
passwords are typically one-shot (derive, use, discard). The string
variant base64url-encodes the bytes (URL-safe, no padding).
`derive_password` is the mechanism for per-site deterministic passwords:
the same seed + path always produces the same password. The path includes
a site hash (`site_password_path(site_hash)`) so different sites get
different passwords.
## Encrypt and Decrypt
### encrypt(plaintext, key_version) → EncryptedData
```rust
pub fn encrypt(&self, plaintext: &str, key_version: u32) -> Result<EncryptedData, VaultServiceError>;
```
Encrypt plaintext using the encryption key derived at `PATHS::ENCRYPTION`.
Derives (and caches) the encryption key on first call, then uses the cache
for subsequent calls. See [encryption.md](encryption.md) for the
cryptographic details.
### decrypt(encrypted) → String
```rust
pub fn decrypt(&self, encrypted: &EncryptedData) -> Result<String, VaultServiceError>;
```
Decrypt an `EncryptedData` blob. Derives (and caches) the encryption key at
`PATHS::ENCRYPTION` if not already cached. The `encrypted.key_version` is
stamped onto the `EncryptionKey` for forward compatibility but **does not
select a different derivation path in v1** — the same key (at
`m/74'/2'/0'/0'`) decrypts any version. Path-per-version routing is a Phase
B concern (OQ-22). See [encryption.md](encryption.md).
## Cache
Derived keys are cached for performance — HD derivation involves HMAC
operations that are not free. The cache is keyed by derivation path and
has TTL-based expiry and LRU eviction.
```rust
pub struct KeyCache {
entries: HashMap<String, CachedKey>,
order: Vec<String>, // LRU ordering
config: CacheConfig,
}
pub struct CacheConfig {
pub ttl: Duration, // default: 1 hour
pub max_entries: usize, // default: 64
}
```
- **TTL**: entries expire after `ttl` (default 1 hour). Expired entries are
evicted lazily on access (`get` checks expiry) or via `evict_expired()`.
- **LRU**: when the cache exceeds `max_entries` (default 64), the least
recently used entry is evicted. Access (`get`) updates the LRU order.
- **Zeroized**: `CachedKey` derives `Zeroize` and `ZeroizeOnDrop`. Evicted
and cleared entries are zeroized — derived private keys do not linger in
freed heap memory.
- **Cleared on lock**: `lock()` calls `cache.clear()`, which removes and
zeroizes all entries.
### What is and isn't cached
| Operation | Cached? | Why |
|-----------|---------|-----|
| `derive_ed25519` | Yes | Derivation is expensive; keys are reused |
| `derive_encryption_key` | Yes | Same — encryption key reused across calls |
| `derive_ethereum_key` | Yes | Same |
| `derive_password` | No | Cheap derivation; passwords are one-shot |
| `encrypt` / `decrypt` | Key cached | The encryption key (at `PATHS::ENCRYPTION`) is cached; the plaintext is not |
`derive_password` does not cache because it's a truncation of derived
bytes, not a keypair that's reused. Caching it would grow the cache with
unique paths (one per site hash) for no reuse benefit.
## Actor Dispatch
The `VaultServiceActor` processes `VaultMessage` variants from an mpsc
channel and dispatches to `VaultServiceHandle` methods. This is the irpc
dispatch mechanism (ADR-005) — the in-process actor pattern that irpc
services use.
```rust
pub struct VaultServiceActor {
handle: VaultServiceHandle,
}
impl VaultServiceActor {
pub fn new(handle: VaultServiceHandle) -> Self;
pub async fn run(mut self, mut rx: mpsc::Receiver<VaultMessage>);
pub fn spawn(handle: VaultServiceHandle) -> (Client<VaultProtocol>, VaultServiceActor);
}
```
- `run(rx)`: Message loop. Each `VaultMessage` variant is dispatched to the
corresponding handle method, and the response is sent through the oneshot
channel embedded in the message. Consumes `self`.
- `spawn(handle)`: Spawn the actor as a `tokio::task` and return a
`Client<VaultProtocol>` for sending messages. **Source bug: the current
`spawn` implementation returns a fresh, unspawned `VaultServiceActor` as
the second tuple element (the spawned actor is consumed by `run`). The
returned actor has no channel and is non-functional. This should be
corrected during implementation sync — either drop the second return
value (return only `Client<VaultProtocol>`) or restructure the API so
the returned actor is the one that was spawned.**
The actor pattern is the irpc dispatch mechanism (ADR-005). For local
in-process use, prefer `VaultServiceHandle` directly — no channel, no
serialization. The actor exists for irpc service dispatch, which is an
in-process pattern (the actor and the handle share state via `Arc`).
### Dispatch paths
| Path | Type | Serialization | Use case |
|------|------|---------------|----------|
| Direct (in-process) | `VaultServiceHandle` method calls | None | CLI binary at startup (the supported path) |
| Actor (in-process) | `VaultMessage` over mpsc | None (channel) | irpc service dispatch (in-process) |
Remote (in-cluster) vault dispatch — where the vault runs as a sidecar
and other processes send `VaultMessage` over a network — is **not
supported** (ADR-019, OQ-21). The irpc `RemoteService` trait infrastructure
exists in the library, but exposing the vault over the network would
require its own ADR with an explicit threat model (the master seed must
never cross the network). The dispatch table above lists only the
supported paths.
The assembly layer (CLI binary) uses the direct path. The actor path
exists for in-process irpc dispatch but is not used by the assembly layer
— it's available for test harnesses and future in-process service
patterns. Neither path is on the alknet call protocol (ADR-008, ADR-014).
## Errors
```rust
#[derive(Debug, thiserror::Error, Serialize, Deserialize)]
pub enum VaultServiceError {
VaultLocked, // called derive/encrypt/decrypt while locked
AlreadyUnlocked, // called unlock while already unlocked
Mnemonic(String), // mnemonic generation/validation failed
Derivation(String), // HD derivation failed (bad path, HMAC error)
Encryption(String), // AES-GCM encrypt/decrypt failed
InvalidPath(String), // derivation path is malformed
UnsupportedKeyType, // secp256k1 called without the feature
}
```
`VaultServiceError` is `Serialize`/`Deserialize` (for irpc dispatch) and
wraps sub-errors as strings. It does not implement `From` for alknet-core
error types — the CLI binary converts at the assembly boundary (ADR-018).
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| Assembly layer is the sole caller | [ADR-019](../../decisions/019-vault-assembly-layer-only.md) | Handlers never hold a vault reference |
| RwLock for thread safety | — | Multiple readers (derive), exclusive writer (unlock/lock) |
| TTL + LRU cache | — | Bounded memory, fresh keys, zeroized eviction |
| Actor for in-cluster dispatch | [ADR-005](../../decisions/005-irpc-as-call-protocol-foundation.md) | irpc message dispatch; not on the call protocol |
| `derive_password` not cached | — | One-shot; caching grows cache with no reuse |
## Open Questions
See [open-questions.md](../../open-questions.md) for full details.
- **OQ-21** (deferred): Remote vault administration — network unlock is not
supported; needs an ADR if ever needed.
## Security Constraints
These are security-critical implementation requirements, not
architectural decisions. They are documented here so implementation agents
don't miss them.
- **OsRng for IVs**: AES-GCM IVs and any cryptographic nonces must use
`OsRng` (or equivalent CSPRNG), not `rand::random()`. IV reuse under the
same key is catastrophic for GCM (authenticity breaks, two-time-pad on
plaintext). **The current source uses `rand::random()` for IV generation
in `encryption::encrypt()` — this is a known drift and must be corrected
during implementation sync.**
- **Zeroized drop**: `Seed`, `Mnemonic`, `CachedKey`, `EncryptionKey`,
`ExtendedPrivKey`, `Secp256k1ExtendedPrivKey`, and `DerivedKey` all
derive `Zeroize` and `ZeroizeOnDrop`. The cache must clear on drop, not
just on explicit `lock()`. **The current `KeyCache::clear()` removes
entries but relies on `CachedKey`'s `Drop` impl for zeroization —
verify that `HashMap::clear()` actually drops the values (it does, but
this is worth a test).**
- **No `unwrap()` or `expect()` outside tests**: poisoned lock recovery
uses `unwrap_or_else(|e| e.into_inner())` or explicit error propagation.
A panic in one vault operation must not brick the vault for all other
operations. **The current source uses `unwrap()` on every `RwLock`
acquisition in `VaultServiceHandle` (lines 142, 161, 182, 191, 196, 227,
264, 307, 340, 367) — this is a known drift and must be corrected. A
poisoned lock should be recovered with `unwrap_or_else(|e|
e.into_inner())`, not panicked.**
- **`DerivedKey` is move-only, not `Clone`**: `DerivedKey` does not derive
`Clone`. It is move-only — consumers receive it by value and zeroize it
when done (handled by `#[zeroize(drop)]`). This prevents accidental
duplication of secret material. **The current source does not derive
`Clone` on `DerivedKey` — this is correct.**
- **Cache eviction zeroizes**: when the cache evicts an entry (LRU or
TTL), the `CachedKey` is dropped, which triggers `ZeroizeOnDrop`. Do not
replace `CachedKey` with a type that doesn't zeroize.
## References
- Implementation: `crates/alknet-vault/src/service.rs`,
`crates/alknet-vault/src/cache.rs`
- Tests: `crates/alknet-vault/tests/service_tests.rs`,
`crates/alknet-vault/src/service.rs` (unit tests),
`crates/alknet-vault/src/cache.rs` (unit tests)
- [protocol.md](protocol.md) — `VaultMessage` and `DerivedKey`
- [encryption.md](encryption.md) — `encrypt` / `decrypt` cryptographic details

View File

@@ -0,0 +1,162 @@
# 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, its own irpc protocol. 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`, `VaultServiceActor` — runtime API
- `VaultProtocol` — irpc message enum (in-process dispatch)
- `VaultServiceError` — its own error enum (string-wrapped sub-errors; the
vault doesn't share an error type with alknet-core)
The `VaultProtocol` uses irpc directly (see ADR-005), not through alknet-call.
This is consistent: irpc is a lightweight framing library, not an alknet
crate. The vault's irpc usage is an in-process dispatch mechanism, not a
network-exposed service.
## Decision
**alknet-vault has zero alknet crate dependencies.** It depends only on
external crates (`bip39`, `ed25519-bip32`, `aes-gcm`, `sha2`, `hmac`,
`secp256k1`, `irpc`, `tokio` for the actor's sync primitives, `serde`,
`zeroize`, `thiserror`, `base64`, `rand`).
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 `Serialize`/`Deserialize`
(for irpc dispatch) and wraps sub-errors as strings. It does not implement
`From` for alknet-core error types — the CLI binary converts at the
assembly boundary.
- `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](../crates/vault/encryption.md)).
## Consequences
**Positive:**
- The vault compiles and runs without QUIC, quinn, iroh, rustls, or a tokio
runtime (the `VaultServiceHandle` works with just `std::sync::RwLock`; the
actor uses `tokio::sync::mpsc` but that's a lightweight dependency).
- 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 (vault uses irpc directly)
- 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](../crates/vault/README.md)
- Implementation: `crates/alknet-vault/`

View File

@@ -0,0 +1,165 @@
# 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`,
`derive_password`, `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. If needed in the future, it requires a
separate, 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](../crates/vault/service.md)
- [crates/vault/README.md](../crates/vault/README.md)

View File

@@ -236,4 +236,33 @@ These questions are acknowledged but not active. They will be promoted to open w
Session-scoped operations run in a locked-down sandbox (no direct net/fs/env access), can only reach operations in the handler's scoped env, and their output should be validated against their declared schema before returning. The promotion path requires review — an agent with a `promote` scope (the architect role) performs the promotion; the writing agent (lower-privileged role) requests it. This is the role-based escalation pattern (ADR-015): privileges escalate through a chain of command, not through direct authority.
The agent-specific mechanism (quickjs sandbox, session registry lifecycle, promotion workflow) belongs to the agent crate spec. The call protocol's job is to keep the `OperationEnv` trait composable and the visibility/ACL model consistent across tiers.
- **Cross-references**: OQ-04, ADR-014, ADR-015, ADR-016, [operation-registry.md](crates/call/operation-registry.md)
- **Cross-references**: OQ-04, ADR-014, ADR-015, ADR-016, [operation-registry.md](crates/call/operation-registry.md)
## Theme: alknet-vault
### OQ-20: Salt/KDF Phase B
- **Origin**: [encryption.md](crates/vault/encryption.md)
- **Status**: open
- **Door type**: Two-way
- **Priority**: low
- **Resolution**: The `EncryptedData.salt` field is reserved for future KDF-based key derivation. In v1, the encryption key is derived directly from the seed at path `m/74'/2'/0'/0'` without using the salt. The salt is generated and stored for forward compatibility but does not participate in key derivation. When KDF-based key derivation (HKDF or PBKDF2 using the salt) is implemented, the wire format does not need to change — the `salt` field is already present. The question of *when* to implement Phase B and *which* KDF to use is open but low-priority: v1's direct derivation is secure; the salt is a forward-compatibility hedge, not a gap. Two-way door — the salt is additive; implementing KDF usage doesn't break v1 data.
- **Cross-references**: [encryption.md](crates/vault/encryption.md)
### OQ-21: Remote Vault Administration
- **Origin**: [service.md](crates/vault/service.md), ADR-019
- **Status**: deferred
- **Door type**: One-way (if implemented)
- **Priority**: low
- **Resolution**: Network unlock of a running node's vault is not supported (ADR-008, ADR-019). The vault is unlocked at startup by the CLI binary from a local mnemonic prompt or file. If a future use case requires remote vault administration (e.g., unlocking a headless node's vault over the network), it requires a separate, heavily restricted mechanism: admin scope (ADR-015), mTLS-only (never expose the mnemonic over an unauthenticated channel), and its own ADR with an explicit threat model. This decision does not close that door; it simply does not open it. Deferred because no current use case requires it.
- **Cross-references**: ADR-008, ADR-014, ADR-019, [service.md](crates/vault/service.md)
### OQ-22: Key Rotation Mechanism
- **Origin**: [encryption.md](crates/vault/encryption.md)
- **Status**: open
- **Door type**: Two-way
- **Priority**: low
- **Resolution**: Key versioning is in place (`EncryptedData.key_version`, `CURRENT_KEY_VERSION = 1`), but the rotation workflow — re-encrypt all existing data with a new key version, update storage — is not specced. The mechanism is straightforward (derive a new key at a new path or from a new seed, decrypt with v1, re-encrypt with v2), but the operational workflow (when to rotate, how to track which data is at which version, how to handle partially-rotated state) needs design. Low priority: keys don't rotate frequently, and v1 is stable. Two-way door — rotation is additive; a v2 key doesn't break v1 data.
- **Cross-references**: [encryption.md](crates/vault/encryption.md)

View File

@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-06-18
last_updated: 2026-06-19
---
# Alknet Overview
@@ -208,6 +208,8 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
| [015](decisions/015-privilege-model-and-authority-context.md) | Privilege Model and Authority Context | `internal` = authority switch not ACL skip; External/Internal visibility; handler identity + scoped env |
| [016](decisions/016-abort-cascade-for-nested-calls.md) | Abort Cascade for Nested Calls | `call.aborted` cascades to descendants; default `abort-dependents`, `continue-running` opt-in |
| [017](decisions/017-call-protocol-client-and-adapter-contract.md) | Call Protocol Client and Adapter Contract | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction |
| [018](decisions/018-vault-standalone-crate.md) | Vault as Standalone Crate | Zero alknet crate dependencies; vault defines own types and errors |
| [019](decisions/019-vault-assembly-layer-only.md) | Vault Assembly-Layer-Only Access | The assembly layer (CLI binary) is the sole direct caller; handlers never hold a vault reference |
## Open Questions
@@ -217,8 +219,11 @@ Open questions are tracked in [open-questions.md](open-questions.md). Key questi
- **OQ-02**: AuthContext resolution timing (resolved: hybrid — see ADR-004)
- **OQ-03**: ALPN string naming convention (resolved: see ADR-006)
- **OQ-04**: Dynamic handler registration at runtime vs static at startup (two-way door, defer to implementation)
- **OQ-08**: Vault integration point (resolved: CLI-embedded, assembly-layer only — see ADR-008, ADR-014)
- **OQ-08**: Vault integration point (resolved: CLI-embedded, assembly-layer only — see ADR-008, ADR-014, ADR-018, ADR-019)
- **OQ-16**: Safe vault operations for call protocol exposure (resolved: none for now — see ADR-014)
- **OQ-20**: Salt/KDF Phase B (open: reserved field, v1 doesn't use it — see [encryption.md](crates/vault/encryption.md))
- **OQ-21**: Remote vault administration (deferred: network unlock not supported — see ADR-019)
- **OQ-22**: Key rotation mechanism (open: versioning in place, workflow not specced — see [encryption.md](crates/vault/encryption.md))
## Failure Modes