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
|
||||
|
||||
**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
|
||||
|
||||
@@ -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 |
|
||||
| [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 |
|
||||
| [020](decisions/020-hd-derivation-for-encryption-keys.md) | HD Derivation for Encryption Keys | Accepted |
|
||||
|
||||
## 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-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-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):**
|
||||
- **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.
|
||||
|
||||
**Deferred (not active):**
|
||||
|
||||
@@ -29,7 +29,7 @@ cross the network.
|
||||
| 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) |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [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
|
||||
|
||||
| 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-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
|
||||
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 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)]
|
||||
pub struct EncryptedData {
|
||||
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 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
|
||||
(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
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -131,18 +152,25 @@ constraint — see below.
|
||||
|
||||
## Key Versioning
|
||||
|
||||
`CURRENT_KEY_VERSION` is `1`. Key versioning allows re-encryption when the
|
||||
encryption key is rotated:
|
||||
`CURRENT_KEY_VERSION` is `2`. Version `1` is reserved for the TypeScript
|
||||
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
|
||||
2. Decrypt all existing `EncryptedData` with key version 1
|
||||
3. Re-encrypt with key version 2
|
||||
2. Decrypt all existing `EncryptedData` with key version 2
|
||||
3. Re-encrypt with key version 3
|
||||
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.
|
||||
|
||||
**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
|
||||
|
||||
```rust
|
||||
@@ -150,7 +178,7 @@ 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
|
||||
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
|
||||
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.
|
||||
`KeyVersionMismatch` is **defined but unused in v2** — neither `encrypt()`
|
||||
nor `decrypt()` returns it. It is reserved for future key rotation
|
||||
enforcement (OQ-22), where the vault may enforce version matching before
|
||||
decrypting. In v2, 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 v2.
|
||||
|
||||
## 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 |
|
||||
| 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 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 |
|
||||
|
||||
## 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-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** (open): Key rotation mechanism — the key versioning is in place,
|
||||
but the rotation workflow (re-encrypt all data, update storage) is not
|
||||
specced.
|
||||
|
||||
@@ -283,8 +283,9 @@ assembly-layer concern.
|
||||
|
||||
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).
|
||||
- **OQ-20** (resolved by ADR-020): Encryption key derivation — HD derivation
|
||||
from seed, not PBKDF2. The salt field is unused in v2. See
|
||||
[encryption.md](encryption.md).
|
||||
|
||||
## References
|
||||
|
||||
|
||||
@@ -302,9 +302,10 @@ error types — the CLI binary converts at the assembly boundary (ADR-018).
|
||||
| Decision | ADR | Summary |
|
||||
|----------|-----|---------|
|
||||
| Assembly layer is the sole caller | [ADR-019](../../decisions/019-vault-assembly-layer-only.md) | Handlers never hold a vault reference |
|
||||
| Encryption key via HD derivation | [ADR-020](../../decisions/020-hd-derivation-for-encryption-keys.md) | Seed-derived key at `m/74'/2'/0'/0'`, not PBKDF2 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
## 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
|
||||
|
||||
### OQ-20: Salt/KDF Phase B
|
||||
### OQ-20: Salt/KDF and Encryption Key Derivation Method
|
||||
|
||||
- **Origin**: [encryption.md](crates/vault/encryption.md)
|
||||
- **Status**: open
|
||||
- **Door type**: Two-way
|
||||
- **Priority**: low
|
||||
- **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.
|
||||
- **Cross-references**: [encryption.md](crates/vault/encryption.md)
|
||||
- **Status**: resolved
|
||||
- **Door type**: One-way (key derivation method), two-way (salt field usage)
|
||||
- **Priority**: high
|
||||
- **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**: ADR-020, [encryption.md](crates/vault/encryption.md)
|
||||
|
||||
### 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 |
|
||||
| [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 |
|
||||
| [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
|
||||
|
||||
@@ -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-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-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-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