Files
alknet/docs/architecture/decisions/020-hd-derivation-for-encryption-keys.md
glm-5.2 cb98f42cd4 docs(architecture): resolve review #002 remaining Tier 4 findings
Add ADR-026 (vault key model — HD derivation) recording the foundational
HD-derivation decision, 74' coin type reservation, SLIP-0010/Ed25519
default, secp256k1 feature-gating, and AES-256-GCM cipher choice. These
were previously inline rationale with no ADR (W9).

Extend ADR-018 with an explicit EncryptedData wire format lock — fields,
encoding, and semantics are frozen; no removal without a format-version
migration (W10).

Resolve the remaining guard clauses and spec decisions:

- W2: Capabilities must be immutable after construction (no interior
  mutability). Makes the Arc vs deep-copy clone semantics genuinely
  two-way.
- W5: Published to_* specs are compatibility contracts — best-effort
  mappings are two-way before first publication, one-way after. Version
  generated specs.
- W6: Salt field clarification — v2 salt is permanently unused; a future
  KDF is a different derivation family, not a version-indexed path; the
  field saves a wire-format change only.
- W7: unlock_new returns Zeroizing<String> — the mnemonic is the root of
  trust and must not linger in freed memory.
- W17: OQ-09 WASM — server-side dispatch door is honestly closed
  (Connection is concrete, tokio-bound), not implicitly preserved.
- W18: OQ-10 git — composability fork (raw smart protocol vs call-protocol
  projection) is a separate decision from ERC721 scope.
- W20: from_openapi must prefix imported error codes (HTTP_404) to avoid
  collision with protocol-level codes (NOT_FOUND). Normative rule, not
  naming convention.
- W21: ScopedOperationEnv field is private — construction via new()/
  empty(), query via allows(). Makes the future subgraph refactor
  non-breaking.
- C13: Connection::set_identity — the endpoint does not read identity()
  after handle() returns (Connection is moved into the spawned task).
  Observability is handler-side logging. Simplest honest answer.
- W1: OperationAdapter trait is async, returns Vec<HandlerRegistration>.
  from_call requires async discovery; ADR-022 changed the return type.
- W11: CompositionAuthority::as_identity() defined — constructs a
  synthetic Identity (label as id, scopes, resources) not resolvable via
  IdentityProvider. Second Identity construction path, acknowledged.
- W14: SecretKey is iroh::SecretKey (Ed25519) — consistent with the
  endpoint's iroh dependency.
- W19: Grandchild abort propagation is inherit-by-default (option a) —
  invoke() with no explicit policy inherits parent's policy. ContinueRunning
  auto-propagates to grandchildren unless explicitly overridden.
2026-06-23 08:20:27 +00:00

230 lines
10 KiB
Markdown

# 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 v2, `m/74'/2'/0'/1'`
for a future v3). PBKDF2 has no equivalent — the only versioning knob is
the iteration count or the password. See ADR-021 for the version-indexed
path scheme.
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.
**Clarification (review #002 W6)**: the salt field is reserved for *future
versions'* use. v2 data's salt is permanently unused — it was random, never
participated in key derivation, and cannot be retroactively made
load-bearing for v2 data. Introducing a KDF in v3 is a new derivation
method (not a version-indexed path), requiring its own design and a v2→v3
migration (re-encrypt with the new KDF, using a newly-generated v3 salt —
the v2 salt is not reused). The field's presence saves a wire-format struct
change only (ADR-018 locks the wire format); it does not make the KDF
design or migration trivial. A KDF doesn't fit the rotation scheme
(version-indexed paths, ADR-021) — it's a different derivation *family*,
not another version index. See OQ-22 (key rotation) and ADR-018
(`EncryptedData` wire format lock).
## 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`