Rename the crate from alknet-vault to alkvault across source and docs: - Cargo.toml: package name and lib name (alknet_vault -> alkvault) - src/ doc comments and doc-test use statements - tests/ use statements and one string literal Convert references to non-vault alknet ADRs (003, 005, 008, 010, 014, 064) that were broken local links into @alkdev/alknet: cross-repo references, matching the alktype sibling pattern. Local ADR/OQ references are now proper links. Rewrote monorepo path references (crates/alknet-vault/src/...) to the flat layout (src/...). Fixed sdd_process.md package name (@alkdev/storage -> @alkdev/alkvault). ADR/OQ renumbering is deferred to a subsequent pass per the alknet- origin numbering convention. Generic prose 'vault' and type names (VaultServiceHandle, VaultServiceError, etc.) are unchanged. Build, 108 tests, and clippy all pass clean.
385 lines
16 KiB
Markdown
385 lines
16 KiB
Markdown
---
|
|
status: stable
|
|
last_updated: 2026-06-23
|
|
---
|
|
|
|
# Service
|
|
|
|
The `VaultServiceHandle` runtime API: unlock/lock lifecycle, key
|
|
derivation, encryption, caching, and the direct method-call 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). The vault is local-only by
|
|
construction (ADR-025) — direct method calls, no actor, no message enum,
|
|
no remote dispatch.
|
|
|
|
## VaultServiceHandle
|
|
|
|
The primary API for local (in-process) use. Thread-safe via
|
|
`std::sync::RwLock` — all methods are **synchronous** (no `async`, no
|
|
`.await`). The RwLock provides concurrent reads (derive operations) and
|
|
exclusive writes (unlock/lock). `tokio` is not a dependency of the vault
|
|
(ADR-025); `std::sync::RwLock` is sufficient because no method holds the
|
|
lock across an await point.
|
|
|
|
```rust
|
|
#[derive(Clone)]
|
|
pub struct VaultServiceHandle {
|
|
inner: Arc<std::sync::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
|
|
}
|
|
```
|
|
|
|
**Invariant**: `unlocked` is `true` iff `seed.is_some()`. The `unlocked`
|
|
flag exists for cheap read-only checks (`is_unlocked`); the ground truth is
|
|
`seed.is_some()`. `lock()` sets `unlocked = false` and clears `seed`/`mnemonic`
|
|
to `None`; `unlock`/`unlock_new` set `unlocked = true` and populate `seed`.
|
|
|
|
`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<Zeroizing<String>, VaultServiceError>;
|
|
```
|
|
|
|
Generate a new random mnemonic, unlock with it, and return the phrase as
|
|
a `Zeroizing<String>`. The returned phrase is the root of trust — it is
|
|
heap-allocated and zeroized on drop, so it does not linger in freed
|
|
memory. The caller should extract the phrase for secure storage (write
|
|
down, display to user) and let the `Zeroizing<String>` drop when done.
|
|
Do not clone the returned value or store it in a non-zeroizing container.
|
|
Supported word counts: 12, 15, 18, 21, 24.
|
|
|
|
Returns `VaultServiceError::AlreadyUnlocked` if the vault is already
|
|
unlocked (matching `unlock`'s behavior — `unlock_new` is a "first run"
|
|
operation and should not silently replace an existing mnemonic).
|
|
|
|
This is the "first run" path — a new node generates its mnemonic, writes
|
|
it down, and the vault is unlocked for the process lifetime. The
|
|
`Zeroizing<String>` wrapper (from the `zeroize` crate) ensures the
|
|
mnemonic is wiped from memory once the caller is done with it, matching
|
|
the `Mnemonic` type's own `ZeroizeOnDrop` behavior. This resolves review
|
|
#002 W7.
|
|
|
|
### 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_encryption_key_for_version(version) → DerivedKey
|
|
|
|
```rust
|
|
pub fn derive_encryption_key_for_version(&self, version: u32) -> Result<DerivedKey, VaultServiceError>;
|
|
```
|
|
|
|
Derive the encryption key for a specific key version. Maps the version to
|
|
its derivation path via `encryption_path_for_version(version)` (ADR-021):
|
|
v2 → `m/74'/2'/0'/0'`, v3 → `m/74'/2'/0'/1'`, etc. Cached by path. This is
|
|
the version-aware method that `encrypt` and `decrypt` use to select the
|
|
correct key for each blob — see [encryption.md](encryption.md) and ADR-021.
|
|
Returns `VaultServiceError::InvalidPath` for `version < 2` (v1 is TS PBKDF2
|
|
legacy — the vault cannot derive it; v0 is meaningless).
|
|
|
|
`derive_encryption_key(path)` (above) remains as the path-based API for
|
|
deriving at arbitrary paths. `derive_encryption_key_for_version(version)`
|
|
is the version-aware API used by `encrypt` and `decrypt`. Both return
|
|
`DerivedKey` with `KeyType::Aes256Gcm` and share the same cache (keyed by
|
|
derivation path). `encrypt` and `decrypt` extract the `EncryptionKey` from
|
|
the `DerivedKey` via `EncryptionKey::from_derived_bytes` (see
|
|
[encryption.md](encryption.md#encryption-key)).
|
|
|
|
### 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).
|
|
|
|
## 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
|
|
`encryption_path_for_version(key_version)` (ADR-021). The same `key_version`
|
|
is stamped on the resulting `EncryptedData`. 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 the version-indexed path indicated by `encrypted.key_version` via
|
|
`derive_encryption_key_for_version` (ADR-021). Each version maps to a
|
|
distinct path (`m/74'/2'/0'/{version-2}'`), so old and new keys can
|
|
coexist during partial rotation. See [encryption.md](encryption.md).
|
|
|
|
### rotate(encrypted, to_version) → EncryptedData
|
|
|
|
```rust
|
|
pub fn rotate(&self, encrypted: &EncryptedData, to_version: u32) -> Result<EncryptedData, VaultServiceError>;
|
|
```
|
|
|
|
Re-encrypt an `EncryptedData` blob from its current key version to a new
|
|
version. Decrypts with the old version's key, re-encrypts with the new
|
|
version's key. Returns the new `EncryptedData` — the caller replaces the
|
|
blob in storage. No new mnemonic needed; the same seed produces all
|
|
version keys via different derivation paths (ADR-021).
|
|
|
|
This is the rotation primitive. The assembly layer or a migration tool
|
|
iterates stored blobs and calls `rotate` on each. The vault does not
|
|
self-rotate — rotation is an operational action.
|
|
|
|
## 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,
|
|
}
|
|
|
|
/// A cached derived key. Wraps a `DerivedKey` with cache metadata.
|
|
/// Derives `Zeroize` and `ZeroizeOnDrop` — the private key is zeroized
|
|
/// when the entry is evicted (LRU/TTL) or the cache is cleared.
|
|
pub struct CachedKey {
|
|
key: DerivedKey, // the derived key (zeroized on drop)
|
|
cached_at: Instant, // when the entry was inserted (for TTL)
|
|
last_accessed: Instant, // for LRU ordering
|
|
}
|
|
|
|
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` (via the
|
|
`DerivedKey` it holds, which is `#[zeroize(drop)]`). 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 |
|
|
| `encrypt` / `decrypt` | Key cached | The encryption `DerivedKey` (at `encryption_path_for_version(key_version)`) is cached; the plaintext is not |
|
|
|
|
## Dispatch
|
|
|
|
The vault uses **direct method calls** on `VaultServiceHandle` — no actor,
|
|
no message enum, no channels, no serialization (ADR-025). The handle is
|
|
`Arc<std::sync::RwLock<VaultServiceInner>>` — clone it, share it, call
|
|
methods directly. The `std::sync::RwLock` provides concurrent reads (derive
|
|
operations) and exclusive writes (unlock/lock). All methods are synchronous
|
|
(no `async`), so `std::sync::RwLock` is correct — a `tokio::sync::RwLock`
|
|
would require async methods or risk blocking a tokio runtime when held
|
|
across an await point. The vault does not depend on `tokio` (ADR-025).
|
|
|
|
```
|
|
Assembly layer (CLI binary):
|
|
1. Create VaultServiceHandle
|
|
2. Unlock with mnemonic (local, from secure prompt or file)
|
|
3. Call derive/encrypt/decrypt methods directly
|
|
4. Extract bytes, construct alknet-core types at the assembly boundary
|
|
5. Inject into handler capabilities (ADR-014)
|
|
```
|
|
|
|
There is no `VaultProtocol` enum, no `VaultServiceActor`, no `Client<S>`,
|
|
and no remote dispatch capability. The vault is local-only by
|
|
construction (ADR-025). If remote vault access is ever needed, it requires
|
|
a separate vault-server crate with its own ADR (OQ-021, ADR-025).
|
|
|
|
The pre-ADR-025 design had an actor path (mpsc channel + oneshot
|
|
backchannels, using irpc's `Service` trait) that was described as
|
|
"secondary" to direct calls. ADR-025 removed it — the actor existed only
|
|
to make irpc's dispatch work, and the direct path was always preferred.
|
|
The RwLock-based concurrency model is both simpler and better for
|
|
throughput (concurrent reads vs. sequential processing).
|
|
|
|
## Errors
|
|
|
|
```rust
|
|
#[derive(Debug, thiserror::Error)]
|
|
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 a plain `thiserror::Error` enum (ADR-025 dropped
|
|
the `Serialize`/`Deserialize` derives that were needed for irpc dispatch).
|
|
It wraps sub-errors as strings. The CLI binary converts vault errors to
|
|
alknet-core error types at the assembly boundary (ADR-018).
|
|
|
|
## Design Decisions
|
|
|
|
| Decision | ADR | Summary |
|
|
|----------|-----|---------|
|
|
| Assembly layer is the sole caller | [ADR-019](decisions/019-vault-assembly-layer-only.md) | Handlers never hold a vault reference |
|
|
| Encryption key via HD derivation | [ADR-020](decisions/020-hd-derivation-for-encryption-keys.md) | Seed-derived key at `m/74'/2'/0'/0'`, not PBKDF2 |
|
|
| Version-indexed paths for rotation | [ADR-021](decisions/021-key-rotation-via-version-indexed-paths.md) | `decrypt` selects key by version; `rotate` re-encrypts |
|
|
| RwLock for thread safety | — | Multiple readers (derive), exclusive writer (unlock/lock) |
|
|
| TTL + LRU cache | — | Bounded memory, fresh keys, zeroized eviction |
|
|
| Direct method calls (no actor) | [ADR-025](decisions/025-vault-local-only-dispatch.md) | No irpc, no message enum, no remote dispatch capability |
|
|
| `derive_password` removed | [ADR-025](decisions/025-vault-local-only-dispatch.md) | Password-manager pattern not relevant to RPC system's vault; resolves C9 |
|
|
|
|
## Open Questions
|
|
|
|
See [open-questions.md](open-questions.md) for full details.
|
|
|
|
- **OQ-21** (resolved by ADR-025): Remote vault access is not a feature
|
|
of the vault crate. The vault is local-only by construction — direct
|
|
method calls on `VaultServiceHandle`, no remote dispatch capability.
|
|
If remote access is ever needed, it requires a separate vault-server
|
|
crate with its own ADR. See [protocol.md → Local-Only by
|
|
Construction](protocol.md#local-only-by-construction).
|
|
|
|
## Security Constraints
|
|
|
|
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).
|
|
- **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()`.
|
|
- **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. 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.
|
|
- **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: `src/service.rs`,
|
|
`src/cache.rs`
|
|
- Tests: `tests/service_tests.rs`,
|
|
`src/service.rs` (unit tests),
|
|
`src/cache.rs` (unit tests)
|
|
- [protocol.md](protocol.md) — `DerivedKey` and `KeyType`
|
|
- [encryption.md](encryption.md) — `encrypt` / `decrypt` cryptographic details |