docs(architecture): add ADR-021, resolve OQ-22 — key rotation via version-indexed paths

Key rotation uses version-indexed derivation paths: each key version maps
to a distinct SLIP-0010 path (m/74'/2'/0'/{version-2}'). v2 is at index 0
(PATHS::ENCRYPTION), v3 at index 1, etc.

Mechanism:
- encryption_path_for_version(version) constructs the path
- decrypt derives the key at the version-indicated path (not always
  PATHS::ENCRYPTION)
- rotate(blob, to_version) decrypts with old key, re-encrypts with new
- No new mnemonic needed — same seed, different path
- Partial rotation is safe — old keys remain derivable
- The vault does not self-rotate; the assembly layer iterates blobs

Source drift flagged:
- decrypt currently ignores key_version for path selection (always uses
  PATHS::ENCRYPTION) — must use version-indexed paths
- rotate method does not exist in source — must be added
- CURRENT_KEY_VERSION must bump from 1 to 2 (per ADR-020, reinforced here)

OQ-22 resolved. Only OQ-21 (remote vault admin, deferred) remains.
This commit is contained in:
2026-06-19 10:09:20 +00:00
parent 6e9414bc81
commit dc27753680
8 changed files with 332 additions and 70 deletions

View File

@@ -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 (001020) 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.
**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 (001021) 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), HD derivation for encryption keys (ADR-020), and key rotation via version-indexed paths (ADR-021). The alknet-core, alknet-call, and alknet-vault crate specs are in draft.
**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.
**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 one deferred OQ (OQ-21, remote vault administration) that does not block implementation.
## Architecture Documents
@@ -55,6 +55,7 @@ last_updated: 2026-06-19
| [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 |
| [021](decisions/021-key-rotation-via-version-indexed-paths.md) | Key Rotation via Version-Indexed Paths | Accepted |
## Open Questions
@@ -81,9 +82,7 @@ See [open-questions.md](open-questions.md) for the full tracker.
- **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-22**: Key rotation mechanism — key versioning is in place; the rotation workflow is not specced. Two-way door.
- **OQ-22**: Key rotation — version-indexed derivation paths; `rotate` method re-encrypts (ADR-021)
**Deferred (not active):**
- **OQ-09**: WASM target boundaries — design constraint, not deliverable

View File

@@ -44,6 +44,7 @@ cross the network.
| [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 |
| [021](../../decisions/021-key-rotation-via-version-indexed-paths.md) | Key Rotation via Version-Indexed Paths | Version-indexed paths; `rotate` re-encrypts |
## Relevant Open Questions
@@ -51,7 +52,7 @@ cross the network.
|----|-------|--------|-----------|
| 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 |
| OQ-22 | Key rotation mechanism | resolved (ADR-021) | Version-indexed paths; `rotate` method |
## Key Design Principles

View File

@@ -136,7 +136,7 @@ pub fn decrypt(encrypted: &EncryptedData, key: &EncryptionKey) -> Result<String,
`encrypt`:
1. Generates a random 12-byte IV (must use `OsRng` — see Security Constraints)
2. Generates a random 32-byte salt (stored, not used in v1)
2. Generates a random 32-byte salt (stored for wire-format compat, unused in key derivation)
3. Encrypts the plaintext with AES-256-GCM
4. Returns `EncryptedData { key_version, salt, iv, data }`
@@ -153,23 +153,39 @@ constraint — see below.
## Key Versioning
`CURRENT_KEY_VERSION` is `2`. Version `1` is reserved for the TypeScript
predecessor's PBKDF2-encrypted data (see ADR-020). Key versioning allows
re-encryption when the encryption key is rotated:
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):
1. Derive a new key from a new derivation path or new seed
2. Decrypt all existing `EncryptedData` with key version 2
3. Re-encrypt with key version 3
4. Update storage
```
v2: m/74'/2'/0'/0' ← PATHS::ENCRYPTION (current)
v3: m/74'/2'/0'/1'
v4: m/74'/2'/0'/2'
```
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.
`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.
**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.
### Rotation
Key rotation re-encrypts a blob from one version to another. The vault
provides a `rotate` method; the caller (assembly layer or migration tool)
handles replacing the blob in storage:
```rust
pub fn rotate(&self, encrypted: &EncryptedData, to_version: u32) -> Result<EncryptedData, VaultServiceError>;
```
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).
## Errors
@@ -201,6 +217,7 @@ implementer should not expect this variant to fire in v2.
| 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 |
| Version-indexed paths for rotation | [ADR-021](../../decisions/021-key-rotation-via-version-indexed-paths.md) | `m/74'/2'/0'/{version-2}'` |
| 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 |
@@ -210,9 +227,8 @@ See [open-questions.md](../../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** (open): Key rotation mechanism — the key versioning is in place,
but the rotation workflow (re-encrypt all data, update storage) is not
specced.
- **OQ-22** (resolved by ADR-021): Key rotation — version-indexed paths;
`rotate` method decrypts old, re-encrypts new.
## Security Constraints

View File

@@ -211,46 +211,22 @@ pub fn site_password_path(site_hash: &str) -> String; // m/74'/1'/0'/{site_hash}
| `m/74'/0'/0'/{n}'` | Worker/device identity | Ed25519 | Multi-device nodes, workers |
| `m/74'/0'/1'/0'` | SSH host key | Ed25519 | SSH handler |
| `m/74'/1'/0'/{hash}'` | Site-specific deterministic password | Ed25519 bytes | Per-site passwords (not cached) |
| `m/74'/2'/0'/0'` | Encryption key for external credentials | AES-256-GCM | Credential encryption (see [encryption.md](encryption.md)) |
| `m/74'/2'/0'/0'` | Encryption key for external credentials | AES-256-GCM | Credential encryption (v2, see [encryption.md](encryption.md)) |
| `m/44'/60'/0'/0/0` | Ethereum signing key | secp256k1 | Ethereum signing (feature-gated) |
### Path namespace discipline
The `74'` coin type is alknet's namespace. Sub-paths follow a convention:
| Account (`/X'`) | Purpose |
|-----------------|---------|
| `0'` | Identity keys (node, devices, SSH) |
| `1'` | Deterministic passwords |
| `2'` | Encryption keys (external credentials) |
New key purposes should allocate a new account index, not reuse an
existing one. This prevents cross-purpose key collisions.
### The `74'` coin type is a one-way door
The `74'` coin type is **committed** — once keys are derived at `m/74'/...`,
changing the coin type breaks every existing key. Every node's identity,
SSH host key, and encryption key is derived at a `74'`-rooted path. This is
effectively a one-way door per ADR-009: reversal requires re-deriving every
key from the seed at a new coin type and re-deploying all nodes. The
reservation is documented inline rather than in a separate ADR because it
is a single, self-evident commitment (the coin type is the alknet
namespace; there is no alternative to evaluate). The SLIP-0044 registry
lists `74'` as unallocated, so there is no collision risk with other
projects.
## Key Types
Helper functions construct parameterized paths:
```rust
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum KeyType {
Ed25519, // SLIP-0010 derivation
Aes256Gcm, // Symmetric key (derived from seed, used for encryption)
Secp256k1, // BIP-0032 derivation (Ethereum, feature-gated)
}
pub fn device_path(index: u32) -> String; // m/74'/0'/0'/{index}'
pub fn site_password_path(site_hash: &str) -> String; // m/74'/1'/0'/{site_hash}'
pub fn encryption_path_for_version(version: u32) -> String; // m/74'/2'/0'/{version-2}'
```
`encryption_path_for_version` maps a key version to its derivation path
(ADR-021). v2 (current) maps to `m/74'/2'/0'/0'` (which is `PATHS::ENCRYPTION`);
v3 maps to `m/74'/2'/0'/1'`; etc. This is the rotation mechanism — each
version gets a cryptographically independent key from the same seed.
`KeyType` tags `DerivedKey` (see [protocol.md](protocol.md)) and
`CachedKey` (see [service.md](service.md)) so consumers know what they
received without inspecting byte lengths.

View File

@@ -172,12 +172,27 @@ cryptographic details.
pub fn decrypt(&self, encrypted: &EncryptedData) -> Result<String, VaultServiceError>;
```
Decrypt an `EncryptedData` blob. Derives (and caches) the encryption key at
`PATHS::ENCRYPTION` if not already cached. The `encrypted.key_version` is
stamped onto the `EncryptionKey` for forward compatibility but **does not
select a different derivation path in v1** — the same key (at
`m/74'/2'/0'/0'`) decrypts any version. Path-per-version routing is a Phase
B concern (OQ-22). See [encryption.md](encryption.md).
Decrypt an `EncryptedData` blob. Derives (and caches) the encryption key
at the version-indexed path indicated by `encrypted.key_version` (ADR-021).
Each version maps to a distinct path (`m/74'/2'/0'/{version-2}'`), so old
and new keys can coexist during partial rotation. See
[encryption.md](encryption.md).
### rotate(encrypted, to_version) → EncryptedData
```rust
pub fn rotate(&self, encrypted: &EncryptedData, to_version: u32) -> Result<EncryptedData, VaultServiceError>;
```
Re-encrypt an `EncryptedData` blob from its current key version to a new
version. Decrypts with the old version's key, re-encrypts with the new
version's key. Returns the new `EncryptedData` — the caller replaces the
blob in storage. No new mnemonic needed; the same seed produces all
version keys via different derivation paths (ADR-021).
This is the rotation primitive. The assembly layer or a migration tool
iterates stored blobs and calls `rotate` on each. The vault does not
self-rotate — rotation is an operational action.
## Cache
@@ -303,6 +318,7 @@ error types — the CLI binary converts at the assembly boundary (ADR-018).
|----------|-----|---------|
| 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 |
| Version-indexed paths for rotation | [ADR-021](../../decisions/021-key-rotation-via-version-indexed-paths.md) | `decrypt` selects key by version; `rotate` re-encrypts |
| RwLock for thread safety | — | Multiple readers (derive), exclusive writer (unlock/lock) |
| TTL + LRU cache | — | Bounded memory, fresh keys, zeroized eviction |
| Actor for in-process irpc dispatch | [ADR-005](../../decisions/005-irpc-as-call-protocol-foundation.md) | irpc message dispatch; not on the call protocol |

View File

@@ -0,0 +1,253 @@
# ADR-021: Key Rotation via Version-Indexed Derivation Paths
## Status
Accepted
## Context
ADR-020 established that the vault derives the AES-256-GCM encryption key
from the BIP39 seed via SLIP-0010 HD derivation at path `m/74'/2'/0'/0'`.
The `EncryptedData.key_version` field exists for rotation tracking, but
the current implementation always derives at the same path regardless of
version — `key_version` is metadata, not a functional selector.
OQ-22 asked: how does key rotation work? The key versioning is in place,
but the rotation mechanism — how a new key is derived, how existing data
is re-encrypted, and how the vault selects the right key for decryption —
is not specified.
### Why rotation matters
Key rotation is a fundamental security hygiene practice. The scenarios
that require it:
1. **Suspected key compromise**: the encryption key may have leaked
(memory dump, process compromise, log accident). All data encrypted
with that key must be re-encrypted with a new key.
2. **Periodic rotation**: security policy mandates key rotation every N
months. The vault must support this without re-deriving from a new
mnemonic (which would require re-deploying all nodes).
3. **Version transition**: moving from TS PBKDF2 data (v1) to vault HD
data (v2, per ADR-020) is itself a rotation. The mechanism should
generalize — it's the same operation.
### What "rotation" means concretely
Rotating from key version N to N+1:
1. Derive a new encryption key at a new derivation path
2. For each existing `EncryptedData` blob with `key_version: N`:
- Decrypt with the v-N key
- Re-encrypt the plaintext with the v-(N+1) key
- Replace the blob in storage with `key_version: N+1`
3. New encryptions use `key_version: N+1`
4. Old keys remain available for decrypting any data that hasn't been
rotated yet (partial rotation is safe)
The question is: **how is the new key derived?** The options:
- **Option A: New derivation path per version.** `m/74'/2'/0'/0'` for v2,
`m/74'/2'/0'/1'` for v3, etc. Each version gets its own HD key. No
new seed needed.
- **Option B: New mnemonic (new seed).** Generate a new mnemonic, unlock
with it, re-encrypt everything. This is heavy — it changes *all* derived
keys (identity, SSH host, etc.), not just the encryption key.
- **Option C: KDF from the existing key.** Use HKDF or PBKDF2 with the
existing derived key + the salt as input. This is the salt field's
potential use (OQ-20 mentioned this), but it adds KDF complexity and
the salt becomes load-bearing.
## Decision
### 1. Version-indexed derivation paths
Each key version maps to a unique derivation path. The last hardened index
in the encryption path is the key version:
```
v2: m/74'/2'/0'/0' ← PATHS::ENCRYPTION (current)
v3: m/74'/2'/0'/1'
v4: m/74'/2'/0'/2'
...
```
The `encryption_path_for_version(version)` function constructs the path:
```rust
pub fn encryption_path_for_version(version: u32) -> String {
// v1 is the TS PBKDF2 legacy — not an HD path. The vault starts at v2.
// v2 → m/74'/2'/0'/0', v3 → m/74'/2'/0'/1', etc.
let index = version.saturating_sub(2);
format!("m/74'/2'/0'/{}'", index)
}
```
`PATHS::ENCRYPTION` remains `m/74'/2'/0'/0'` — it's the v2 path, and v2
is the current version. When the vault is rotated to v3,
`encryption_path_for_version(3)` produces `m/74'/2'/0'/1'`.
This means:
- No new mnemonic needed — rotation uses the same seed, different path
- Each version's key is cryptographically independent (HD derivation
ensures this)
- The derivation path is self-documenting (`m/74'/2'/0'/1'` is clearly
"encryption key, version 3")
- Old keys are always derivable (the seed doesn't change), so partial
rotation is safe — the vault can decrypt any version
### 2. `encrypt_key(version)` and `decrypt_key(version)` methods
The `VaultServiceHandle` gains version-aware key derivation:
```rust
impl VaultServiceHandle {
/// Derive the encryption key for the given version. Cached.
fn derive_encryption_key_for_version(
&self,
version: u32,
) -> Result<EncryptionKey, VaultServiceError> {
let path = encryption_path_for_version(version);
// ... derive at path, cache by path ...
}
/// Encrypt with the current key version.
pub fn encrypt(&self, plaintext: &str, key_version: u32) -> Result<EncryptedData, VaultServiceError>;
/// Decrypt by deriving the key at the version indicated by the blob.
pub fn decrypt(&self, encrypted: &EncryptedData) -> Result<String, VaultServiceError> {
let key = self.derive_encryption_key_for_version(encrypted.key_version)?;
encryption::decrypt(encrypted, &key)
}
}
```
`decrypt` now derives the key at the path **indicated by
`encrypted.key_version`** — not always at `PATHS::ENCRYPTION`. This is
the fix for the W1 drift issue from the vault review: the current source
ignores `key_version` for key selection; the spec now makes it functional.
### 3. `rotate` method
```rust
impl VaultServiceHandle {
/// Re-encrypt an EncryptedData blob from one key version to another.
///
/// Decrypts with the key at the blob's current key_version,
/// re-encrypts with the key at `to_version`. Returns the new
/// EncryptedData. Does not update storage — the caller replaces the
/// blob in storage.
pub fn rotate(
&self,
encrypted: &EncryptedData,
to_version: u32,
) -> Result<EncryptedData, VaultServiceError> {
let plaintext = self.decrypt(encrypted)?;
self.encrypt(&plaintext, to_version)
}
}
```
`rotate` is a vault method, not a storage operation. It decrypts and
re-encrypts; the caller (the assembly layer or a migration tool) handles
replacing the blob in storage. This keeps the vault focused on crypto and
the storage system focused on storage.
### 4. `CURRENT_KEY_VERSION` and rotation policy
```rust
pub const CURRENT_KEY_VERSION: u32 = 2;
```
`encrypt()` stamps `CURRENT_KEY_VERSION` (or the explicitly-passed version)
onto new `EncryptedData` blobs. The assembly layer decides when to rotate:
- **Manual rotation**: an operator triggers rotation (e.g., a CLI command
`alknet vault rotate --to v3` that loads all blobs, calls `rotate` on
each, and writes them back to storage).
- **No automatic rotation**: the vault does not self-rotate. Rotation is
an operational action, not a runtime behavior. The vault provides the
mechanism; the policy is external.
### 5. Cache implications
The `KeyCache` is keyed by derivation path. Since each version has a
distinct path, the cache naturally holds multiple versions simultaneously.
This is correct — during a rotation, the vault may need to decrypt old
blobs (v2) and encrypt new blobs (v3), and both keys should be cached.
The cache's TTL and LRU eviction still apply. If the cache evicts an old
version's key during a long rotation, the next `decrypt` of an old blob
re-derives it (the seed hasn't changed). This is correct but slightly
slower — the rotation tool should be aware that cache misses on old keys
are expected.
## Consequences
**Positive:**
- Key rotation is a vault method (`rotate`), not a storage operation or a
full mnemonic change. It's cheap (HD derivation) and local.
- Partial rotation is safe. Old and new keys coexist — the vault can
decrypt any version. This means a rotation can be performed incrementally
(rotate some blobs, verify, rotate the rest).
- No new mnemonic needed. The same seed produces all version keys. A
backup node with the same mnemonic can decrypt any version.
- The derivation path is self-documenting. `m/74'/2'/0'/1'` is clearly
"encryption key version 3."
- The `salt` field remains unused — no KDF complexity. Rotation is pure HD
path indexing.
- The mechanism generalizes the TS→vault migration (v1→v2 is a rotation,
though v1 requires the TS PBKDF2 `decrypt`, not the vault's `decrypt`).
**Negative:**
- `decrypt` now derives the key at the version-indicated path, which means
a cache miss on an old version re-derives from the seed. This is a few
HMAC operations — negligible, but the path construction and cache lookup
add a small amount of complexity over the current "always use
`PATHS::ENCRYPTION`" approach.
- The rotation tool (CLI command or migration script) must iterate all
stored blobs and call `rotate` on each. This is an operational concern,
not a vault concern — but the vault spec should document the expected
usage pattern so the tool implementer knows the contract.
- Old version keys are always derivable (the seed doesn't change). This is
a feature (partial rotation is safe) but also means a compromised seed
allows decrypting all versions. If the seed itself is compromised, all
versions are compromised — rotation doesn't help. This is inherent to
HD derivation and not specific to this design.
## Assumptions
1. **The seed is not compromised.** If the seed is compromised, rotating
the encryption key path doesn't help — the attacker can derive all
version keys. Seed compromise requires a full mnemonic change (new
seed, re-derive everything, re-deploy). This ADR covers encryption key
rotation, not seed rotation. Seed rotation is an operational procedure
(generate new mnemonic, unlock with it, re-encrypt all data) that is
outside the vault's API.
2. **Rotation is infrequent.** The vault does not optimize for frequent
rotation (e.g., per-request key derivation). Rotation is an operational
event triggered by policy or incident. The cache and path-indexed
approach are efficient for this usage pattern.
3. **The storage system tracks which blobs to rotate.** The vault's `rotate`
method handles one blob at a time. Iterating all stored
`EncryptedData` blobs is the storage system's job (or the CLI's). The
vault doesn't know what's in storage — it only knows how to rotate a
blob it's given.
4. **v1 (TS PBKDF2) data is not rotated through the vault.** v1 data is
decrypted by the TS `decrypt()` function (PBKDF2), not the vault's
`decrypt()` (which uses HD derivation). The v1→v2 migration is a
separate tool that has access to both. Once data is at v2, future
rotations (v2→v3, etc.) use the vault's `rotate` method.
## References
- ADR-020: HD derivation for encryption keys (this ADR builds on the
version-indexed path scheme)
- OQ-22: Key rotation mechanism (resolved by this ADR)
- [encryption.md](../crates/vault/encryption.md) — AES-256-GCM, EncryptedData
- [service.md](../crates/vault/service.md) — encrypt, decrypt, rotate methods
- [mnemonic-derivation.md](../crates/vault/mnemonic-derivation.md) —
derivation paths, `PATHS::ENCRYPTION`

View File

@@ -261,8 +261,8 @@ These questions are acknowledged but not active. They will be promoted to open w
### OQ-22: Key Rotation Mechanism
- **Origin**: [encryption.md](crates/vault/encryption.md)
- **Status**: open
- **Door type**: Two-way
- **Priority**: low
- **Resolution**: Key versioning is in place (`EncryptedData.key_version`, `CURRENT_KEY_VERSION = 1`), but the rotation workflow — re-encrypt all existing data with a new key version, update storage — is not specced. The mechanism is straightforward (derive a new key at a new path or from a new seed, decrypt with v1, re-encrypt with v2), but the operational workflow (when to rotate, how to track which data is at which version, how to handle partially-rotated state) needs design. Low priority: keys don't rotate frequently, and v1 is stable. Two-way door — rotation is additive; a v2 key doesn't break v1 data.
- **Cross-references**: [encryption.md](crates/vault/encryption.md)
- **Status**: resolved
- **Door type**: One-way (path scheme), two-way (rotation policy)
- **Priority**: medium
- **Resolution**: Key rotation uses version-indexed derivation paths. Each key version maps to a distinct SLIP-0010 path: `m/74'/2'/0'/{version-2}'`. v2 (current) is at `m/74'/2'/0'/0'`; v3 is at `m/74'/2'/0'/1'`; etc. The `decrypt` method derives the key at the path indicated by `encrypted.key_version` (not always at `PATHS::ENCRYPTION`). The `rotate` method decrypts with the old version's key and re-encrypts with the new version's key — no new mnemonic needed. The assembly layer or a migration tool iterates stored blobs and calls `rotate` on each; the vault does not self-rotate. Partial rotation is safe (old keys remain derivable). See ADR-021.
- **Cross-references**: ADR-020, ADR-021, [encryption.md](crates/vault/encryption.md), [service.md](crates/vault/service.md)

View File

@@ -211,6 +211,7 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
| [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 |
| [021](decisions/021-key-rotation-via-version-indexed-paths.md) | Key Rotation via Version-Indexed Paths | Version-indexed derivation paths; `rotate` re-encrypts between versions |
## Open Questions
@@ -224,7 +225,7 @@ Open questions are tracked in [open-questions.md](open-questions.md). Key questi
- **OQ-16**: Safe vault operations for call protocol exposure (resolved: none for now — see ADR-014)
- **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))
- **OQ-22**: Key rotation (resolved: version-indexed paths, `rotate` method — see ADR-021)
## Failure Modes