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:
@@ -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 (001–017) 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 (001–019) 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
|
||||
|
||||
|
||||
131
docs/architecture/crates/vault/README.md
Normal file
131
docs/architecture/crates/vault/README.md
Normal 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;
|
||||
```
|
||||
215
docs/architecture/crates/vault/encryption.md
Normal file
215
docs/architecture/crates/vault/encryption.md
Normal 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
|
||||
301
docs/architecture/crates/vault/mnemonic-derivation.md
Normal file
301
docs/architecture/crates/vault/mnemonic-derivation.md
Normal 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`
|
||||
177
docs/architecture/crates/vault/protocol.md
Normal file
177
docs/architecture/crates/vault/protocol.md
Normal 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
|
||||
361
docs/architecture/crates/vault/service.md
Normal file
361
docs/architecture/crates/vault/service.md
Normal 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
|
||||
162
docs/architecture/decisions/018-vault-standalone-crate.md
Normal file
162
docs/architecture/decisions/018-vault-standalone-crate.md
Normal 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/`
|
||||
165
docs/architecture/decisions/019-vault-assembly-layer-only.md
Normal file
165
docs/architecture/decisions/019-vault-assembly-layer-only.md
Normal 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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user