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
|
status: draft
|
||||||
last_updated: 2026-06-21
|
last_updated: 2026-06-19
|
||||||
---
|
---
|
||||||
|
|
||||||
# Alknet Architecture
|
# Alknet Architecture
|
||||||
|
|
||||||
## Current State
|
## 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
|
## 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/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/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/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
|
## 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 |
|
| [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 |
|
| [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 |
|
| [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
|
## 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-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
|
- **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):**
|
**Deferred (not active):**
|
||||||
- **OQ-09**: WASM target boundaries — design constraint, not deliverable
|
- **OQ-09**: WASM target boundaries — design constraint, not deliverable
|
||||||
- **OQ-10**: Git adapter scope — start with smart protocol, add ERC721 later
|
- **OQ-10**: Git adapter scope — start with smart protocol, add ERC721 later
|
||||||
|
- **OQ-21**: Remote vault administration — network unlock not supported; needs ADR if ever needed
|
||||||
**All open questions are resolved.** No open one-way or two-way doors remain. The architecture is ready for review.
|
|
||||||
|
|
||||||
## Document Lifecycle
|
## 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)
|
||||||
@@ -237,3 +237,32 @@ These questions are acknowledged but not active. They will be promoted to open w
|
|||||||
|
|
||||||
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.
|
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
|
status: draft
|
||||||
last_updated: 2026-06-18
|
last_updated: 2026-06-19
|
||||||
---
|
---
|
||||||
|
|
||||||
# Alknet Overview
|
# 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 |
|
| [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 |
|
| [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 |
|
| [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
|
## 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-02**: AuthContext resolution timing (resolved: hybrid — see ADR-004)
|
||||||
- **OQ-03**: ALPN string naming convention (resolved: see ADR-006)
|
- **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-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-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
|
## Failure Modes
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user