docs(architecture): add ADR-025 — vault local-only dispatch, drop irpc

Drops irpc from alknet-vault entirely. The vault's dispatch is now direct
method calls on VaultServiceHandle — no VaultProtocol enum, no
VaultMessage, no VaultServiceActor, no mpsc channel, no Service trait, no
RemoteService trait, no postcard serialization. The vault is local-only by
construction.

The core security argument: irpc made the vault remote-capable by default
(RemoteService generated unless no_rpc is passed). The IrohProtocol handler
forwards all messages without auth. The docs framed 'register an ALPN' as a
server-setup change. This is the default-insecure anti-pattern — security
should be opt-in, not opt-out. ADR-025 inverts the default: local-only is
the only mode, and remote access requires building a separate vault-server
crate (a visible architectural act, not a flag flip).

The actor path was already dead code — service.md said 'prefer
VaultServiceHandle directly — no channel, no serialization.' The actor
existed only to make irpc's Service trait work, which existed only to make
RemoteService work, which was the footgun. VaultServiceHandle's
Arc<RwLock> provides concurrent reads and exclusive writes — better
throughput than the actor's sequential processing.

DerivedKey serialization simplifies: always redact on serialize (for
logging safety), reject '[REDACTED]' on deserialize with an error. No
'postcard preserves bytes' path. This resolves review #002 W8 (silent
corruption on JSON-deserialized DerivedKey).

Resolves:
- OQ-21: remote vault access — resolved (not deferred). Not a vault crate
  feature; if needed, a separate vault-server crate with its own ADR.
- C7: vault-server-crate question decided — not created now, not precluded.
- C8: operation access policy table dissolved — all operations local-only
  by default; if a vault-server crate exposes some remotely, that crate
  defines the policy.
- W8: DerivedKey JSON deserialization — resolved (reject redacted payloads).

Amends ADR-005 (irpc remains for alknet-call, not for alknet-vault),
ADR-018 (vault is even more standalone — zero RPC framework deps),
ADR-019 (vault is the only layer, not just the only direct-caller layer),
ADR-008 (vault integration point unchanged, but now local-only by
construction).
This commit is contained in:
2026-06-22 14:53:52 +00:00
parent cdf340bec7
commit 7dda6eec68
13 changed files with 527 additions and 368 deletions

View File

@@ -436,10 +436,9 @@ irpc and the operation registry serve different scopes:
| Layer | Mechanism | Serialization | Scope |
|-------|-----------|---------------|-------|
| Call protocol (external) | `EventEnvelope` over QUIC streams | JSON | Cross-language, cross-node |
| irpc services (internal) | `VaultProtocol` derive macro, `Service` trait | postcard (binary) | Rust-to-Rust, in-process or in-cluster |
| Local dispatch (in-process) | Direct function call through `OperationRegistry` | None | Same process |
| irpc services (internal) | `#[rpc_requests]` derive macro, `Service` trait | postcard (binary) | Rust-to-Rust, in-process or in-cluster |
irpc services are an internal dispatch mechanism — they are not directly exposed on the call protocol. The vault's `VaultProtocol` uses irpc for in-process, type-safe dispatch via `VaultServiceHandle` (postcard serialization for in-cluster, direct calls for in-process). The vault is accessed by the assembly layer (CLI binary) at startup, not by handlers at call time. See ADR-008 and ADR-014.
irpc services are an internal dispatch mechanism — they are not directly exposed on the call protocol. alknet-call itself uses irpc for its call-protocol framing (ADR-005); the vault no longer uses irpc (ADR-025 — direct method calls on `VaultServiceHandle`). The vault is accessed by the assembly layer (CLI binary) at startup, not by handlers at call time. See ADR-008 and ADR-014.
If a handler internally uses an irpc-based service, the handler bridges the two: it receives JSON input from the call protocol, calls the irpc service in-process (postcard, type-safe), and serializes the result back to JSON for the call protocol response. This layering preserves irpc's type safety for internal calls while keeping the external interface cross-language.

View File

