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:
@@ -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–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.
|
||||
**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–021) 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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`
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user