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