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

@@ -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 |