docs(architecture): resolve review #003 — type/API surface completeness
Review #003 found 11 critical, 14 warning, and 6 suggestion findings after reviews #001 (governance/security) and #002 (cross-document consistency/two-way-door audit) were resolved. The theme: types and APIs that were *referenced* but never *defined*, and stale ADR sketches that didn't match the now-updated spec docs. Critical fixes (11): - C1: DerivedKey #[derive(Deserialize)] contradicted the custom Deserialize that rejects "[REDACTED]" — dropped the derive, added explicit manual Serialize/Deserialize impls (protocol.md). - C2: encrypt prose said "derived at PATHS::ENCRYPTION" but the signature takes key_version — updated to encryption_path_for_version (service.md). - C3: derive_encryption_key returned DerivedKey, derive_encryption_key _for_version returned EncryptionKey (same cache) — unified on DerivedKey, defined CachedKey (service.md). - C4: tokio vs std::sync::RwLock contradiction — specified std::sync::RwLock, dropped tokio from vault deps (ADR-018, ADR-025, service.md). - C5: Missing drift rows in vault README — added #9 (key_version ignored) and #10 (rotate not implemented). - C6: ADR-022 build_root_context and invoke() sketches omitted abort_policy (9 fields vs 10) — added the field to both sketches. - C7: Capabilities type referenced 20+ times, never defined — added struct definition to core-types.md with Clone+Send+Sync, Zeroize, sealed builder API, immutability guard. - C8: SessionOverlaySource on CallAdapter but never defined, crate violation (alknet-call can't depend on alknet-agent) — defined the trait in alknet-call (call-protocol.md), matching the IdentityProvider pattern. - C9: CompositeOperationEnv dispatch fall-through was "a two-way door" — added contains() to OperationEnv trait, made the composite probe before dispatching, eliminating the sentinel ambiguity. - C10: No API for Layer 2 (connection overlay) registration, CallConnection undefined — defined CallConnection struct + register_imported() API (call-protocol.md). - C11: with_local signature diverged between two examples (4 args vs 5) — added capabilities as the 5th arg, made both examples consistent. Warning fixes (14): - W1: invoke_with_policy restructured as required method, invoke gets a default impl delegating to it — eliminates duplication across impls. - W2: CachedKey defined (service.md). - W3: EncryptionKey constructor/glue specified, added to re-export list. - W4: Secp256k1ExtendedPrivKey defined, derive_ethereum_key glue shown. - W5: encryption_path_for_version rejects version < 2 (v1 is TS PBKDF2). - W6: Wire payload schemas for all event types + ResponseEnvelope → EventEnvelope conversion table (call-protocol.md). - W7: Timeout section — deadline on OperationContext, composed calls inherit parent's deadline, CallAdapter::with_timeout(). - W8: Request ID generation spec — UUID v4 for composed calls, wire ID vs internal ID relationship for abort cascade. - W9: unlock_new already-unlocked behavior specified (returns AlreadyUnlocked). - W10: KeyType Serialize/Deserialize justification corrected (stale irpc reference removed). - W11: OperationProvenance and CompositionAuthority defined inline in operation-registry.md (were only in ADR-022). - W12: encrypt/decrypt free functions marked pub(crate), relationship to VaultServiceHandle methods stated. - W13: rotate signature removed from encryption.md (it's a VaultServiceHandle method, not a free function). - W14: CallAdapter::new() + with_session_source() + with_timeout() constructors shown. Suggestion fixes (6): Seed: Clone note, VaultServiceInner invariant, ExtendedPrivKey accessor signatures, CURRENT_KEY_VERSION location, ADR-018 stale actor text, derivation helpers re-export note.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-22-25
|
||||
last_updated: 2026-06-23
|
||||
---
|
||||
|
||||
# alknet-vault
|
||||
@@ -128,6 +128,8 @@ truth for drift tracking — if an item is fixed in source, update this table.
|
||||
| 6 | `HashMap::clear` zeroization | `KeyCache::clear()` removes entries and relies on `CachedKey`'s `Drop` impl for zeroization | Verify `HashMap::clear()` actually drops values (it does, but worth a test) | `cache.rs` | [service.md → Security Constraints](service.md#security-constraints) |
|
||||
| 7 | `derive_password` / `site_password_path` | `derive_password`, `derive_password_string`, `site_password_path` methods exist | Remove entirely — password-manager pattern not relevant to RPC system's vault (ADR-025, resolves C9) | `service.rs`, `mnemonic-derivation.rs` | [ADR-025](../../decisions/025-vault-local-only-dispatch.md) |
|
||||
| 8 | `unlock_new` return type | Returns `String` (not zeroized on drop) | Return `Zeroizing<String>` — the mnemonic is the root of trust and must not linger in freed memory (resolves W7) | `service.rs` | [service.md → unlock_new](service.md#unlock_newword_count--phrase) |
|
||||
| 9 | `key_version` ignored in encrypt/decrypt | `encrypt`/`decrypt` always derive at `PATHS::ENCRYPTION` regardless of `key_version` | Derive at `encryption_path_for_version(key_version)` — encrypt stamps the passed version, decrypt selects the key by the blob's version (ADR-021) | `service.rs` | [service.md → encrypt](service.md#encryptplaintext-key_version--encrypteddata), [ADR-021](../../decisions/021-key-rotation-via-version-indexed-paths.md) |
|
||||
| 10 | `rotate` not implemented | No `rotate` method exists | Implement `rotate(encrypted, to_version)` — decrypt with old version's key, re-encrypt with new version's key (ADR-021) | `service.rs` | [service.md → rotate](service.md#rotateencrypted-to_version--encrypteddata), [ADR-021](../../decisions/021-key-rotation-via-version-indexed-paths.md) |
|
||||
|
||||
## Public API
|
||||
|
||||
@@ -139,9 +141,14 @@ pub use mnemonic::{Language, Mnemonic, Seed};
|
||||
|
||||
// Derivation
|
||||
pub use derivation::{DerivationError, ExtendedPrivKey, PATHS};
|
||||
// Derivation helpers (derive_path_from_seed, parse_derivation_path,
|
||||
// device_path, encryption_path_for_version) are accessible as
|
||||
// alknet_vault::derivation::* — not re-exported at crate root to avoid
|
||||
// clutter, but fully public.
|
||||
|
||||
// Encryption
|
||||
pub use encryption::{EncryptedData, EncryptionError};
|
||||
pub use encryption::{EncryptedData, EncryptionError, EncryptionKey};
|
||||
pub use encryption::CURRENT_KEY_VERSION;
|
||||
|
||||
// Key types (DerivedKey, KeyType)
|
||||
pub use protocol::{DerivedKey, KeyType};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-20
|
||||
last_updated: 2026-06-23
|
||||
---
|
||||
|
||||
# Encryption
|
||||
@@ -59,27 +59,58 @@ equals the mnemonic. Migration is a one-time re-encryption (see ADR-020).
|
||||
|
||||
## Encryption Key
|
||||
|
||||
The encryption key is derived from the seed at path `m/74'/2'/0'/0'`
|
||||
(`PATHS::ENCRYPTION`):
|
||||
The encryption key is derived from the seed at a version-indexed path
|
||||
(`m/74'/2'/0'/{version-2}'` per ADR-021; v2 is `PATHS::ENCRYPTION`):
|
||||
|
||||
```rust
|
||||
/// AES-256-GCM encryption key. Not `Clone` — move-only, like `DerivedKey`.
|
||||
/// Implements a custom redacting `Debug` (never prints key bytes).
|
||||
#[derive(Zeroize, ZeroizeOnDrop)]
|
||||
pub struct EncryptionKey {
|
||||
key_bytes: [u8; 32], // 32-byte AES-256 key
|
||||
key_version: u32, // for rotation tracking
|
||||
}
|
||||
|
||||
impl EncryptionKey {
|
||||
/// Construct from raw 32 bytes. Private — for internal use.
|
||||
fn new(key_bytes: [u8; 32], key_version: u32) -> Self;
|
||||
|
||||
/// Take the first 32 bytes of derived key material (the private key
|
||||
/// bytes from SLIP-0010 derivation) and construct an `EncryptionKey`.
|
||||
/// This is the bridge from `DerivedKey` (SLIP-0010 output) to
|
||||
/// `EncryptionKey` (AES-256-GCM input). `VaultServiceHandle::encrypt`
|
||||
/// and `decrypt` call this on the cached `DerivedKey` to obtain the
|
||||
/// `EncryptionKey` for the crypto layer.
|
||||
pub fn from_derived_bytes(derived: &[u8], key_version: u32) -> Self;
|
||||
|
||||
/// Return the key version (for rotation tracking).
|
||||
pub fn version(&self) -> u32;
|
||||
|
||||
/// Return the key bytes (crate-internal — for `encrypt`/`decrypt`).
|
||||
pub(crate) fn key_bytes(&self) -> &[u8; 32];
|
||||
}
|
||||
|
||||
impl fmt::Debug for EncryptionKey {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("EncryptionKey")
|
||||
.field("key_version", &self.key_version)
|
||||
.field("key_bytes", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `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.
|
||||
are zeroized before deallocation. It does **not** derive `Clone` (move-only,
|
||||
like `DerivedKey`) and does **not** derive `Serialize` (never crosses a
|
||||
wire). The `Debug` impl is custom and redacts `key_bytes`.
|
||||
|
||||
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.
|
||||
The key is derived once (on first encrypt/decrypt) and cached in the
|
||||
`KeyCache` as a `CachedKey` wrapping a `DerivedKey` (see
|
||||
[service.md](service.md)). `encrypt`/`decrypt` extract the `EncryptionKey`
|
||||
from the cached `DerivedKey` via `EncryptionKey::from_derived_bytes` on each
|
||||
call (the `DerivedKey` is the cached form; the `EncryptionKey` is a
|
||||
short-lived per-call value derived from it).
|
||||
|
||||
## EncryptedData
|
||||
|
||||
@@ -129,9 +160,18 @@ existing v2 data. This is additive — see OQ-22 (key rotation) and ADR-020
|
||||
|
||||
## Encrypt and Decrypt
|
||||
|
||||
These are **module-internal crypto helpers** (in `encryption.rs`), not the
|
||||
public API. The public API is `VaultServiceHandle::encrypt` /
|
||||
`VaultServiceHandle::decrypt` (see [service.md](service.md)), which derive
|
||||
the key (from the cache or via `derive_encryption_key_for_version`), extract
|
||||
the `EncryptionKey` via `EncryptionKey::from_derived_bytes`, and call these
|
||||
helpers.
|
||||
|
||||
```rust
|
||||
pub fn encrypt(plaintext: &str, key: &EncryptionKey) -> Result<EncryptedData, EncryptionError>;
|
||||
pub fn decrypt(encrypted: &EncryptedData, key: &EncryptionKey) -> Result<String, EncryptionError>;
|
||||
// Module-internal (encryption.rs). Not re-exported from the crate root.
|
||||
// VaultServiceHandle::encrypt/decrypt call through to these.
|
||||
pub(crate) fn encrypt(plaintext: &str, key: &EncryptionKey) -> Result<EncryptedData, EncryptionError>;
|
||||
pub(crate) fn decrypt(encrypted: &EncryptedData, key: &EncryptionKey) -> Result<String, EncryptionError>;
|
||||
```
|
||||
|
||||
`encrypt`:
|
||||
@@ -152,9 +192,10 @@ constraint — see below.
|
||||
|
||||
## Key Versioning
|
||||
|
||||
`CURRENT_KEY_VERSION` is `2`. Version `1` is reserved for the TypeScript
|
||||
predecessor's PBKDF2-encrypted data (see ADR-020). Each version maps to a
|
||||
unique derivation path — the last hardened index is the version offset
|
||||
`CURRENT_KEY_VERSION` is `2` (defined in `encryption.rs`, re-exported from
|
||||
the crate root). Version `1` is reserved for the TypeScript predecessor's
|
||||
PBKDF2-encrypted data (see ADR-020). Each version maps to a unique
|
||||
derivation path — the last hardened index is the version offset
|
||||
(see ADR-021):
|
||||
|
||||
```
|
||||
@@ -171,13 +212,9 @@ seed doesn't change), so partial rotation is safe.
|
||||
### Rotation
|
||||
|
||||
Key rotation re-encrypts a blob from one version to another. The vault
|
||||
provides a `rotate` method; the caller (assembly layer or migration tool)
|
||||
handles replacing the blob in storage:
|
||||
|
||||
```rust
|
||||
pub fn rotate(&self, encrypted: &EncryptedData, to_version: u32) -> Result<EncryptedData, VaultServiceError>;
|
||||
```
|
||||
|
||||
provides a `VaultServiceHandle::rotate` method (see [service.md →
|
||||
rotate](service.md#rotateencrypted-to_version--encrypteddata)); the caller
|
||||
(assembly layer or migration tool) handles replacing the blob in storage.
|
||||
Rotation decrypts with the old version's key and re-encrypts with the new
|
||||
version's key. No new mnemonic needed — the same seed produces all version
|
||||
keys via different paths. See ADR-021 for the full mechanism.
|
||||
@@ -185,7 +222,8 @@ keys via different paths. See ADR-021 for the full mechanism.
|
||||
**The current source uses `CURRENT_KEY_VERSION = 1` with HD derivation and
|
||||
does not implement version-indexed paths or `rotate`.** These are drift
|
||||
items to be corrected during implementation sync. See ADR-020 (version
|
||||
bump to 2) and ADR-021 (rotation mechanism).
|
||||
bump to 2) and ADR-021 (rotation mechanism). See the [Known Source
|
||||
Drift](README.md#known-source-drift) table in the vault README.
|
||||
|
||||
## Errors
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-22-19
|
||||
last_updated: 2026-06-23
|
||||
---
|
||||
|
||||
# Mnemonic and Key Derivation
|
||||
@@ -91,6 +91,12 @@ pub struct Seed {
|
||||
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.
|
||||
|
||||
`Seed` derives `Clone` for convenience (derivation functions take `&[u8]`,
|
||||
and the cache rebuild may need to reference the seed multiple times).
|
||||
Callers should prefer `&Seed` and avoid cloning — the seed is the root of
|
||||
trust, and each clone duplicates it into heap memory that lingers until
|
||||
zeroized.
|
||||
|
||||
## SLIP-0010 Ed25519 Derivation
|
||||
|
||||
The default derivation scheme. SLIP-0010 specifies Ed25519 HD key
|
||||
@@ -149,6 +155,15 @@ pub struct ExtendedPrivKey {
|
||||
The result of SLIP-0010 derivation. Zeroized on drop. Accessors return
|
||||
slices — the caller copies what it needs.
|
||||
|
||||
```rust
|
||||
impl ExtendedPrivKey {
|
||||
pub fn private_key(&self) -> &[u8]; // 32 bytes
|
||||
pub fn public_key(&self) -> &[u8]; // 32 bytes
|
||||
pub fn chain_code(&self) -> &[u8]; // 32 bytes
|
||||
pub fn path(&self) -> &str;
|
||||
}
|
||||
```
|
||||
|
||||
## BIP-0032 secp256k1 Derivation (Ethereum)
|
||||
|
||||
Feature-gated behind `secp256k1`. Implements BIP-0032 HD key derivation for
|
||||
@@ -163,6 +178,33 @@ 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.
|
||||
|
||||
```rust
|
||||
#[derive(Clone, Zeroize)]
|
||||
#[zeroize(drop)]
|
||||
#[cfg(feature = "secp256k1")]
|
||||
pub struct Secp256k1ExtendedPrivKey {
|
||||
private_key: Vec<u8>, // 32 bytes
|
||||
public_key: Vec<u8>, // 33 bytes (compressed)
|
||||
chain_code: Vec<u8>, // 32 bytes
|
||||
path: String, // the path that produced this key
|
||||
}
|
||||
|
||||
#[cfg(feature = "secp256k1")]
|
||||
impl Secp256k1ExtendedPrivKey {
|
||||
pub fn private_key(&self) -> &[u8];
|
||||
pub fn public_key(&self) -> &[u8];
|
||||
pub fn chain_code(&self) -> &[u8];
|
||||
pub fn path(&self) -> &str;
|
||||
}
|
||||
```
|
||||
|
||||
The `VaultServiceHandle::derive_ethereum_key` method calls
|
||||
`derive_secp256k1_path` and wraps the result into a `DerivedKey`:
|
||||
`DerivedKey { key_type: KeyType::Secp256k1, private_key:
|
||||
extended.private_key().to_vec(), public_key:
|
||||
extended.public_key().to_vec() }`. The `Secp256k1ExtendedPrivKey` is then
|
||||
dropped and zeroized; the `DerivedKey` is the caller-facing type.
|
||||
|
||||
### Why a separate module
|
||||
|
||||
SLIP-0010 and BIP-0032 differ in:
|
||||
@@ -200,9 +242,17 @@ Helper functions construct parameterized paths:
|
||||
|
||||
```rust
|
||||
pub fn device_path(index: u32) -> String; // m/74'/0'/0'/{index}'
|
||||
pub fn encryption_path_for_version(version: u32) -> String; // m/74'/2'/0'/{version-2}'
|
||||
pub fn encryption_path_for_version(version: u32) -> Result<String, DerivationError>;
|
||||
// m/74'/2'/0'/{version-2}' — returns InvalidPath for version < 2
|
||||
```
|
||||
|
||||
`encryption_path_for_version` returns `DerivationError::InvalidPath` for
|
||||
`version < 2`. v1 is reserved for the TS PBKDF2 legacy (ADR-020) — the vault
|
||||
cannot derive it, and silently mapping v1 to the v2 path would produce the
|
||||
wrong key (making v1 blobs appear to "decrypt" with a corrupted key). v0 is
|
||||
meaningless. `derive_encryption_key_for_version` propagates this error
|
||||
(`VaultServiceError::InvalidPath`).
|
||||
|
||||
### Path semantics
|
||||
|
||||
| Path | Purpose | Key type | Used by |
|
||||
@@ -216,7 +266,9 @@ pub fn encryption_path_for_version(version: u32) -> String; // m/74'/2'/0'/{v
|
||||
`encryption_path_for_version` maps a key version to its derivation path
|
||||
(ADR-021). v2 (current) maps to `m/74'/2'/0'/0'` (which is `PATHS::ENCRYPTION`);
|
||||
v3 maps to `m/74'/2'/0'/1'`; etc. This is the rotation mechanism — each
|
||||
version gets a cryptographically independent key from the same seed.
|
||||
version gets a cryptographically independent key from the same seed. Returns
|
||||
`InvalidPath` for `version < 2` (v1 is TS PBKDF2 legacy — undecryptable by
|
||||
the vault by design).
|
||||
|
||||
`KeyType` tags `DerivedKey` (see [protocol.md](protocol.md)) and
|
||||
`CachedKey` (see [service.md](service.md)) so consumers know what they
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-22-25
|
||||
last_updated: 2026-06-23
|
||||
---
|
||||
|
||||
# Protocol
|
||||
@@ -26,7 +26,7 @@ The result of key derivation. Holds the key type, private key, and public
|
||||
key.
|
||||
|
||||
```rust
|
||||
#[derive(Zeroize, Deserialize)]
|
||||
#[derive(Zeroize)]
|
||||
#[zeroize(drop)]
|
||||
pub struct DerivedKey {
|
||||
#[zeroize(skip)]
|
||||
@@ -38,6 +38,12 @@ pub struct DerivedKey {
|
||||
}
|
||||
```
|
||||
|
||||
`DerivedKey` does **not** derive `Deserialize` via `#[derive]`. It has a **custom
|
||||
`Deserialize` impl** that rejects redacted payloads — see
|
||||
[Serialization Redaction](#serialization-redaction) below. (A derived
|
||||
`Deserialize` would generate a default impl that conflicts with the manual one,
|
||||
and would not produce the explicit redaction-rejection error the spec requires.)
|
||||
|
||||
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
|
||||
@@ -65,10 +71,13 @@ private key, regardless of format:
|
||||
`"[REDACTED]"`. This is defense-in-depth — if a `DerivedKey` accidentally
|
||||
ends up in a log, a JSON config, or debug output, the private key is not
|
||||
exposed.
|
||||
- **Deserialization**: rejects `private_key == "[REDACTED]"` with an error.
|
||||
A JSON-deserialized `DerivedKey` with a redacted private key is invalid
|
||||
and produces a deserialization error, not a corrupted key. This resolves
|
||||
review #002 W8 (silent corruption on JSON-deserialized `DerivedKey`).
|
||||
- **Deserialization**: a custom `Deserialize` impl rejects
|
||||
`private_key == "[REDACTED]"` with a deserialization error (not a corrupted
|
||||
key). This resolves review #002 W8 (silent corruption on JSON-deserialized
|
||||
`DerivedKey`). The custom impl is required because `#[derive(Deserialize)]`
|
||||
would generate a default impl that conflicts and would only fail incidentally
|
||||
(serde type mismatch: string vs sequence), not with the explicit
|
||||
redaction-rejection error the spec requires.
|
||||
- **No binary-format preservation path.** ADR-025 dropped the postcard/remote
|
||||
dispatch path that previously preserved private key bytes in binary
|
||||
formats. `DerivedKey` is always used in-process (ADR-014: never appears
|
||||
@@ -76,6 +85,49 @@ private key, regardless of format:
|
||||
`DerivedKey` over the wire, it defines its own serialization for that
|
||||
context — the vault's `DerivedKey` stays redact-always.
|
||||
|
||||
```rust
|
||||
// Custom Serialize — always redacts private_key
|
||||
impl serde::Serialize for DerivedKey {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where S: serde::Serializer {
|
||||
use serde::SerializeStruct;
|
||||
let mut s = serializer.serialize_struct("DerivedKey", 3)?;
|
||||
s.serialize_field("key_type", &self.key_type)?;
|
||||
s.serialize_field("private_key", "[REDACTED]")?; // never the real bytes
|
||||
s.serialize_field("public_key", &self.public_key)?;
|
||||
s.end()
|
||||
}
|
||||
}
|
||||
|
||||
// Custom Deserialize — rejects "[REDACTED]" with an error
|
||||
impl<'de> serde::Deserialize<'de> for DerivedKey {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where D: serde::Deserializer<'de> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct DerivedKeyHelper {
|
||||
key_type: KeyType,
|
||||
private_key: Vec<u8>,
|
||||
public_key: Vec<u8>,
|
||||
}
|
||||
let helper = DerivedKeyHelper::deserialize(deserializer)?;
|
||||
// Reject redacted payloads — a JSON-deserialized DerivedKey with a
|
||||
// redacted private key is invalid, not a corrupted key.
|
||||
if helper.private_key == b"[REDACTED]" {
|
||||
return Err(serde::de::Error::custom(
|
||||
"DerivedKey.private_key is \"[REDACTED]\" — redacted payloads \
|
||||
cannot be deserialized. JSON round-tripping a DerivedKey is \
|
||||
not supported (the private key is gone)."
|
||||
));
|
||||
}
|
||||
Ok(DerivedKey {
|
||||
key_type: helper.key_type,
|
||||
private_key: helper.private_key,
|
||||
public_key: helper.public_key,
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
@@ -112,8 +164,10 @@ pub enum KeyType {
|
||||
```
|
||||
|
||||
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).
|
||||
`KeyType` is `Serialize`/`Deserialize` (retained for `EncryptedData` interop
|
||||
and future use — ADR-025 removed the irpc dispatch path that previously
|
||||
justified these derives, but the type remains serializable for structured
|
||||
storage scenarios) and `Clone` (it's not secret material — it's a tag).
|
||||
|
||||
## Wire Format
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-22-25
|
||||
last_updated: 2026-06-23
|
||||
---
|
||||
|
||||
# Service
|
||||
@@ -24,12 +24,16 @@ no remote dispatch.
|
||||
## VaultServiceHandle
|
||||
|
||||
The primary API for local (in-process) use. Thread-safe via
|
||||
`Arc<RwLock<VaultServiceInner>>`.
|
||||
`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<RwLock<VaultServiceInner>>,
|
||||
inner: Arc<std::sync::RwLock<VaultServiceInner>>,
|
||||
}
|
||||
|
||||
struct VaultServiceInner {
|
||||
@@ -40,6 +44,11 @@ struct VaultServiceInner {
|
||||
}
|
||||
```
|
||||
|
||||
**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.
|
||||
|
||||
@@ -85,6 +94,10 @@ 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
|
||||
@@ -137,22 +150,27 @@ 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) → EncryptionKey
|
||||
### derive_encryption_key_for_version(version) → DerivedKey
|
||||
|
||||
```rust
|
||||
pub fn derive_encryption_key_for_version(&self, version: u32) -> Result<EncryptionKey, VaultServiceError>;
|
||||
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 `decrypt` uses to select the correct key for
|
||||
each blob — see [encryption.md](encryption.md) and ADR-021.
|
||||
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`. The two share
|
||||
the same cache (keyed by derivation path).
|
||||
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)
|
||||
|
||||
@@ -172,10 +190,11 @@ Derive a secp256k1 keypair at the given BIP-0032 path. Returns
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -218,6 +237,15 @@ pub struct KeyCache {
|
||||
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
|
||||
@@ -228,9 +256,10 @@ pub struct CacheConfig {
|
||||
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.
|
||||
- **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.
|
||||
|
||||
@@ -241,15 +270,18 @@ pub struct CacheConfig {
|
||||
| `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 key (at `PATHS::ENCRYPTION`) is cached; the plaintext is not |
|
||||
| `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<RwLock<VaultServiceInner>>` — clone it, share it, call methods
|
||||
directly. The RwLock provides concurrent reads (derive operations) and
|
||||
exclusive writes (unlock/lock).
|
||||
`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):
|
||||
|
||||
Reference in New Issue
Block a user