diff --git a/crates/alknet-vault/src/derivation.rs b/crates/alknet-vault/src/derivation.rs index dcbd73e..dae50ca 100644 --- a/crates/alknet-vault/src/derivation.rs +++ b/crates/alknet-vault/src/derivation.rs @@ -59,6 +59,22 @@ pub fn site_password_path(site_hash: &str) -> String { format!("m/74'/1'/0'/{}'", site_hash) } +/// Construct the version-indexed encryption key derivation path (ADR-021). +/// +/// Maps a key version to its derivation path: v2 → `m/74'/2'/0'/0'` +/// (which is `PATHS::ENCRYPTION`), v3 → `m/74'/2'/0'/1'`, etc. Returns +/// `DerivationError::InvalidPath` for `version < 2` — v1 is reserved for +/// the TypeScript PBKDF2 legacy (ADR-020), which the vault cannot derive, +/// and v0 is meaningless. +pub fn encryption_path_for_version(version: u32) -> Result { + if version < 2 { + return Err(DerivationError::InvalidPath(format!( + "key version {version} has no derivable path (v1 is TS PBKDF2 legacy)" + ))); + } + Ok(format!("m/74'/2'/0'/{}'", version - 2)) +} + /// A derived extended private key with its public key. /// /// Contains the private key bytes and public key bytes from @@ -253,6 +269,37 @@ mod tests { assert_eq!(site_password_path("abc123"), "m/74'/1'/0'/abc123'"); } + #[test] + fn test_encryption_path_for_version_v2() { + assert_eq!(encryption_path_for_version(2).unwrap(), PATHS::ENCRYPTION); + } + + #[test] + fn test_encryption_path_for_version_v3() { + assert_eq!(encryption_path_for_version(3).unwrap(), "m/74'/2'/0'/1'"); + } + + #[test] + fn test_encryption_path_for_version_v4() { + assert_eq!(encryption_path_for_version(4).unwrap(), "m/74'/2'/0'/2'"); + } + + #[test] + fn test_encryption_path_for_version_rejects_v1() { + assert!(matches!( + encryption_path_for_version(1), + Err(DerivationError::InvalidPath(_)) + )); + } + + #[test] + fn test_encryption_path_for_version_rejects_v0() { + assert!(matches!( + encryption_path_for_version(0), + Err(DerivationError::InvalidPath(_)) + )); + } + #[test] fn test_derive_master_key_from_seed() { // Use a known 64-byte seed diff --git a/crates/alknet-vault/src/encryption.rs b/crates/alknet-vault/src/encryption.rs index 779e0ab..dd83f34 100644 --- a/crates/alknet-vault/src/encryption.rs +++ b/crates/alknet-vault/src/encryption.rs @@ -7,10 +7,10 @@ //! # Salt Field (Reserved for Future KDF-Based Key Derivation) //! //! The `salt` field in `EncryptedData` is **reserved for future KDF-based key -//! derivation** (Phase B). In v1, the encryption key is derived directly from the +//! derivation** (Phase B). In v2, 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. +//! compatibility, but it plays no role in the v2 key derivation process. //! //! 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 @@ -27,11 +27,14 @@ //! # Key Versioning //! //! Key versioning allows re-encryption when the encryption key is rotated. The -//! current key version is `1`. To rotate: -//! 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 -//! 4. Update storage +//! current key version is `2` (HD-derived at `m/74'/2'/0'/0'`). Version `1` is +//! reserved for the TypeScript predecessor's PBKDF2-encrypted data, which the +//! vault cannot decrypt (different key derivation) — migration is a one-time +//! re-encryption. Each version maps to a unique derivation path +//! (`m/74'/2'/0'/{version-2}'`, see ADR-021). To rotate: +//! 1. Decrypt all existing `EncryptedData` with the old key version +//! 2. Re-encrypt with the new key version (via `VaultServiceHandle::rotate`) +//! 3. Update storage use aes_gcm::{ aead::{Aead, KeyInit}, @@ -42,7 +45,11 @@ use serde::{Deserialize, Serialize}; use zeroize::Zeroize; /// Current default key version for encryption. -pub const CURRENT_KEY_VERSION: u32 = 1; +/// +/// Version `2` is HD-derived at `m/74'/2'/0'/0'` (`PATHS::ENCRYPTION`) per +/// ADR-020. Version `1` is reserved for the TypeScript predecessor's +/// PBKDF2-encrypted data, which the vault cannot decrypt. +pub const CURRENT_KEY_VERSION: u32 = 2; /// Encrypted data blob stored in the metagraph. /// @@ -62,7 +69,7 @@ pub struct EncryptedData { pub key_version: u32, /// Base64-encoded random salt. /// - /// **Reserved for future KDF-based key derivation (Phase B).** In v1, the + /// **Reserved for future KDF-based key derivation (Phase B).** In v2, 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. diff --git a/crates/alknet-vault/src/service.rs b/crates/alknet-vault/src/service.rs index d365d41..7e61524 100644 --- a/crates/alknet-vault/src/service.rs +++ b/crates/alknet-vault/src/service.rs @@ -46,7 +46,7 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine; use crate::cache::{CacheConfig, CachedKey, KeyCache}; -use crate::derivation::{self, DerivationError, PATHS}; +use crate::derivation::{self, DerivationError}; use crate::encryption::{self, EncryptedData, EncryptionKey}; use crate::mnemonic::{Language, Mnemonic, Seed}; use crate::protocol::{DerivedKey, KeyType}; @@ -240,6 +240,23 @@ impl VaultServiceHandle { }) } + /// Derive the encryption key for a specific key version (ADR-021). + /// + /// Maps `version` to its derivation path via + /// `derivation::encryption_path_for_version` (v2 → `m/74'/2'/0'/0'`, + /// v3 → `m/74'/2'/0'/1'`, etc.) and derives the key. Cached by path + /// (same cache as `derive_encryption_key`). Returns + /// `VaultServiceError::InvalidPath` for `version < 2` (v1 is the TS + /// PBKDF2 legacy, which the vault cannot derive; v0 is meaningless). + pub fn derive_encryption_key_for_version( + &self, + version: u32, + ) -> Result { + let path = derivation::encryption_path_for_version(version) + .map_err(|e| VaultServiceError::InvalidPath(e.to_string()))?; + self.derive_encryption_key(&path) + } + /// Derive a secp256k1 (Ethereum) keypair at the given path. /// /// Uses BIP-0032 derivation (HMAC-SHA512 with "Bitcoin seed") when the @@ -306,59 +323,49 @@ impl VaultServiceHandle { Ok(URL_SAFE_NO_PAD.encode(&bytes)) } - /// Encrypt plaintext using the derived encryption key. + /// Encrypt plaintext using the encryption key derived for `key_version`. /// - /// Uses the key at path `m/74'/2'/0'/0'` (PATHS::ENCRYPTION) by default. + /// Derives the key at `encryption_path_for_version(key_version)` (ADR-021) + /// and stamps the same `key_version` on the resulting `EncryptedData`. + /// Returns `VaultServiceError::InvalidPath` for `version < 2`. pub fn encrypt( &self, plaintext: &str, key_version: u32, ) -> Result { - let mut inner = self.inner.write().unwrap(); - if !inner.unlocked { - return Err(VaultServiceError::VaultLocked); - } - - let private_key = if let Some(cached) = inner.cache.get(PATHS::ENCRYPTION) { - cached.private_key.clone() - } else { - let seed = inner.seed.as_ref().ok_or(VaultServiceError::VaultLocked)?; - let derived = derivation::derive_path_from_seed(seed.as_bytes(), PATHS::ENCRYPTION)?; - let pk = derived.private_key().to_vec(); - let pubk = derived.public_key().to_vec(); - let cached = CachedKey::new(KeyType::Aes256Gcm, pk.clone(), pubk); - inner.cache.insert(PATHS::ENCRYPTION, cached); - pk - }; - - let enc_key = EncryptionKey::from_derived_bytes(&private_key, key_version); - + let derived = self.derive_encryption_key_for_version(key_version)?; + let enc_key = EncryptionKey::from_derived_bytes(&derived.private_key, key_version); encryption::encrypt(plaintext, &enc_key).map_err(|e| e.into()) } - /// Decrypt an EncryptedData blob using the derived encryption key. + /// Decrypt an `EncryptedData` blob using the key for its `key_version`. + /// + /// Derives the key at `encryption_path_for_version(encrypted.key_version)` + /// (ADR-021). Each version maps to a distinct derivation path, so old and + /// new keys can coexist during partial rotation. pub fn decrypt(&self, encrypted: &EncryptedData) -> Result { - let mut inner = self.inner.write().unwrap(); - if !inner.unlocked { - return Err(VaultServiceError::VaultLocked); - } - - let private_key = if let Some(cached) = inner.cache.get(PATHS::ENCRYPTION) { - cached.private_key.clone() - } else { - let seed = inner.seed.as_ref().ok_or(VaultServiceError::VaultLocked)?; - let derived = derivation::derive_path_from_seed(seed.as_bytes(), PATHS::ENCRYPTION)?; - let pk = derived.private_key().to_vec(); - let pubk = derived.public_key().to_vec(); - let cached = CachedKey::new(KeyType::Aes256Gcm, pk.clone(), pubk); - inner.cache.insert(PATHS::ENCRYPTION, cached); - pk - }; - - let enc_key = EncryptionKey::from_derived_bytes(&private_key, encrypted.key_version); - + let derived = self.derive_encryption_key_for_version(encrypted.key_version)?; + let enc_key = + EncryptionKey::from_derived_bytes(&derived.private_key, encrypted.key_version); encryption::decrypt(encrypted, &enc_key).map_err(|e| e.into()) } + + /// Re-encrypt an `EncryptedData` blob from its current version to + /// `to_version` (ADR-021). + /// + /// Decrypts with the old version's key (`encrypted.key_version`) and + /// re-encrypts with the new version's key (`to_version`). Returns the new + /// `EncryptedData` with `key_version = to_version` — the caller replaces + /// the blob in storage. No new mnemonic is needed; the same seed produces + /// all version keys via different derivation paths. + pub fn rotate( + &self, + encrypted: &EncryptedData, + to_version: u32, + ) -> Result { + let plaintext = self.decrypt(encrypted)?; + self.encrypt(&plaintext, to_version) + } } impl Default for VaultServiceHandle { @@ -370,6 +377,7 @@ impl Default for VaultServiceHandle { #[cfg(test)] mod tests { use super::*; + use crate::derivation::PATHS; #[test] fn test_service_starts_locked() { @@ -455,7 +463,7 @@ mod tests { service.unlock_new(24).unwrap(); let plaintext = "my-api-key-12345"; - let encrypted = service.encrypt(plaintext, 1).unwrap(); + let encrypted = service.encrypt(plaintext, 2).unwrap(); let decrypted = service.decrypt(&encrypted).unwrap(); assert_eq!(decrypted, plaintext); @@ -649,7 +657,7 @@ mod tests { service.unlock_new(24).unwrap(); let plaintext = "cached-encryption-test"; - let encrypted = service.encrypt(plaintext, 1).unwrap(); + let encrypted = service.encrypt(plaintext, 2).unwrap(); assert_eq!(service.inner.read().unwrap().cache.len(), 1); let decrypted = service.decrypt(&encrypted).unwrap(); @@ -658,6 +666,99 @@ mod tests { assert_eq!(service.inner.read().unwrap().cache.len(), 1); } + #[test] + fn test_encrypt_v2_round_trip() { + let service = VaultServiceHandle::new(); + service.unlock_new(24).unwrap(); + + let plaintext = "v2 round trip secret"; + let encrypted = service.encrypt(plaintext, 2).unwrap(); + assert_eq!(encrypted.key_version, 2); + + let decrypted = service.decrypt(&encrypted).unwrap(); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn test_rotate_v2_to_v3_round_trip() { + let service = VaultServiceHandle::new(); + service.unlock_new(24).unwrap(); + + let plaintext = "rotated secret"; + let encrypted_v2 = service.encrypt(plaintext, 2).unwrap(); + assert_eq!(encrypted_v2.key_version, 2); + + let encrypted_v3 = service.rotate(&encrypted_v2, 3).unwrap(); + assert_eq!(encrypted_v3.key_version, 3); + assert_ne!(encrypted_v3.data, encrypted_v2.data); + + let decrypted_v3 = service.decrypt(&encrypted_v3).unwrap(); + assert_eq!(decrypted_v3, plaintext); + } + + #[test] + fn test_rotate_old_key_still_derivable_after_rotation() { + let service = VaultServiceHandle::new(); + service.unlock_new(24).unwrap(); + + let plaintext = "partial rotation safe"; + let encrypted_v2 = service.encrypt(plaintext, 2).unwrap(); + + let _encrypted_v3 = service.rotate(&encrypted_v2, 3).unwrap(); + + let decrypted_v2 = service.decrypt(&encrypted_v2).unwrap(); + assert_eq!(decrypted_v2, plaintext); + } + + #[test] + fn test_derive_encryption_key_for_version_rejects_v1() { + let service = VaultServiceHandle::new(); + service.unlock_new(24).unwrap(); + + let result = service.derive_encryption_key_for_version(1); + assert!(matches!(result, Err(VaultServiceError::InvalidPath(_)))); + } + + #[test] + fn test_derive_encryption_key_for_version_rejects_v0() { + let service = VaultServiceHandle::new(); + service.unlock_new(24).unwrap(); + + let result = service.derive_encryption_key_for_version(0); + assert!(matches!(result, Err(VaultServiceError::InvalidPath(_)))); + } + + #[test] + fn test_encrypt_rejects_v1() { + let service = VaultServiceHandle::new(); + service.unlock_new(24).unwrap(); + + let result = service.encrypt("secret", 1); + assert!(matches!(result, Err(VaultServiceError::InvalidPath(_)))); + } + + #[test] + fn test_derive_encryption_key_for_version_v2_matches_path() { + let service = VaultServiceHandle::new(); + service.unlock_new(24).unwrap(); + + let by_version = service.derive_encryption_key_for_version(2).unwrap(); + let by_path = service + .derive_encryption_key(crate::derivation::PATHS::ENCRYPTION) + .unwrap(); + assert_eq!(by_version.private_key, by_path.private_key); + } + + #[test] + fn test_derive_encryption_key_for_version_v3_distinct_from_v2() { + let service = VaultServiceHandle::new(); + service.unlock_new(24).unwrap(); + + let v2 = service.derive_encryption_key_for_version(2).unwrap(); + let v3 = service.derive_encryption_key_for_version(3).unwrap(); + assert_ne!(v2.private_key, v3.private_key); + } + #[test] fn test_unlock_with_passphrase_produces_different_seed() { let service_a = VaultServiceHandle::new(); diff --git a/crates/alknet-vault/tests/service_tests.rs b/crates/alknet-vault/tests/service_tests.rs index 62ebc5f..9020fa7 100644 --- a/crates/alknet-vault/tests/service_tests.rs +++ b/crates/alknet-vault/tests/service_tests.rs @@ -76,7 +76,7 @@ fn test_encrypt_decrypt_lifecycle() { service.unlock_new(24).unwrap(); let plaintext = "my-api-key-12345"; - let encrypted = service.encrypt(plaintext, 1).unwrap(); + let encrypted = service.encrypt(plaintext, 2).unwrap(); let decrypted = service.decrypt(&encrypted).unwrap(); assert_eq!(decrypted, plaintext);