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.
14 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-06-23 |
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.
Key Derivation: HD, Not PBKDF2
The encryption key is derived from the BIP39 seed via SLIP-0010 HD
derivation at path m/74'/2'/0'/0' (PATHS::ENCRYPTION). This is a
deliberate choice over the PBKDF2 approach used by the TypeScript
predecessor (@alkdev/storage/src/graphs/crypto.ts). See ADR-020 for the
full rationale.
| Aspect | TS predecessor (PBKDF2) | Vault (HD derivation) |
|---|---|---|
| Secret input | Password (user-provided) | BIP39 seed (64 bytes) |
| Salt role | Load-bearing — part of key derivation | Unused — stored for wire-format compat |
| Derivation | PBKDF2 (100k iterations) | SLIP-0010 (a few HMACs) |
| Speed | Intentionally slow | Instant |
| Reproducible | Only with exact password | Deterministic from mnemonic |
| key_version | 1 | 2 |
Data encrypted by the TS implementation (PBKDF2, key_version=1) cannot be decrypted by the vault — the keys are different even if the password equals the mnemonic. Migration is a one-time re-encryption (see ADR-020).
Encryption Key
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):
/// 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()
}
}
EncryptionKey implements Zeroize and ZeroizeOnDrop — the key bytes
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 (on first encrypt/decrypt) and cached in the
KeyCache as a CachedKey wrapping a DerivedKey (see
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
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.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct EncryptedData {
pub key_version: u32, // rotation tracking
pub salt: String, // base64, 32 bytes — unused in v2 (wire-format compat, see ADR-020)
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 (unused in v2 — reserved for future KDF)
The salt field is unused for key derivation in v2 (HD derivation
doesn't need a salt — the derivation path provides domain separation). The
salt is generated randomly (32 bytes) and stored for wire-format
compatibility with the TypeScript EncryptedDataSchema, but it plays no
cryptographic role.
In the TypeScript predecessor, the salt was load-bearing — it was part of the PBKDF2 key derivation. The vault's HD derivation doesn't use it, but the field is kept in the wire format so the struct doesn't need to change if a future KDF-based derivation is added.
If KDF-based key derivation is ever implemented (using HKDF or PBKDF2 with
the salt as input), it would be a new key_version and would not affect
existing v2 data. This is additive — see OQ-22 (key rotation) and ADR-020
(HD derivation decision).
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), 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.
// 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:
- Generates a random 12-byte IV (must use
OsRng— see Security Constraints) - Generates a random 32-byte salt (stored for wire-format compat, unused in key derivation)
- Encrypts the plaintext with AES-256-GCM
- Returns
EncryptedData { key_version, salt, iv, data }
decrypt:
- Decodes the base64 IV and ciphertext
- Decrypts with AES-256-GCM (verifies the auth tag)
- 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 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):
v2: m/74'/2'/0'/0' ← PATHS::ENCRYPTION (current)
v3: m/74'/2'/0'/1'
v4: m/74'/2'/0'/2'
encrypt stamps the version onto new blobs. decrypt derives the key at
the path indicated by encrypted.key_version — each version has its own
cryptographically independent key. Old version keys remain derivable (the
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 VaultServiceHandle::rotate method (see service.md →
rotate); 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.
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). See the Known Source
Drift table in the vault README.
Errors
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 }, // unused — see note below
}
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. ADR-021 implements key
rotation via version-indexed derivation paths — decrypt derives the key
at the path indicated by encrypted.key_version, so there is no
version-mismatch to detect at the error level (every blob carries its own
version, and every version has a derivable key). This variant predates
ADR-021's rotation mechanism and is retained in the enum for source
compatibility but is not emitted by any code path in v2. An implementer
should not wire it up or expect it to fire. If a future use case requires
enforcing version constraints (e.g., "refuse to decrypt blobs older than
v3"), this variant could be repurposed — but that would be a new decision,
not part of ADR-021's rotation scheme.
Design Decisions
| Decision | ADR | Summary |
|---|---|---|
| AES-256-GCM for credential encryption | ADR-026 | Authenticated encryption, hardware-accelerated |
| HD derivation, not PBKDF2 | ADR-020 | Seed-derived key; no password; deterministic |
| Salt unused in v2 (wire-format compat) | ADR-020 | Kept for TS compat; not used in key derivation |
Key derived at m/74'/2'/0'/0' |
ADR-026 | Dedicated account for encryption keys |
| Version-indexed paths for rotation | ADR-021 | m/74'/2'/0'/{version-2}' |
| Key versioning (v1=TS PBKDF2, v2=vault HD) | ADR-020 | Distinguishes derivation methods |
| All fields base64-encoded | — | JSON serialization compatibility |
EncryptedData wire format frozen |
ADR-018 | Fields, encoding, semantics locked; no removal without migration |
Open Questions
See open-questions.md for full details.
- OQ-20 (resolved by ADR-020): Salt/KDF — HD derivation is the method; the salt field is unused in v2 (wire-format compatibility only).
- OQ-22 (resolved by ADR-021): Key rotation — version-indexed paths;
rotatemethod decrypts old, re-encrypts new.
Security Constraints
These are security-critical implementation requirements.
- OsRng for IVs: The IV must be generated with
OsRng(or an equivalent CSPRNG), neverrand::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 usesrand::random()for IV generation (encryption.rsline 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;OsRngreads from the operating system's entropy source and is the correct choice for cryptographic nonces. - Zeroized drop:
EncryptionKeyderivesZeroizeandZeroizeOnDrop. The key bytes are zeroized before deallocation. Do not store key material in types that don't zeroize. - No plaintext in logs:
EncryptedDatais safe to log (it's ciphertext). The plaintext and theEncryptionKeyare not. Do not addDebugorDisplayimplementations that print key bytes or plaintext.
References
- NIST SP 800-38D — 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 — how the vault caches the encryption key