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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user