docs(architecture): add ADR-020, resolve OQ-20 — HD derivation for encryption keys
The vault uses SLIP-0010 HD derivation from the BIP39 seed for the AES-256-GCM encryption key, not PBKDF2. This replaces the TypeScript predecessor's (@alkdev/storage/src/graphs/crypto.ts) PBKDF2-based approach. Key decisions: - HD derivation at m/74'/2'/0'/0' produces the encryption key - PBKDF2 is not implemented in the vault; no password-based derivation - salt field is unused in v2 (wire-format compat only) - key_version=1 reserved for TS PBKDF2 data; key_version=2 for vault HD - TS-encrypted data requires one-time migration to v2 - CURRENT_KEY_VERSION changes from 1 to 2 (source drift flagged) OQ-20 resolved: the encryption key derivation method is locked. OQ-22 (key rotation workflow) remains open but does not block implementation.
This commit is contained in:
@@ -7,9 +7,9 @@ last_updated: 2026-06-19
|
|||||||
|
|
||||||
## Current State
|
## Current State
|
||||||
|
|
||||||
**Pre-implementation.** The project has completed a pivot from a three-layer model to an ALPN-as-service model. The greenfield workspace contains only `alknet-vault` (stable — implementation exists) and research/reference material. Foundational ADRs (001–019) are in place, including the BiStream type definition (ADR-007), vault integration (ADR-008), ALPN router/endpoint (ADR-010), AuthContext structure (ADR-011), call protocol stream model (ADR-012), Rust as canonical implementation language (ADR-013), secret material flow with capability injection (ADR-014), privilege model with authority context (ADR-015), abort cascade for nested calls (ADR-016), call protocol client and adapter contract (ADR-017), vault standalone crate (ADR-018), and vault assembly-layer-only access (ADR-019). The alknet-core, alknet-call, and alknet-vault crate specs are in draft.
|
**Pre-implementation.** The project has completed a pivot from a three-layer model to an ALPN-as-service model. The greenfield workspace contains only `alknet-vault` (stable — implementation exists) and research/reference material. Foundational ADRs (001–020) are in place, including the BiStream type definition (ADR-007), vault integration (ADR-008), ALPN router/endpoint (ADR-010), AuthContext structure (ADR-011), call protocol stream model (ADR-012), Rust as canonical implementation language (ADR-013), secret material flow with capability injection (ADR-014), privilege model with authority context (ADR-015), abort cascade for nested calls (ADR-016), call protocol client and adapter contract (ADR-017), vault standalone crate (ADR-018), vault assembly-layer-only access (ADR-019), and HD derivation for encryption keys (ADR-020). The alknet-core, alknet-call, and alknet-vault crate specs are in draft.
|
||||||
|
|
||||||
**Next step**: Review the vault spec documents (newly added), then begin implementation. All open questions for the core and call crates are resolved; the vault crate has three open/deferred OQs (OQ-20, OQ-21, OQ-22) that do not block implementation.
|
**Next step**: Review the vault spec documents, then begin implementation. All open questions for the core and call crates are resolved; the vault crate has two open/deferred OQs (OQ-21, OQ-22) that do not block implementation.
|
||||||
|
|
||||||
## Architecture Documents
|
## Architecture Documents
|
||||||
|
|
||||||
@@ -54,6 +54,7 @@ last_updated: 2026-06-19
|
|||||||
| [017](decisions/017-call-protocol-client-and-adapter-contract.md) | Call Protocol Client and Adapter Contract | Accepted |
|
| [017](decisions/017-call-protocol-client-and-adapter-contract.md) | Call Protocol Client and Adapter Contract | Accepted |
|
||||||
| [018](decisions/018-vault-standalone-crate.md) | Vault as Standalone Crate | Accepted |
|
| [018](decisions/018-vault-standalone-crate.md) | Vault as Standalone Crate | Accepted |
|
||||||
| [019](decisions/019-vault-assembly-layer-only.md) | Vault Assembly-Layer-Only Access | Accepted |
|
| [019](decisions/019-vault-assembly-layer-only.md) | Vault Assembly-Layer-Only Access | Accepted |
|
||||||
|
| [020](decisions/020-hd-derivation-for-encryption-keys.md) | HD Derivation for Encryption Keys | Accepted |
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
@@ -79,9 +80,9 @@ See [open-questions.md](open-questions.md) for the full tracker.
|
|||||||
- **OQ-13**: Operation path format — `/{service}/{op}` is the correct design for alknet-call, not a simplification
|
- **OQ-13**: Operation path format — `/{service}/{op}` is the correct design for alknet-call, not a simplification
|
||||||
- **OQ-14**: Batch operation semantics — multiple correlated `call.requested` events is the correct protocol design, not a simplification
|
- **OQ-14**: Batch operation semantics — multiple correlated `call.requested` events is the correct protocol design, not a simplification
|
||||||
- **OQ-19**: Session-scoped registries — agent-written operations via `OperationEnv` trait layering; protocol doesn't need changes; `OperationEnv` must remain a trait
|
- **OQ-19**: Session-scoped registries — agent-written operations via `OperationEnv` trait layering; protocol doesn't need changes; `OperationEnv` must remain a trait
|
||||||
|
- **OQ-20**: Encryption key derivation — HD derivation from BIP39 seed, not PBKDF2; salt field unused in v2 (wire-format compat) (ADR-020)
|
||||||
|
|
||||||
**Open (low priority, does not block implementation):**
|
**Open (low priority, does not block implementation):**
|
||||||
- **OQ-20**: Salt/KDF Phase B — the `EncryptedData.salt` field is reserved; v1 does not use it. Two-way door.
|
|
||||||
- **OQ-22**: Key rotation mechanism — key versioning is in place; the rotation workflow is not specced. Two-way door.
|
- **OQ-22**: Key rotation mechanism — key versioning is in place; the rotation workflow is not specced. Two-way door.
|
||||||
|
|
||||||
**Deferred (not active):**
|
**Deferred (not active):**
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ cross the network.
|
|||||||
| Document | Status | Description |
|
| Document | Status | Description |
|
||||||
|----------|--------|-------------|
|
|----------|--------|-------------|
|
||||||
| [mnemonic-derivation.md](mnemonic-derivation.md) | draft | BIP39, SLIP-0010, BIP-0032, derivation paths, key types |
|
| [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) |
|
| [encryption.md](encryption.md) | draft | AES-256-GCM, EncryptedData, key versioning, HD derivation (ADR-020) |
|
||||||
| [service.md](service.md) | draft | VaultServiceHandle lifecycle, actor dispatch, cache, error model |
|
| [service.md](service.md) | draft | VaultServiceHandle lifecycle, actor dispatch, cache, error model |
|
||||||
| [protocol.md](protocol.md) | draft | VaultProtocol irpc messages, DerivedKey redaction, serialization |
|
| [protocol.md](protocol.md) | draft | VaultProtocol irpc messages, DerivedKey redaction, serialization |
|
||||||
|
|
||||||
@@ -43,12 +43,13 @@ cross the network.
|
|||||||
| [014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Secret Material Flow and Capability Injection | Capabilities carry vault-derived material |
|
| [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 |
|
| [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 |
|
| [019](../../decisions/019-vault-assembly-layer-only.md) | Vault Assembly-Layer-Only Access | The assembly layer is the sole caller |
|
||||||
|
| [020](../../decisions/020-hd-derivation-for-encryption-keys.md) | HD Derivation for Encryption Keys | SLIP-0010 derivation, not PBKDF2; salt unused in v2 |
|
||||||
|
|
||||||
## Relevant Open Questions
|
## Relevant Open Questions
|
||||||
|
|
||||||
| OQ | Title | Status | Relevance |
|
| OQ | Title | Status | Relevance |
|
||||||
|----|-------|--------|-----------|
|
|----|-------|--------|-----------|
|
||||||
| OQ-20 | Salt/KDF Phase B | open | Salt field is reserved; v1 does not use it |
|
| OQ-20 | Encryption key derivation | resolved (ADR-020) | HD derivation from seed; salt field unused in v2 |
|
||||||
| OQ-21 | Remote vault administration | deferred | Network unlock not supported; needs ADR if ever needed |
|
| 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 |
|
| OQ-22 | Key rotation mechanism | open | Key versioning is in place; rotation workflow is not specced |
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,27 @@ dangerous) plaintext.
|
|||||||
GCM is also hardware-accelerated on modern CPUs (AES-NI), making it fast
|
GCM is also hardware-accelerated on modern CPUs (AES-NI), making it fast
|
||||||
enough that encryption is never a bottleneck.
|
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
|
## Encryption Key
|
||||||
|
|
||||||
The encryption key is derived from the seed at path `m/74'/2'/0'/0'`
|
The encryption key is derived from the seed at path `m/74'/2'/0'/0'`
|
||||||
@@ -77,7 +98,7 @@ changing it breaks both `alknet-storage` and the TypeScript consumer.
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct EncryptedData {
|
pub struct EncryptedData {
|
||||||
pub key_version: u32, // rotation tracking
|
pub key_version: u32, // rotation tracking
|
||||||
pub salt: String, // base64, 32 bytes — reserved for Phase B (see OQ-20)
|
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 iv: String, // base64, 12 bytes — AES-GCM nonce
|
||||||
pub data: String, // base64 — ciphertext + auth tag
|
pub data: String, // base64 — ciphertext + auth tag
|
||||||
}
|
}
|
||||||
@@ -88,23 +109,23 @@ compatibility. The `iv` is 12 bytes (the standard GCM nonce size). The
|
|||||||
`data` field includes the GCM authentication tag appended to the ciphertext
|
`data` field includes the GCM authentication tag appended to the ciphertext
|
||||||
(the `aes-gcm` crate handles this).
|
(the `aes-gcm` crate handles this).
|
||||||
|
|
||||||
### Salt field (reserved for Phase B)
|
### Salt field (unused in v2 — reserved for future KDF)
|
||||||
|
|
||||||
The `salt` field is **reserved for future KDF-based key derivation** (Phase
|
The `salt` field is **unused for key derivation in v2** (HD derivation
|
||||||
B, OQ-20). In v1, the encryption key is derived directly from the seed at
|
doesn't need a salt — the derivation path provides domain separation). The
|
||||||
path `m/74'/2'/0'/0'` **without using the salt**. The salt is generated
|
salt is generated randomly (32 bytes) and stored for wire-format
|
||||||
randomly (32 bytes) and stored in `EncryptedData.salt` for forward
|
compatibility with the TypeScript `EncryptedDataSchema`, but it plays no
|
||||||
compatibility, but it plays no role in the v1 key derivation process.
|
cryptographic role.
|
||||||
|
|
||||||
When key rotation is implemented in Phase B, the salt will be used as
|
In the TypeScript predecessor, the salt was load-bearing — it was part of
|
||||||
input to HKDF or PBKDF2 for stretch-based key derivation, allowing the
|
the PBKDF2 key derivation. The vault's HD derivation doesn't use it, but the
|
||||||
same seed to produce different encryption keys without changing the
|
field is kept in the wire format so the struct doesn't need to change if a
|
||||||
derivation path. The wire format does not need to change — the `salt`
|
future KDF-based derivation is added.
|
||||||
field is already present and populated.
|
|
||||||
|
|
||||||
This is a deliberate forward-compatibility decision: the field exists in
|
If KDF-based key derivation is ever implemented (using HKDF or PBKDF2 with
|
||||||
v1 so that v2 can use it without a format migration. The cost is 32 extra
|
the salt as input), it would be a new `key_version` and would not affect
|
||||||
bytes per `EncryptedData`; the benefit is no future format break.
|
existing v2 data. This is additive — see OQ-22 (key rotation) and ADR-020
|
||||||
|
(HD derivation decision).
|
||||||
|
|
||||||
## Encrypt and Decrypt
|
## Encrypt and Decrypt
|
||||||
|
|
||||||
@@ -131,18 +152,25 @@ constraint — see below.
|
|||||||
|
|
||||||
## Key Versioning
|
## Key Versioning
|
||||||
|
|
||||||
`CURRENT_KEY_VERSION` is `1`. Key versioning allows re-encryption when the
|
`CURRENT_KEY_VERSION` is `2`. Version `1` is reserved for the TypeScript
|
||||||
encryption key is rotated:
|
predecessor's PBKDF2-encrypted data (see ADR-020). Key versioning allows
|
||||||
|
re-encryption when the encryption key is rotated:
|
||||||
|
|
||||||
1. Derive a new key from a new derivation path or new seed
|
1. Derive a new key from a new derivation path or new seed
|
||||||
2. Decrypt all existing `EncryptedData` with key version 1
|
2. Decrypt all existing `EncryptedData` with key version 2
|
||||||
3. Re-encrypt with key version 2
|
3. Re-encrypt with key version 3
|
||||||
4. Update storage
|
4. Update storage
|
||||||
|
|
||||||
The key version is stored in `EncryptedData.key_version` so decryption can
|
The key version is stored in `EncryptedData.key_version` so decryption can
|
||||||
select the right key. The rotation workflow itself is not specced — see
|
select the right key. The rotation workflow itself is not specced — see
|
||||||
OQ-22.
|
OQ-22.
|
||||||
|
|
||||||
|
**The current source uses `CURRENT_KEY_VERSION = 1` with HD derivation.**
|
||||||
|
This is a drift from the spec — the source's v1 is HD-derived, but the TS
|
||||||
|
v1 is PBKDF2-derived. Same version number, different derivation. The source
|
||||||
|
must be updated to `CURRENT_KEY_VERSION = 2` to distinguish vault-encrypted
|
||||||
|
data from TS-encrypted data. See ADR-020.
|
||||||
|
|
||||||
## Errors
|
## Errors
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
@@ -150,7 +178,7 @@ pub enum EncryptionError {
|
|||||||
Encryption(String), // encryption failed
|
Encryption(String), // encryption failed
|
||||||
Decryption(String), // decryption failed (wrong key, tampered data, bad UTF-8)
|
Decryption(String), // decryption failed (wrong key, tampered data, bad UTF-8)
|
||||||
Decoding(String), // base64 decoding failed
|
Decoding(String), // base64 decoding failed
|
||||||
KeyVersionMismatch { expected: u32, actual: u32 }, // reserved for Phase B
|
KeyVersionMismatch { expected: u32, actual: u32 }, // reserved for future rotation (OQ-22)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -158,29 +186,30 @@ Decryption failures are intentionally generic — they don't distinguish
|
|||||||
"wrong key" from "tampered data" from "corrupted storage" to avoid
|
"wrong key" from "tampered data" from "corrupted storage" to avoid
|
||||||
leaking information to an attacker.
|
leaking information to an attacker.
|
||||||
|
|
||||||
`KeyVersionMismatch` is **defined but unused in v1** — neither `encrypt()`
|
`KeyVersionMismatch` is **defined but unused in v2** — neither `encrypt()`
|
||||||
nor `decrypt()` returns it. It is reserved for Phase B key rotation (OQ-22),
|
nor `decrypt()` returns it. It is reserved for future key rotation
|
||||||
where the vault may enforce version matching before decrypting. In v1, the
|
enforcement (OQ-22), where the vault may enforce version matching before
|
||||||
`key_version` is stamped onto `EncryptedData` and `EncryptionKey` for
|
decrypting. In v2, the `key_version` is stamped onto `EncryptedData` and
|
||||||
forward compatibility but does not gate decryption. An implementer should
|
`EncryptionKey` for forward compatibility but does not gate decryption. An
|
||||||
not expect this variant to fire in v1.
|
implementer should not expect this variant to fire in v2.
|
||||||
|
|
||||||
## Design Decisions
|
## Design Decisions
|
||||||
|
|
||||||
| Decision | ADR | Summary |
|
| Decision | ADR | Summary |
|
||||||
|----------|-----|---------|
|
|----------|-----|---------|
|
||||||
| AES-256-GCM for credential encryption | — | Authenticated encryption, hardware-accelerated |
|
| 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 |
|
| HD derivation, not PBKDF2 | [ADR-020](../../decisions/020-hd-derivation-for-encryption-keys.md) | Seed-derived key; no password; deterministic |
|
||||||
|
| Salt unused in v2 (wire-format compat) | [ADR-020](../../decisions/020-hd-derivation-for-encryption-keys.md) | Kept for TS compat; not used in key derivation |
|
||||||
| Key derived at `m/74'/2'/0'/0'` | — | Dedicated account for encryption keys |
|
| Key derived at `m/74'/2'/0'/0'` | — | Dedicated account for encryption keys |
|
||||||
| Key versioning | — | Rotation support without format break |
|
| Key versioning (v1=TS PBKDF2, v2=vault HD) | [ADR-020](../../decisions/020-hd-derivation-for-encryption-keys.md) | Distinguishes derivation methods |
|
||||||
| All fields base64-encoded | — | JSON serialization compatibility |
|
| All fields base64-encoded | — | JSON serialization compatibility |
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
See [open-questions.md](../../open-questions.md) for full details.
|
See [open-questions.md](../../open-questions.md) for full details.
|
||||||
|
|
||||||
- **OQ-20** (open): Salt/KDF Phase B — when and how to use the reserved
|
- **OQ-20** (resolved by ADR-020): Salt/KDF — HD derivation is the method;
|
||||||
`salt` field for KDF-based key derivation.
|
the salt field is unused in v2 (wire-format compatibility only).
|
||||||
- **OQ-22** (open): Key rotation mechanism — the key versioning is in place,
|
- **OQ-22** (open): Key rotation mechanism — the key versioning is in place,
|
||||||
but the rotation workflow (re-encrypt all data, update storage) is not
|
but the rotation workflow (re-encrypt all data, update storage) is not
|
||||||
specced.
|
specced.
|
||||||
|
|||||||
@@ -283,8 +283,9 @@ assembly-layer concern.
|
|||||||
|
|
||||||
See [open-questions.md](../../open-questions.md) for full details.
|
See [open-questions.md](../../open-questions.md) for full details.
|
||||||
|
|
||||||
- **OQ-20** (open): Salt/KDF Phase B — the `EncryptedData.salt` field is
|
- **OQ-20** (resolved by ADR-020): Encryption key derivation — HD derivation
|
||||||
reserved; v1 does not use it. See [encryption.md](encryption.md).
|
from seed, not PBKDF2. The salt field is unused in v2. See
|
||||||
|
[encryption.md](encryption.md).
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
|
|||||||
@@ -302,9 +302,10 @@ error types — the CLI binary converts at the assembly boundary (ADR-018).
|
|||||||
| Decision | ADR | Summary |
|
| Decision | ADR | Summary |
|
||||||
|----------|-----|---------|
|
|----------|-----|---------|
|
||||||
| Assembly layer is the sole caller | [ADR-019](../../decisions/019-vault-assembly-layer-only.md) | Handlers never hold a vault reference |
|
| Assembly layer is the sole caller | [ADR-019](../../decisions/019-vault-assembly-layer-only.md) | Handlers never hold a vault reference |
|
||||||
|
| Encryption key via HD derivation | [ADR-020](../../decisions/020-hd-derivation-for-encryption-keys.md) | Seed-derived key at `m/74'/2'/0'/0'`, not PBKDF2 |
|
||||||
| RwLock for thread safety | — | Multiple readers (derive), exclusive writer (unlock/lock) |
|
| RwLock for thread safety | — | Multiple readers (derive), exclusive writer (unlock/lock) |
|
||||||
| TTL + LRU cache | — | Bounded memory, fresh keys, zeroized eviction |
|
| 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 |
|
| Actor for in-process irpc 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 |
|
| `derive_password` not cached | — | One-shot; caching grows cache with no reuse |
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
# ADR-020: HD Derivation for Encryption Keys
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The vault encrypts external credentials (API keys, OAuth tokens) that cannot
|
||||||
|
be derived from the BIP39 seed — they're arbitrary bytes. The encryption
|
||||||
|
key for AES-256-GCM must come from somewhere. Two approaches exist:
|
||||||
|
|
||||||
|
### The TypeScript predecessor
|
||||||
|
|
||||||
|
The `@alkdev/storage` library (`/workspace/@alkdev/storage/src/graphs/crypto.ts`)
|
||||||
|
implemented credential encryption before the vault existed. It uses
|
||||||
|
**PBKDF2** (Password-Based Key Derivation Function 2) with a password and
|
||||||
|
salt:
|
||||||
|
|
||||||
|
```
|
||||||
|
key = PBKDF2(password, salt, iterations=100_000, hash=SHA-256, output=32 bytes)
|
||||||
|
```
|
||||||
|
|
||||||
|
- The **password** is the secret (a user-provided string, not a BIP39 seed)
|
||||||
|
- The **salt** is 16 bytes, randomly generated per encryption, and is
|
||||||
|
load-bearing — it participates in key derivation
|
||||||
|
- **Iterations**: 100,000 for key_version=1, 200,000 for key_version=2
|
||||||
|
- The resulting key is used for AES-256-GCM encryption
|
||||||
|
|
||||||
|
This was the right design before the vault existed: without a BIP39 seed,
|
||||||
|
PBKDF2 from a password was the only option. The salt prevents rainbow-table
|
||||||
|
attacks, and the iteration count slows brute-force.
|
||||||
|
|
||||||
|
### The vault's approach
|
||||||
|
|
||||||
|
The vault derives the encryption key from the BIP39 seed via **SLIP-0010
|
||||||
|
HD derivation** at path `m/74'/2'/0'/0'`:
|
||||||
|
|
||||||
|
```
|
||||||
|
seed → SLIP-0010 derive(m/74'/2'/0'/0') → first 32 bytes → AES-256-GCM key
|
||||||
|
```
|
||||||
|
|
||||||
|
- The **seed** is the secret (64 bytes, derived from the BIP39 mnemonic)
|
||||||
|
- The **salt** is generated (32 bytes) but **not used** in key derivation —
|
||||||
|
it's stored in `EncryptedData.salt` for forward compatibility
|
||||||
|
- No PBKDF2, no iteration count, no password stretching
|
||||||
|
- The key is deterministic: the same mnemonic + path always produces the
|
||||||
|
same key
|
||||||
|
|
||||||
|
### Why HD derivation is better now
|
||||||
|
|
||||||
|
With the vault in place, HD derivation is strictly better than PBKDF2 for
|
||||||
|
credential encryption:
|
||||||
|
|
||||||
|
1. **No password to manage.** The BIP39 mnemonic is already the root of
|
||||||
|
trust. PBKDF2 requires a separate password — another secret to manage,
|
||||||
|
lose, or have stolen. HD derivation uses the seed that already exists.
|
||||||
|
|
||||||
|
2. **Deterministic and reproducible.** The same mnemonic always produces the
|
||||||
|
same encryption key at the same path. A backup node derives the same key.
|
||||||
|
PBKDF2 with a different password produces a different key — there's no
|
||||||
|
way to reproduce the key without the exact password.
|
||||||
|
|
||||||
|
3. **No iteration overhead.** PBKDF2 with 100k iterations is intentionally
|
||||||
|
slow (that's the point — it slows brute-force). HD derivation is a few
|
||||||
|
HMAC operations — effectively instant. This matters when encrypting or
|
||||||
|
decrypting multiple credentials at startup.
|
||||||
|
|
||||||
|
4. **Domain separation via paths.** Different encryption purposes can use
|
||||||
|
different derivation paths (`m/74'/2'/0'/0'` for v1, `m/74'/2'/1'/0'`
|
||||||
|
for a future v2). PBKDF2 has no equivalent — the only versioning knob is
|
||||||
|
the iteration count or the password.
|
||||||
|
|
||||||
|
5. **The salt becomes unnecessary for key derivation.** HD derivation
|
||||||
|
doesn't need a salt — the path provides domain separation. The salt
|
||||||
|
field in `EncryptedData` is kept for wire-format compatibility but does
|
||||||
|
not participate in key derivation.
|
||||||
|
|
||||||
|
### The compatibility problem
|
||||||
|
|
||||||
|
The `EncryptedData` wire format is the same across both implementations
|
||||||
|
(`keyVersion`, `salt`, `iv`, `data` — all base64-encoded strings). But the
|
||||||
|
key derivation is different:
|
||||||
|
|
||||||
|
- **TS v1**: PBKDF2(password, salt, 100k iterations) → key
|
||||||
|
- **Rust v1**: SLIP-0010(seed, `m/74'/2'/0'/0'`) → key
|
||||||
|
|
||||||
|
Data encrypted by the TS implementation **cannot be decrypted by the vault**
|
||||||
|
— the keys are different even if the password equals the mnemonic. This is a
|
||||||
|
hard incompatibility at the crypto layer, not a format issue.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### 1. HD derivation is the vault's encryption key derivation method
|
||||||
|
|
||||||
|
The vault uses SLIP-0010 HD derivation from the BIP39 seed at path
|
||||||
|
`m/74'/2'/0'/0'` (`PATHS::ENCRYPTION`) to produce the AES-256-GCM
|
||||||
|
encryption key. No PBKDF2. No password-based key derivation. The seed is
|
||||||
|
the sole secret input.
|
||||||
|
|
||||||
|
### 2. The salt field is unused in vault-encrypted data
|
||||||
|
|
||||||
|
The `EncryptedData.salt` field exists in the wire format for compatibility
|
||||||
|
with the TS `EncryptedDataSchema`, but the vault does not use it for key
|
||||||
|
derivation. The vault generates a random salt and stores it (for wire-format
|
||||||
|
consistency), but it plays no cryptographic role. If a future KDF-based
|
||||||
|
derivation is needed (see "Future KDF" below), the field is already present.
|
||||||
|
|
||||||
|
### 3. key_version semantics
|
||||||
|
|
||||||
|
| Version | Key derivation | Used by | Decryptable by vault? |
|
||||||
|
|---------|---------------|---------|----------------------|
|
||||||
|
| 1 | PBKDF2 (password + salt + 100k iterations) | TS `@alkdev/storage` | No — different key |
|
||||||
|
| 2 | SLIP-0010 HD derivation (seed → `m/74'/2'/0'/0'`) | Rust vault | Yes |
|
||||||
|
|
||||||
|
The vault stamps `key_version: 2` on new encryptions. `CURRENT_KEY_VERSION`
|
||||||
|
is `2`.
|
||||||
|
|
||||||
|
**The current source uses `CURRENT_KEY_VERSION = 1` with HD derivation.**
|
||||||
|
This is a drift from the spec — the source's v1 is HD-derived, but the TS
|
||||||
|
v1 is PBKDF2-derived. Same version number, different derivation. The source
|
||||||
|
must be updated to use `key_version: 2` for HD-derived data, reserving v1
|
||||||
|
for the TS PBKDF2 legacy.
|
||||||
|
|
||||||
|
### 4. Migration path: TS → vault
|
||||||
|
|
||||||
|
TS-encrypted credentials (PBKDF2, key_version=1) are migrated to vault-
|
||||||
|
encrypted credentials (HD derivation, key_version=2) through a one-time
|
||||||
|
re-encryption:
|
||||||
|
|
||||||
|
1. Decrypt the TS-encrypted data with the original password and PBKDF2
|
||||||
|
(using the TS `@alkdev/storage` `decrypt()` function or a migration
|
||||||
|
tool that implements PBKDF2)
|
||||||
|
2. Re-encrypt the plaintext with the vault at `key_version: 2`
|
||||||
|
3. Replace the old `EncryptedData` blob in storage
|
||||||
|
|
||||||
|
The vault does **not** implement PBKDF2. The migration is performed by a
|
||||||
|
separate tool or script that has access to both the TS `decrypt()` function
|
||||||
|
and the vault's `encrypt()`. This is a one-time migration — once all data
|
||||||
|
is at key_version=2, PBKDF2 is no longer needed.
|
||||||
|
|
||||||
|
### 5. No PBKDF2 in the vault
|
||||||
|
|
||||||
|
The vault does not implement PBKDF2 and does not support decrypting
|
||||||
|
key_version=1 (TS PBKDF2) data. The vault's `decrypt()` method derives the
|
||||||
|
key via HD derivation and attempts decryption. If the data was encrypted
|
||||||
|
with PBKDF2 (TS), decryption fails (wrong key) — this is correct behavior,
|
||||||
|
not a bug. The migration tool handles the TS→vault transition.
|
||||||
|
|
||||||
|
### 6. Future KDF (not v2)
|
||||||
|
|
||||||
|
If a future use case requires KDF-based key derivation (e.g., stretching a
|
||||||
|
key derived from a non-seed source, or using a salt for additional domain
|
||||||
|
separation), it would be a new key_version with its own derivation method.
|
||||||
|
The `salt` field is available for this. This is additive — it doesn't
|
||||||
|
change v2 data. See OQ-22 (key rotation).
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive:**
|
||||||
|
- One secret (the BIP39 seed) is the root of trust for both derived keys
|
||||||
|
and encryption keys. No separate password to manage.
|
||||||
|
- Encryption key derivation is instant (HD derivation) vs. slow (PBKDF2
|
||||||
|
100k iterations). Startup with many credentials is fast.
|
||||||
|
- The encryption key is reproducible — a backup node with the same mnemonic
|
||||||
|
derives the same key and can decrypt the same credentials.
|
||||||
|
- Domain separation via paths — future encryption purposes can use
|
||||||
|
different paths without changing the wire format.
|
||||||
|
- Clean break from the TS approach. No PBKDF2 code in the vault. The vault
|
||||||
|
is smaller and simpler.
|
||||||
|
|
||||||
|
**Negative:**
|
||||||
|
- TS-encrypted data cannot be decrypted by the vault. Migration requires a
|
||||||
|
separate tool with access to both the TS `decrypt()` and the vault's
|
||||||
|
`encrypt()`. This is expected — the TS implementation is being replaced,
|
||||||
|
not integrated.
|
||||||
|
- The `salt` field is unused in v2. It occupies 44 bytes (base64-encoded 32
|
||||||
|
bytes) per `EncryptedData` blob for no cryptographic purpose. This is the
|
||||||
|
cost of wire-format compatibility — keeping the field means the struct
|
||||||
|
doesn't need to change if a future KDF uses it.
|
||||||
|
- `CURRENT_KEY_VERSION` must change from 1 to 2 in the source. If any
|
||||||
|
vault-encrypted data already exists at key_version=1 (with HD derivation),
|
||||||
|
it would need re-encryption at key_version=2. In practice, the vault is
|
||||||
|
pre-production, so this is a source change, not a data migration.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
1. **The TS `@alkdev/storage` encrypted data is the only legacy.** If other
|
||||||
|
systems produce PBKDF2-encrypted `EncryptedData` blobs, they need the same
|
||||||
|
migration treatment. The assumption is that `@alkdev/storage` is the only
|
||||||
|
consumer.
|
||||||
|
|
||||||
|
2. **The vault is pre-production.** No significant amount of vault-encrypted
|
||||||
|
data (HD derivation, key_version=1) exists in production. Bumping to
|
||||||
|
key_version=2 is a source change, not a data migration. If vault-encrypted
|
||||||
|
data does exist, it needs re-encryption at key_version=2 (decrypt with HD
|
||||||
|
key at v1 path, re-encrypt at v2 — same key, just version bump).
|
||||||
|
|
||||||
|
3. **The migration is one-time and one-directional.** Once data is at
|
||||||
|
key_version=2, there's no path back to PBKDF2. The TS `@alkdev/storage`
|
||||||
|
crypto module becomes legacy after migration.
|
||||||
|
|
||||||
|
4. **The `salt` field's forward compatibility is worth the 44 bytes.** If a
|
||||||
|
future KDF is never needed, the salt field is wasted space. The assumption
|
||||||
|
is that the cost is negligible (credentials are small, not bulk data) and
|
||||||
|
the flexibility is worth it.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- ADR-018: Vault as standalone crate
|
||||||
|
- ADR-019: Vault assembly-layer-only access
|
||||||
|
- [encryption.md](../crates/vault/encryption.md) — AES-256-GCM, EncryptedData
|
||||||
|
- [mnemonic-derivation.md](../crates/vault/mnemonic-derivation.md) — SLIP-0010, PATHS::ENCRYPTION
|
||||||
|
- OQ-20: Salt/KDF Phase B (resolved by this ADR)
|
||||||
|
- OQ-22: Key rotation mechanism (still open — this ADR defines v2 but not the rotation workflow)
|
||||||
|
- TypeScript predecessor: `/workspace/@alkdev/storage/src/graphs/crypto.ts`
|
||||||
|
- TypeScript secret graph: `/workspace/@alkdev/storage/src/graphs/modules/secret-graph.ts`
|
||||||
@@ -240,14 +240,14 @@ These questions are acknowledged but not active. They will be promoted to open w
|
|||||||
|
|
||||||
## Theme: alknet-vault
|
## Theme: alknet-vault
|
||||||
|
|
||||||
### OQ-20: Salt/KDF Phase B
|
### OQ-20: Salt/KDF and Encryption Key Derivation Method
|
||||||
|
|
||||||
- **Origin**: [encryption.md](crates/vault/encryption.md)
|
- **Origin**: [encryption.md](crates/vault/encryption.md)
|
||||||
- **Status**: open
|
- **Status**: resolved
|
||||||
- **Door type**: Two-way
|
- **Door type**: One-way (key derivation method), two-way (salt field usage)
|
||||||
- **Priority**: low
|
- **Priority**: high
|
||||||
- **Resolution**: The `EncryptedData.salt` field is reserved for future KDF-based key derivation. In v1, the encryption key is derived directly from the seed at path `m/74'/2'/0'/0'` without using the salt. The salt is generated and stored for forward compatibility but does not participate in key derivation. When KDF-based key derivation (HKDF or PBKDF2 using the salt) is implemented, the wire format does not need to change — the `salt` field is already present. The question of *when* to implement Phase B and *which* KDF to use is open but low-priority: v1's direct derivation is secure; the salt is a forward-compatibility hedge, not a gap. Two-way door — the salt is additive; implementing KDF usage doesn't break v1 data.
|
- **Resolution**: The vault uses SLIP-0010 HD derivation from the BIP39 seed at path `m/74'/2'/0'/0'` to produce the AES-256-GCM encryption key — not PBKDF2. The `salt` field in `EncryptedData` is unused for key derivation (kept for wire-format compatibility with the TS predecessor). The TypeScript `@alkdev/storage` crypto module used PBKDF2 with a password + salt; data encrypted by that method (key_version=1) cannot be decrypted by the vault and must be migrated via one-time re-encryption to key_version=2. See ADR-020 for the full rationale and migration path.
|
||||||
- **Cross-references**: [encryption.md](crates/vault/encryption.md)
|
- **Cross-references**: ADR-020, [encryption.md](crates/vault/encryption.md)
|
||||||
|
|
||||||
### OQ-21: Remote Vault Administration
|
### OQ-21: Remote Vault Administration
|
||||||
|
|
||||||
|
|||||||
@@ -210,6 +210,7 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
|
|||||||
| [017](decisions/017-call-protocol-client-and-adapter-contract.md) | Call Protocol Client and Adapter Contract | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction |
|
| [017](decisions/017-call-protocol-client-and-adapter-contract.md) | Call Protocol Client and Adapter Contract | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction |
|
||||||
| [018](decisions/018-vault-standalone-crate.md) | Vault as Standalone Crate | Zero alknet crate dependencies; vault defines own types and errors |
|
| [018](decisions/018-vault-standalone-crate.md) | Vault as Standalone Crate | Zero alknet crate dependencies; vault defines own types and errors |
|
||||||
| [019](decisions/019-vault-assembly-layer-only.md) | Vault Assembly-Layer-Only Access | The assembly layer (CLI binary) is the sole direct caller; handlers never hold a vault reference |
|
| [019](decisions/019-vault-assembly-layer-only.md) | Vault Assembly-Layer-Only Access | The assembly layer (CLI binary) is the sole direct caller; handlers never hold a vault reference |
|
||||||
|
| [020](decisions/020-hd-derivation-for-encryption-keys.md) | HD Derivation for Encryption Keys | SLIP-0010 derivation from seed, not PBKDF2; salt field unused in v2 |
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
@@ -221,7 +222,7 @@ Open questions are tracked in [open-questions.md](open-questions.md). Key questi
|
|||||||
- **OQ-04**: Dynamic handler registration at runtime vs static at startup (two-way door, defer to implementation)
|
- **OQ-04**: Dynamic handler registration at runtime vs static at startup (two-way door, defer to implementation)
|
||||||
- **OQ-08**: Vault integration point (resolved: CLI-embedded, assembly-layer only — see ADR-008, ADR-014, ADR-018, ADR-019)
|
- **OQ-08**: Vault integration point (resolved: CLI-embedded, assembly-layer only — see ADR-008, ADR-014, ADR-018, ADR-019)
|
||||||
- **OQ-16**: Safe vault operations for call protocol exposure (resolved: none for now — see ADR-014)
|
- **OQ-16**: Safe vault operations for call protocol exposure (resolved: none for now — see ADR-014)
|
||||||
- **OQ-20**: Salt/KDF Phase B (open: reserved field, v1 doesn't use it — see [encryption.md](crates/vault/encryption.md))
|
- **OQ-20**: Encryption key derivation (resolved: HD derivation, not PBKDF2 — see ADR-020)
|
||||||
- **OQ-21**: Remote vault administration (deferred: network unlock not supported — see ADR-019)
|
- **OQ-21**: Remote vault administration (deferred: network unlock not supported — see ADR-019)
|
||||||
- **OQ-22**: Key rotation mechanism (open: versioning in place, workflow not specced — see [encryption.md](crates/vault/encryption.md))
|
- **OQ-22**: Key rotation mechanism (open: versioning in place, workflow not specced — see [encryption.md](crates/vault/encryption.md))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user