@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-06-22-19
last_updated: 2026-06-22-25
---
# alknet-vault
@@ -13,16 +13,18 @@ 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.
(ADR-018) and zero RPC framework dependencies (ADR-025). It provides the
cryptographic primitives and runtime API for managing the root of trust.
The CLI binary (the `alknet` crate) is the sole component that talks to the
vault directly (ADR-019) — handlers receive derived/decrypted material
through capabilities, never through a vault reference.
The vault is **not a network service**. It has no ALPN, no
`ProtocolHandler` implementation, and no operations registered in the call
protocol (ADR-008, ADR-014). The master seed and derived private keys never
cross the network.
`ProtocolHandler` implementation, no operations registered in the call
protocol (ADR-008, ADR-014), and no remote dispatch capability (ADR-025).
The vault is **local-only by construction** — direct method calls on
`VaultServiceHandle`, no actor, no message enum, no wire format. The master
seed and derived private keys never cross the network.
## Documents
@@ -30,16 +32,14 @@ cross the network.
|----------|--------|-------------|
| [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, HD derivation (ADR-020) |
| [service.md](service.md) | draft | VaultServiceHandle lifecycle, actor dispatch, cache, error model |
| [protocol.md](protocol.md) | draft | VaultProtocol irpc messages, DerivedKey redaction, serialization |
| [service.md](service.md) | draft | VaultServiceHandle lifecycle, direct dispatch, cache, error model |
| [protocol.md](protocol.md) | draft | DerivedKey redaction, KeyType, serialization behavior |
## Applicable ADRs
| ADR | Title | Relevance |
|-----|-------|-----------|
| [003](../../decisions/003-crate-decomposition.md) | Crate Decomposition | alknet-vault's standalone position |
| [006](../../decisions/006-alpn-convention-and-connection-model.md) | ALPN String Convention | ALPN versioning pattern for potential `alknet/vault/v2` |
| [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 |
| [010](../../decisions/010-alpn-router-and-endpoint.md) | ALPN Router and Endpoint | Ed25519 as default curve for TLS raw key identity |
| [014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Secret Material Flow and Capability Injection | Capabilities carry vault-derived material |
@@ -47,33 +47,38 @@ cross the network.
| [019](../../decisions/019-vault-assembly-layer-only.md) | Vault Assembly-Layer-Only Access | The assembly layer is the sole caller |
| [020](../../decisions/020-hd-derivation-for-encryption-keys.md) | HD Derivation for Encryption Keys | SLIP-0010 derivation, not PBKDF2; salt unused in v2 |
| [021](../../decisions/021-key-rotation-via-version-indexed-paths.md) | Key Rotation via Version-Indexed Paths | Version-indexed paths; `rotate` re-encrypts |
| [025](../../decisions/025-vault-local-only-dispatch.md) | Vault Local-Only Dispatch | Dropped irpc; direct method calls; local-only by construction |
## Relevant Open Questions
| OQ | Title | Status | Relevance |
|----|-------|--------|-----------|
| OQ-20 | Encryption key derivation | resolved (ADR-020) | HD derivation from seed; salt field unused in v2 |
| OQ-21 | Remote vault access | deferred | Protocol is remote-capable by construction; enabling = server-setup change with auth-wrapping handler; Unlock/Lock local-only |
| OQ-21 | Remote vault access | resolved (ADR-025) | Vault is local-only by construction; remote access requires a separate vault-server crate with its own ADR |
| OQ-22 | Key rotation mechanism | resolved (ADR-021) | Version-indexed paths; `rotate` method |
## Key Design Principles
1. **Standalone**: The vault depends on no alknet crate. It defines its own
types, errors, and protocol. External crates depend on the vault; the
vault depends on nothing in alknet.
1. **Standalone**: The vault depends on no alknet crate and no RPC framework.
It defines its own types and errors. External crates depend on the vault;
the vault depends on nothing in alknet.
2. **Assembly-layer only**: The vault's API is consumed by the CLI binary,
not by handlers. Handlers receive material through capabilities
(ADR-014). The vault is not on the wire.
3. **Zeroize everything sensitive**: The mnemonic, seed, derived private
3. **Local-only by construction**: The vault has no remote dispatch
capability. Direct method calls on `VaultServiceHandle` — no actor, no
message enum, no wire format (ADR-025). Remote access, if ever needed,
requires a separate crate with its own ADR.
4. **Zeroize everything sensitive**: The mnemonic, seed, derived private
keys, encryption keys, and cached keys all implement `Zeroize` and
`ZeroizeOnDrop`. Secret material does not linger in freed heap memory.
4. **Deterministic derivation**: The same mnemonic + passphrase + path
5. **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
6. **OsRng for nonces**: AES-GCM IVs and any cryptographic nonces use
`OsRng` (or equivalent CSPRNG), never `rand::random()`. IV reuse under
the same key is catastrophic for GCM.
6. **No `unwrap()` or `expect()` outside tests**: vault operations
7. **No `unwrap()` or `expect()` outside tests**: vault operations
propagate errors. A poisoned lock is recovered with
`unwrap_or_else(|e| e.into_inner())`, not `unwrap()`. A panic in one
vault operation must not brick the vault for all other operations.
@@ -97,11 +102,13 @@ the full list.
`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).
- **DerivedKey redaction in serialization**: `DerivedKey` serializes the
`private_key` as `"[REDACTED]"` in all formats (ADR-025 dropped the
postcard/remote path that previously preserved bytes in binary formats).
Deserialization rejects `"[REDACTED]"` with an error (resolves review
#002 W8). The redaction is a defense-in-depth measure for logging safety,
not the primary control — the primary control is that `DerivedKey` never
crosses the call protocol wire (ADR-014).
## Known Source Drift
@@ -115,8 +122,9 @@ truth for drift tracking — if an item is fixed in source, update this table.
| 1 | IV generation | `rand::random()` | `OsRng` (CSPRNG) | `encryption.rs` L133 | [encryption.md → Security Constraints](encryption.md#security-constraints), [service.md → Security Constraints](service.md#security-constraints) |
| 2 | RwLock `unwrap()` | `unwrap()` on every `RwLock` acquisition (L142, 161, 182, 191, 196, 227, 264, 307, 340, 367) | `unwrap_or_else(\|e\| e.into_inner())` for poisoned lock recovery | `service.rs` (see line numbers) | [service.md → Security Constraints](service.md#security-constraints) |
| 3 | `CURRENT_KEY_VERSION` | `1` (HD-derived, but v1 is reserved for TS PBKDF2 legacy per ADR-020) | `2` (HD-derived, per ADR-020) | `encryption.rs` | [encryption.md → Key Versioning](encryption.md#key-versioning), [ADR-020](../../decisions/020-hd-derivation-for-encryption-keys.md) |
| 4 | `spawn()` return value | Returns a fresh, unspawned `VaultServiceActor` as the second tuple element (the spawned actor is consumed by `run`) | Either drop the second return value (return only `Client<VaultProtocol>`) or restructure so the returned actor is the one that was spawned | `service.rs` `VaultServiceActor::spawn()` | [service.md → Actor Dispatch](service.md#actor-dispatch) |
| 5 | `HashMap::clear` zeroization | `KeyCache::clear()` removes entries and relies on `CachedKey`'s `Drop` impl for zeroization | Verify `HashMap::clear()` actually drops values (it does, but worth a test) | `cache.rs` | [service.md → Security Constraints](service.md#security-constraints) |
| 4 | irpc dependency | `VaultProtocol` enum with `#[rpc_requests]`, `VaultServiceActor`, `Client<VaultProtocol>`, irpc/postcard deps | Remove entirely — direct method calls on `VaultServiceHandle` (ADR-025) | `protocol.rs`, `service.rs`, `Cargo.toml` | [ADR-025](../../decisions/025-vault-local-only-dispatch.md) |
| 5 | `DerivedKey` dual serialization | JSON redacts, postcard preserves bytes | Always redact on serialize; reject `"[REDACTED]"` on deserialize with error (ADR-025, resolves W8) | `protocol.rs` | [protocol.md → Serialization Redaction](protocol.md#serialization-redaction), [ADR-025](../../decisions/025-vault-local-only-dispatch.md) |
| 6 | `HashMap::clear` zeroization | `KeyCache::clear()` removes entries and relies on `CachedKey`'s `Drop` impl for zeroization | Verify `HashMap::clear()` actually drops values (it does, but worth a test) | `cache.rs` | [service.md → Security Constraints](service.md#security-constraints) |
## Public API
@@ -132,11 +140,11 @@ pub use derivation::{DerivationError, ExtendedPrivKey, PATHS};
// Encryption
pub use encryption::{EncryptedData, EncryptionError};
// Protocol (irpc messages)
pub use protocol::{DerivedKey, KeyType, VaultMessage, VaultProtocol};
// Key types (DerivedKey, KeyType)
pub use protocol::{DerivedKey, KeyType};
// Service (runtime)
pub use service::{VaultService, VaultServiceActor, VaultServiceError, VaultServiceHandle};
pub use service::{VaultServiceError, VaultServiceHandle};
// Cache
pub use cache::CacheConfig;

View File

@@ -1,58 +1,24 @@
---
status: draft
last_updated: 2026-06-19
last_updated: 2026-06-22-25
---
# Protocol
The `VaultProtocol` irpc message enum, `DerivedKey` type, and serialization
behavior.
The `DerivedKey` type, `KeyType` enum, and serialization behavior. The
vault's "protocol" is the `VaultServiceHandle` method API (ADR-025) — there
is no message enum, no irpc dispatch, and no wire format.
## What
The 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).
The vault's dispatch is direct method calls on `VaultServiceHandle`
(ADR-025). The types defined here — `DerivedKey`, `KeyType` — are the
return types from those methods. There is no `VaultProtocol` enum, no
`VaultMessage`, no `VaultServiceActor`, and no remote dispatch capability.
## 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.
The vault is **local-only by construction**. If remote vault access is ever
needed, it requires a separate crate that wraps the vault and adds remote
transport + auth (ADR-025, OQ-021).
## DerivedKey
@@ -92,19 +58,23 @@ and zeroized.
### Serialization redaction
`DerivedKey` has a custom `Serialize` impl that redacts the private key in
human-readable formats:
`DerivedKey` has a custom `Serialize` impl that **always** redacts the
private key, regardless of format:
- **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).
- **JSON** (and all human-readable formats): `private_key` serializes as
`"[REDACTED]"`. This is defense-in-depth — if a `DerivedKey` accidentally
ends up in a log, a JSON config, or debug output, the private key is not
exposed.
- **Deserialization**: rejects `private_key == "[REDACTED]"` with an error.
A JSON-deserialized `DerivedKey` with a redacted private key is invalid
and produces a deserialization error, not a corrupted key. This resolves
review #002 W8 (silent corruption on JSON-deserialized `DerivedKey`).
- **No binary-format preservation path.** ADR-025 dropped the postcard/remote
dispatch path that previously preserved private key bytes in binary
formats. `DerivedKey` is always used in-process (ADR-014: never appears
in call protocol payloads). If a future remote-vault crate needs to send
`DerivedKey` over the wire, it defines its own serialization for that
context — the vault's `DerivedKey` stays redact-always.
The redaction is **not the primary control** for keeping private keys off
the wire. The primary control is architectural: `DerivedKey` never appears
@@ -147,169 +117,74 @@ Tags `DerivedKey` and `CachedKey` so consumers know what they received.
## 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.
The vault has no wire format (ADR-025). Dispatch is direct method calls on
`VaultServiceHandle` — no serialization, no channels, no network. The
`DerivedKey` custom `Serialize`/`Deserialize` impls exist solely for
logging safety (redaction) and defense-in-depth, not for wire transport.
## Remote Capability
`EncryptedData` has a stable wire format (shared with `alknet-storage` and
the TypeScript consumer by type-level agreement — see
[encryption.md](encryption.md) and ADR-018). That format is for *stored
encrypted data*, not for vault dispatch — the vault's `encrypt`/`decrypt`
methods operate on `EncryptedData` as a value type, not as a wire message.
The `VaultProtocol` is a remote-capable irpc service **by construction**.
The `#[rpc_requests]` macro generates both `Service` (local) and
`RemoteService` (remote) trait implementations. The `VaultServiceActor`
processes `VaultMessage` variants identically regardless of transport —
the only difference between local and remote use is the `Client<VaultProtocol>`
construction and the server-side listener setup.
## Local-Only by Construction
This was a purposeful design decision: irpc's "zero-overhead local,
transparent remote" architecture means the same protocol definition and
actor code work for both in-process and cross-network dispatch. Enabling
remote vault access is a server-setup change, not a protocol change.
The vault is **local-only by construction** (ADR-025). There is no
`RemoteService` trait, no remote handler, no wire format for vault
messages. The vault's API is `VaultServiceHandle` — direct method calls,
nothing else.
### What's already in place
If remote vault access is ever needed (e.g., the machine→worker pattern
where a long-lived node exposes a restricted vault API to ephemeral
workers), it requires a **separate vault-server crate** that:
- **Protocol**: `VaultProtocol` is already a `RemoteService`. No code
changes needed in the protocol definition.
- **Serialization**: `DerivedKey`'s dual serialization (JSON redacts private
key for safety; postcard preserves bytes for remote dispatch) was
designed for this use case.
- **Actor**: `VaultServiceActor` already processes all message types. The
actor is transport-agnostic — it doesn't know whether a message arrived
via a local mpsc channel or a remote QUIC stream.
- **Auth transport**: irpc over iroh uses iroh's QUIC connections, which
authenticate via NodeId (Ed25519, RFC 7250 raw keys) — the same identity
model as the rest of alknet (ADR-010). The connection-level identity
("which NodeId is calling") is available before any vault operation is
dispatched.
1. Depends on both alknet-core (for `IdentityProvider`, scopes,
auth-wrapping) and alknet-vault (for `VaultServiceHandle`).
2. Defines its own threat model, access policy, and operation filtering
(`Unlock`/`Lock` must be local-only; other operations may be
remote-capable depending on the policy).
3. Adds the remote transport (iroh/QUIC or similar) and an auth-wrapping
handler that checks caller identity before forwarding to the vault.
4. Requires its own ADR (matching ADR-019's language: "requires its own
ADR") defining the threat model and access policy.
### What's not in place (the gap)
This is a deliberate addition, not a flag flip on a default that was
already loaded. The pre-ADR-025 design made the vault remote-capable *by
construction* (irpc generated `RemoteService` by default), which was the
default-insecure anti-pattern. ADR-025 inverts the default: local-only is
the only mode, and remote access requires building something new.
The `IrohProtocol` handler that irpc provides forwards **all** message
types to the actor without auth checks. For local use this is correct
(the assembly layer is trusted). For remote use, the listener needs:
1. **NodeId allowlist**: only known worker NodeIds may connect.
2. **Message filtering**: reject `Unlock` and `Lock` from remote callers
(see "Operation access policy" below).
3. **Then** forward to the actor.
This auth-wrapping handler cannot live in the vault crate — the vault is
standalone (ADR-018) and depends on no alknet crate. The auth model
(`IdentityProvider`, `Identity`, scopes) lives in alknet-core. The
auth-wrapping listener lives in the **assembly layer** (the CLI binary)
or a dedicated vault-server crate that depends on both alknet-core and
alknet-vault. This is the same pattern as ADR-019: the vault is a
library, the assembly layer is the integrator.
```
alknet-vault (standalone, no deps)
- VaultProtocol (RemoteService by construction)
- VaultServiceActor (processes all message types, no auth)
- VaultServiceHandle (direct API)
assembly layer / vault-server (depends on alknet-core + alknet-vault)
- AuthWrappingHandler: checks NodeId, filters message types, forwards
- IrohProtocol::new(auth_wrapping_handler)
- Router::builder(endpoint).accept(b"alknet/vault", protocol).spawn()
```
### Operation access policy
Not all `VaultProtocol` operations are safe to expose remotely. The vault
spec defines the policy; the assembly-layer listener enforces it.
| Operation | Local (assembly layer) | Remote (workers) | Why |
|-----------|----------------------|-------------------|-----|
| `Unlock` | ✅ | ❌ | Sends the mnemonic (root of trust) over the wire. Even with NodeId auth, the mnemonic in transit is a different threat model — it's in memory on the receiving end, potentially in logs/traces. Local-only. |
| `Lock` | ✅ | ❌ | Locking the vault bricks the machine node for all workers. A compromised or buggy worker could DoS the entire machine node. Local-only. |
| `DeriveEd25519` | ✅ | ✅ | Workers need derived keys for signing, identity. The derivation path is the access control — the worker can only derive at paths the assembly layer declares. |
| `DeriveEncryptionKey` | ✅ | ✅ | Workers need encryption keys for credential encryption. Same path-based access control. |
| `DeriveEthereumKey` | ✅ | ✅ | Same as DeriveEd25519, for Ethereum signing. |
| `DerivePassword` | ✅ | ✅ | Workers need deterministic passwords for service credentials. |
| `Encrypt` | ✅ | ✅ | Workers encrypt external credentials (API keys) for storage. |
| `Decrypt` | ✅ | ✅ | Workers decrypt stored credentials at call time. |
The policy is: **`Unlock` and `Lock` are local-only; all other operations
are remote-capable.** The assembly-layer listener filters `Unlock` and
`Lock` messages from remote connections and returns an error.
### Use case: machine node → workers
The primary use case is a **machine node** (long-lived, holds the mnemonic,
manages container services) exposing a restricted vault API to its
**workers** (ephemeral, containerized, no mnemonic):
```
Machine Node (head, vault unlocked locally)
├── exposes alknet/vault ALPN to workers
├── NodeId allowlist: only known worker NodeIds may connect
├── message filter: rejects Unlock/Lock from remote callers
├── Worker A (no mnemonic)
│ └── calls DeriveEd25519, Encrypt, Decrypt on machine node's vault
└── Worker B (also a head for its own sub-workers)
├── gets its own credentials from machine node's vault
└── can expose its own restricted vault API to sub-workers
```
Workers don't hold mnemonics. They get static credentials injected at
construction (the common case) and call the machine node's vault for
dynamic derivation or decryption when needed. This is the
defense-in-depth (Russian doll) model: the seed is the innermost layer,
the machine node's vault is the next, iroh's NodeId auth is the outer,
and workers are outside that — calling in through authenticated channels.
### Per-machine-node vaults, not shared
Each machine node has its own vault and mnemonic. Machine nodes do not
share vaults with each other. Compromising one machine node exposes only
that node's workers, not all nodes. This is compartmentalization — the
blast radius of a vault compromise is one machine node, not the entire
fleet.
The remote vault capability is for the **machine→worker** relationship,
not for cross-machine-node sharing. Machine nodes don't expose their
vaults to peer machine nodes — only to their own workers, authenticated
by NodeId.
### What's breaking vs. non-breaking
| Change | Breaking? | Why |
|--------|-----------|-----|
| Enabling remote vault access | **No** | Server-setup change — register `IrohProtocol` with an ALPN. The protocol is already a `RemoteService`. |
| Restricting which operations are remote-capable | **No** | Policy in the assembly-layer handler, not a protocol change. |
| Adding NodeId auth checks | **No** | Implementation in the assembly-layer handler. The vault crate doesn't change. |
| Adding new `VaultProtocol` variants | **Yes (wire break)** | Inherent to irpc — versioning is a non-goal. Would need ALPN versioning (`alknet/vault/v2`) if the protocol evolves. Same constraint as any irpc service. |
| Changing `DerivedKey` serialization | **No** | Dual serialization is already in place — postcard preserves bytes for remote, JSON redacts for safety. |
The only breaking change is evolving the `VaultProtocol` enum itself, and
that's manageable with ALPN versioning (`alknet/vault`, then
`alknet/vault/v2` if needed) — the same pattern alknet uses for all ALPN
protocols (ADR-006).
**Per-node vaults are the recommended pattern for multi-node deployments.**
Each node has its own vault and mnemonic. Credentials are encrypted *for*
the receiving node's public key or derived at a shared path the receiving
node can derive locally. This is end-to-end encryption between nodes, not
a centralized decryption oracle. It matches ADR-008's "capability source"
model — credentials are injected at the assembly layer, not fetched over
the network at call time.
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| irpc for vault dispatch | [ADR-005](../../decisions/005-irpc-as-call-protocol-foundation.md) | In-process type-safe dispatch; remote-capable by construction |
| Vault is standalone | [ADR-018](../../decisions/018-vault-standalone-crate.md) | Zero alknet crate dependencies |
| Vault is local-only | [ADR-025](../../decisions/025-vault-local-only-dispatch.md) | Direct method calls, no irpc, no remote dispatch capability |
| HD derivation (not stored keys) | — | One seed, many keys, no key storage |
| `DerivedKey` is move-only | [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Prevents accidental duplication of secret material |
| JSON redacts private key | [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 |
| JSON redacts private key (always) | [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Defense-in-depth for logging accidents |
| No vault operations on call protocol | [ADR-008](../../decisions/008-secret-service-integration.md), [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Master seed never crosses the network |
| Unlock/Lock are local-only | OQ-21 (deferred) | Mnemonic and lock control must not be remotely accessible |
| Auth wrapping lives in assembly layer | [ADR-018](../../decisions/018-vault-standalone-crate.md), [ADR-019](../../decisions/019-vault-assembly-layer-only.md) | Vault is standalone; can't import alknet-core's auth model |
| No remote dispatch in vault crate | [ADR-025](../../decisions/025-vault-local-only-dispatch.md) | Remote access requires a separate vault-server crate with its own ADR |
## Open Questions
None active for this document.
None active for this document. OQ-21 (remote vault) is resolved — see
ADR-025 and [open-questions.md](../../open-questions.md).
## References
- Implementation: `crates/alknet-vault/src/protocol.rs`
- Implementation: `crates/alknet-vault/src/protocol.rs` (to be updated
per ADR-025 — remove `VaultProtocol` enum and irpc usage)
- Tests: `crates/alknet-vault/src/protocol.rs` (unit tests for redaction
and zeroize behavior)
- [service.md](service.md) — how the actor dispatches `VaultMessage`
and zeroize behavior; postcard tests to be removed)
- [service.md](service.md) — `VaultServiceHandle` runtime API
- [mnemonic-derivation.md](mnemonic-derivation.md) — what `KeyType` means

View File

@@ -1,12 +1,13 @@
---
status: draft
last_updated: 2026-06-20
last_updated: 2026-06-22-25
---
# Service
The `VaultServiceHandle` runtime API: unlock/lock lifecycle, key
derivation, encryption, caching, and the actor dispatch path.
derivation, encryption, caching, and the direct method-call dispatch
path.
## What
@@ -16,7 +17,9 @@ stateful runtime with a clear lifecycle. It holds the master seed in
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).
calls these methods directly (ADR-019). The vault is local-only by
construction (ADR-025) — direct method calls, no actor, no message enum,
no remote dispatch.
## VaultServiceHandle
@@ -254,91 +257,54 @@ pub struct CacheConfig {
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
## 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.
The vault uses **direct method calls** on `VaultServiceHandle` — no actor,
no message enum, no channels, no serialization (ADR-025). The handle is
`Arc<RwLock<VaultServiceInner>>` — clone it, share it, call methods
directly. The RwLock provides concurrent reads (derive operations) and
exclusive writes (unlock/lock).
```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);
}
```
Assembly layer (CLI binary):
1. Create VaultServiceHandle
2. Unlock with mnemonic (local, from secure prompt or file)
3. Call derive/encrypt/decrypt methods directly
4. Extract bytes, construct alknet-core types at the assembly boundary
5. Inject into handler capabilities (ADR-014)
```
- `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.**
There is no `VaultProtocol` enum, no `VaultServiceActor`, no `Client<S>`,
and no remote dispatch capability. The vault is local-only by
construction (ADR-025). If remote vault access is ever needed, it requires
a separate vault-server crate with its own ADR (OQ-021, ADR-025).
The 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 vault dispatch — where the vault is exposed over irpc/iroh to
workers or other processes — is **deferred** (OQ-21). The `VaultProtocol`
is already a `RemoteService` by construction (irpc's `#[rpc_requests]`
generates it), and `DerivedKey`'s dual serialization was designed for this.
Enabling remote access is a server-setup change (register `IrohProtocol`
with an ALPN), not a protocol change.
However, the `IrohProtocol` handler that irpc provides forwards all
message types without auth checks. Remote use needs an **auth-wrapping
handler** in the assembly layer (not the vault crate — the vault is
standalone, ADR-018, and can't import alknet-core's auth model) that:
1. Checks the caller's NodeId against an allowlist
2. Filters `Unlock` and `Lock` messages from remote callers (local-only)
3. Forwards remaining messages to the actor
See [protocol.md → Remote Capability](protocol.md#remote-capability) for
the full design, operation access policy, use case (machine node →
workers), and breaking-vs-non-breaking analysis.
The assembly layer (CLI binary) uses the direct path. The actor path
exists for in-process irpc dispatch. Neither path is on the alknet call
protocol (ADR-008, ADR-014) — the vault has no ALPN until a future
deployment explicitly registers one with an auth-wrapping handler.
The pre-ADR-025 design had an actor path (mpsc channel + oneshot
backchannels, using irpc's `Service` trait) that was described as
"secondary" to direct calls. ADR-025 removed it — the actor existed only
to make irpc's dispatch work, and the direct path was always preferred.
The RwLock-based concurrency model is both simpler and better for
throughput (concurrent reads vs. sequential processing).
## Errors
```rust
#[derive(Debug, thiserror::Error, Serialize, Deserialize)]
#[derive(Debug, thiserror::Error)]
pub enum VaultServiceError {
VaultLocked, // called derive/encrypt/decrypt while locked
AlreadyUnlocked, // called unlock while already unlocked
Mnemonic(String), // mnemonic generation/validation failed
Derivation(String), // HD derivation failed (bad path, HMAC error)
Encryption(String), // AES-GCM encrypt/decrypt failed
InvalidPath(String), // derivation path is malformed
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).
`VaultServiceError` is a plain `thiserror::Error` enum (ADR-025 dropped
the `Serialize`/`Deserialize` derives that were needed for irpc dispatch).
It wraps sub-errors as strings. The CLI binary converts vault errors to
alknet-core error types at the assembly boundary (ADR-018).
## Design Decisions
@@ -349,18 +315,19 @@ error types — the CLI binary converts at the assembly boundary (ADR-018).
| Version-indexed paths for rotation | [ADR-021](../../decisions/021-key-rotation-via-version-indexed-paths.md) | `decrypt` selects key by version; `rotate` re-encrypts |
| RwLock for thread safety | — | Multiple readers (derive), exclusive writer (unlock/lock) |
| TTL + LRU cache | — | Bounded memory, fresh keys, zeroized eviction |
| Actor for in-process irpc dispatch | [ADR-005](../../decisions/005-irpc-as-call-protocol-foundation.md) | irpc message dispatch; not on the call protocol |
| Direct method calls (no actor) | [ADR-025](../../decisions/025-vault-local-only-dispatch.md) | No irpc, no message enum, no remote dispatch capability |
| `derive_password` 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 access — the `VaultProtocol` is
remote-capable by construction (irpc `RemoteService`). Enabling remote
access is a server-setup change with an auth-wrapping handler in the
assembly layer. `Unlock`/`Lock` are local-only; other operations are
remote-capable. See [protocol.md → Remote Capability](protocol.md#remote-capability).
- **OQ-21** (resolved by ADR-025): Remote vault access is not a feature
of the vault crate. The vault is local-only by construction — direct
method calls on `VaultServiceHandle`, no remote dispatch capability.
If remote access is ever needed, it requires a separate vault-server
crate with its own ADR. See [protocol.md → Local-Only by
Construction](protocol.md#local-only-by-construction).
## Security Constraints
@@ -405,5 +372,5 @@ don't miss them.
- 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`
- [protocol.md](protocol.md) — `DerivedKey` and `KeyType`
- [encryption.md](encryption.md) — `encrypt` / `decrypt` cryptographic details