From 916ed91b79cd11edcbd9cf26fc021ec49eeb2e0e Mon Sep 17 00:00:00 2001 From: "glm-5.1" Date: Wed, 10 Jun 2026 06:08:15 +0000 Subject: [PATCH] docs: close 7 spec gaps in secret-service.md Address implementation-identified gaps: - Add irpc integration model (SecretServiceHandle vs Client, dispatch paths) - Add Key Caching subsection (derivation path as cache key, 1-hour TTL, LRU, cleared on Lock) - Specify DerivedKey.private_key must derive Zeroize per ADR-038 - Add Password Derivation subsection (HMAC-SHA512, Base64url encoding) - Add secp256k1 derivation note (BIP-0032 algorithm, feature flag) - Document EncryptedData.salt as reserved for future KDF-based key rotation - Add Test Vectors section (BIP39, SLIP-0010, AES-256-GCM known-answer) - Mark OQ-SVC-04 as resolved - Update dependencies (secp256k1 feature-gated, future KDF deps) - Update crate structure diagram (add cache.rs, vectors_tests.rs) --- docs/architecture/secret-service.md | 253 +++++++++++++++++++++++++--- 1 file changed, 233 insertions(+), 20 deletions(-) diff --git a/docs/architecture/secret-service.md b/docs/architecture/secret-service.md index 4fd80c8..6bca743 100644 --- a/docs/architecture/secret-service.md +++ b/docs/architecture/secret-service.md @@ -1,6 +1,6 @@ --- status: reviewed -last_updated: 2026-06-09 +last_updated: 2026-06-10 --- # Secret Service (alknet-secret) @@ -39,11 +39,13 @@ alknet-secret/ │ ├── derivation.rs # SLIP-0010: HD key derivation, path constants │ ├── encryption.rs # AES-256-GCM: encrypt/decrypt, EncryptedData type │ ├── protocol.rs # SecretProtocol irpc service enum, DerivedKey, KeyType -│ └── service.rs # SecretServiceImpl: in-memory seed, Unlock/Lock lifecycle +│ ├── service.rs # SecretServiceImpl: in-memory seed, Unlock/Lock lifecycle +│ └── cache.rs # Key caching: LRU cache with TTL, derivation path as key └── tests/ ├── derivation_tests.rs # Path derivation, coin type 74' consistency ├── encryption_tests.rs # Round-trip encrypt/decrypt, key version - └── service_tests.rs # Unlock/Lock lifecycle, derive on locked = error + ├── service_tests.rs # Unlock/Lock lifecycle, derive on locked = error + └── vectors_tests.rs # Known-answer tests: BIP39, SLIP-0010, AES-256-GCM ``` ### Dependencies @@ -53,18 +55,39 @@ alknet-secret/ bip39 = "2" ed25519-bip32 = "0.x" # IOHK SLIP-0010 Ed25519 HD derivation aes-gcm = "0.10" # AES-256-GCM -sha2 = "0.10" # SHA-256 +sha2 = "0.10" # SHA-256 (also used for HMAC-SHA512 in password derivation) serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" irpc = "0.x" # Always-on, not feature-gated (ADR-027) zeroize = { version = "1", features = ["derive"] } # Secure memory wiping (ADR-038) +base64 = "0.22" # Base64url encoding for derived passwords + +[dependencies.libsecp256k1] +version = "0.7" +optional = true # BIP-0032 secp256k1 derivation (behind feature flag) + +[features] +default = [] +secp256k1 = ["libsecp256k1"] # Enable Ethereum/secp256k1 key derivation + +# Future (Phase B): key rotation via KDF +# hkdf = "0.12" # HKDF for salt-based key stretching (deferred) +# pbkdf2 = "0.12" # PBKDF2 for password-based key derivation (deferred) ``` irpc is always a dependency (not behind a feature flag). Per ADR-027, irpc in alknet-secret and alknet-storage is not feature-gated because these crates are used in production deployments where the service layer is always active. +The `libsecp256k1` crate is feature-gated behind `secp256k1` because +Ethereum/BIP-0032 derivation is not needed in minimal deployments. Only +deployments that require `DeriveEthereumKey` should enable this feature. + +The `hkdf` and `pbkdf2` crates are deferred to Phase B. They will be needed for +salt-based key stretching when key rotation is implemented (see +[EncryptedData.salt](#aes-256-gcm-encryption-for-external-credentials)). + ### Crate Interface (Public API) The crate exposes these types as its stable public interface: @@ -76,6 +99,10 @@ pub use derivation::{ExtendedPrivKey, DerivationPath, PATHS}; pub use encryption::{EncryptedData, EncryptionError}; pub use protocol::{SecretProtocol, DerivedKey, KeyType, SecretMessage}; pub use service::{SecretService, SecretServiceHandle, SecretServiceError}; + +// secp256k1 types (behind feature flag) +#[cfg(feature = "secp256k1")] +pub use derivation::Secp256k1ExtendedPrivKey; ``` Other crates consume this interface: @@ -101,6 +128,26 @@ RAM, and never written to disk. `Lock` calls `zeroize()` on the seed and all cached derived keys. The `SecretService` uses `Zeroize`-derived types for all sensitive material. +#### Key Caching + +Per OQ-SVC-04 (resolved), derived keys are cached in RAM with the following +properties: + +- **Cache key**: The derivation path string (e.g., `m/74'/0'/0'/0'`). This + uniquely identifies a derived key — the same path always produces the same + key from the same seed. +- **TTL**: 1 hour (configurable). Cached entries expire after the TTL elapses, + forcing re-derivation from the seed on next access. +- **Eviction policy**: LRU (least recently used). When the cache exceeds its + maximum size, the least recently accessed entry is evicted. +- **Clearing**: The entire cache is cleared on `Lock`, and all entries are + zeroized before removal per ADR-038. +- **Implementation**: The cache lives in `cache.rs` as an LRU map from + derivation path to `Zeroize`-protected key bytes. + +The cache avoids redundant derivation for frequently used keys (identity, +encryption) while ensuring that `Lock` purges all sensitive material. + ### Key Derivation #### BIP39 Mnemonic and Seed Derivation @@ -122,12 +169,69 @@ The `74'` coin type is unallocated per SLIP-0044 and reserved for alknet. | `m/74'/0'/0'/0'` | Primary identity keypair | Ed25519 (alknet auth) | | `m/74'/0'/0'/{n}'` | Worker/device identity | Ed25519 | | `m/74'/0'/1'/0'` | SSH host key | Ed25519 | -| `m/74'/1'/0'/{hash}'` | Site-specific password | Deterministic | +| `m/74'/1'/0'/{hash}'` | Site-specific password | Deterministic (HMAC-SHA512) | | `m/74'/2'/0'/0'` | Encryption key for external credentials | AES-256-GCM | | `m/44'/60'/0'/0/0` | Ethereum signing key | secp256k1 | These constants are defined in `derivation::PATHS` for programmatic access. +#### Password Derivation + +`DerivePassword` produces a deterministic password from the seed using the +following algorithm: + +1. Derive the extended private key at path `m/74'/1'/0'/{hash}'` using + SLIP-0010 (HMAC-SHA512 with key "ed25519 seed"), where `{hash}'` is a + site-specific hardened index derived from the site identifier. +2. Take the HMAC-SHA512 output (64 bytes) at that derivation level. +3. Truncate to the requested `length` bytes. +4. Encode as Base64url (RFC 4648 §5, no padding). + +This produces a URL-safe, deterministic password of the requested length. v1 +does not impose a special character set — the Base64url alphabet (`A-Z`, +`a-z`, `0-9`, `-`, `_`) provides sufficient entropy. If a specific character +set is required in the future, a versioned path can be introduced +(e.g., `m/74'/1'/1'/{hash}'`). + +#### secp256k1 Derivation (Ethereum) + +`DeriveEthereumKey` uses **BIP-0032** (not SLIP-0010) at path +`m/44'/60'/0'/0/0`. This is a fundamentally different derivation algorithm from +Ed25519: + +- SLIP-0010 (Ed25519) uses HMAC-SHA512 with key "ed25519 seed" and only + supports hardened child derivation. +- BIP-0032 (secp256k1) uses HMAC-SHA512 with key "Bitcoin seed" and supports + both hardened and unhardened child derivation. + +The Ethereum path contains unhardened indices (`0/0`), which are invalid under +SLIP-0010. The `alknet-secret` crate gates secp256k1 derivation behind a +`secp256k1` feature flag, which pulls in the `libsecp256k1` crate. Deployments +that do not need Ethereum signing can omit this feature to avoid the +dependency. + +#### DerivedKey Security Properties + +Per ADR-038, the `private_key` field of `DerivedKey` must derive `Zeroize` and +use `#[zeroize(drop)]` to ensure sensitive key material is overwritten before +deallocation: + +```rust +#[derive(Zeroize)] +#[zeroize(drop)] +pub struct DerivedKey { + pub key_type: KeyType, + #[zeroize] + pub private_key: Vec, + pub public_key: Vec, +} +``` + +Because `private_key` is zeroized on drop, `DerivedKey` cannot derive `Clone` +directly on the `private_key` field. Instead, `Clone` is implemented manually +with a custom `clone()` that zeroizes the source's `private_key` after copying +it, ensuring no two `DerivedKey` instances share the same `Vec` allocation. + ### AES-256-GCM Encryption for External Credentials External credentials (API keys, OAuth tokens) that cannot be derived are @@ -141,6 +245,24 @@ encrypted using a key derived from the seed at path `m/74'/2'/0'/0'`. The 5. The seed phrase (or derived encryption key) is held only by the secret service — never in the database +#### EncryptedData.salt — Reserved for Future KDF-Based Key Rotation + +In v1, the encryption key is derived directly from the seed at path +`m/74'/2'/0'/0'` without any salt-based key derivation. The `salt` field in +`EncryptedData` is **reserved for future KDF-based key rotation** (Phase B): + +- The salt is generated randomly (32 bytes) and stored in `EncryptedData.salt` + for forward compatibility, but it is **not used** in the v1 key derivation + process. +- When key rotation is implemented, the salt will be used as input to HKDF or + PBKDF2 for stretch-based key derivation, allowing the same seed to produce + different encryption keys without changing the derivation path. +- This design ensures that the wire format does not need to change when key + rotation is introduced — the `salt` field is already present and populated. + +The `hkdf` and `pbkdf2` crates are listed as future dependencies in the +`Dependencies` section but are not included in v1. + ### SecretProtocol irpc Service ```rust @@ -180,29 +302,60 @@ enum SecretProtocol { Unlock { passphrase: String }, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] struct DerivedKey { key_type: KeyType, private_key: Vec, public_key: Vec, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] enum KeyType { Ed25519, Aes256Gcm, Secp256k1, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] struct EncryptedData { key_version: u32, - salt: String, // Base64-encoded + salt: String, // Base64-encoded (reserved for future KDF, not used in v1) iv: String, // Base64-encoded data: String, // Base64-encoded } ``` +#### irpc Integration Model + +The `SecretProtocol` enum defines the **wire protocol** — the set of operations +the secret service supports. The `#[rpc_requests(message = SecretMessage)]` +macro generates `SecretMessage` as the irpc wire type, which comes in two +variants: + +- `SecretMessage::Request`: serialized form for remote (QUIC) communication, + using postcard encoding. +- `SecretMessage::RequestWithChannels`: local form with `oneshot::Sender` + channels for in-process communication. + +There are two dispatch paths for consuming the secret service: + +1. **Local (in-process)**: `SecretServiceHandle` wraps `SecretServiceInner` + behind `Arc>` and provides direct method calls + (`derive_ed25519()`, `encrypt()`, etc.) without any serialization overhead. + This is the path used by the CLI binary and single-node deployments. No irpc + message passing is involved — the handle calls the implementation directly. + +2. **Remote (in-cluster)**: `Client` connects to the secret + service node via irpc over QUIC. The client sends `SecretMessage::Request` + messages (postcard-serialized) and receives responses. Workers on remote + nodes use this path. The seed never leaves the secret service node — only + derived keys are transmitted. + +The `SecretService` type owns the irpc service handler and a +`SecretServiceHandle`. It dispatches incoming `SecretMessage` variants to the +handle's methods. For call protocol exposure (e.g., `/head/secrets/derive`), +the service is wrapped in an operation that serializes to JSON. + ### Wire Format Compatibility with alknet-storage The `EncryptedData` type (`key_version`, `salt`, `iv`, `data`) is the stable @@ -213,22 +366,67 @@ alknet-secret encrypts and decrypts using this format. The Rust `EncryptedData` struct in alknet-secret is a superset of the TypeScript `EncryptedDataSchema` from `@alkdev/storage`. Migration path: re-encrypt TypeScript-encrypted data using the Rust secret service with a new key version. -See OQ-SVC-03. +The wire format is stable — future key rotation will use the existing `salt` +field rather than adding new fields (see OQ-SVC-03). ### Deployment Topologies **Minimal (single node, CLI)**: Secret service runs in the same process. Seed -phrase entered at startup. All keys derived locally. No irpc overhead. +phrase entered at startup. All keys derived locally via `SecretServiceHandle`. +No irpc overhead. **Production (head node)**: Secret service runs on a dedicated node or as a -local irpc service. Workers request derived keys via irpc over QUIC. The seed -never leaves the secret service node. +local irpc service. Workers request derived keys via `Client` +over QUIC. The seed never leaves the secret service node. + +### Test Vectors + +Known-answer tests are required against published test vectors to verify +correctness of the cryptographic implementations: + +#### BIP39 Test Vectors + +The `mnemonic` module must produce identical output to the BIP39 reference +test vectors: + +- Given a known mnemonic phrase and passphrase, the derived seed must match + the reference output byte-for-byte. +- Test vectors from + [BIP39 reference](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki) + and the `bip39` crate's own test suite. + +#### SLIP-0010 Test Vectors + +The `derivation` module must produce identical output to the SLIP-0010 reference +test vectors: + +- Given a known seed, the derived master key (private key + chain code) must + match the SLIP-0010 reference output. +- Given a known master key, the derived child key at path `m/74'/0'/0'/0'` + must match the reference output. +- Test vectors from + [SLIP-0010 reference](https://github.com/satoshilabs/slips/blob/master/slip-0010.md). + +#### AES-256-GCM Test Vectors + +The `encryption` module must produce identical results to published AES-256-GCM +test vectors: + +- Given a known key, IV, and plaintext, the ciphertext must match the reference + output. +- Use IEEE P802.1ASck or NIST SP 800-38D test vectors. +- Round-trip encryption/decryption must always succeed for valid inputs. + +These tests ensure that the implementation is correct and compatible with +other BIP39/SLIP-0010/AES-256-GCM implementations. They are placed in +`tests/vectors_tests.rs`. ## Constraints - The seed phrase is never persisted to disk. It is entered at startup or via `Unlock` and held only in `Zeroize`-protected RAM (ADR-038). -- `Lock` calls `zeroize()` on the seed and all cached derived keys. +- `Lock` calls `zeroize()` on the seed and all cached derived keys. The key + cache is fully cleared and zeroized on `Lock` (OQ-SVC-04, resolved). - alknet-secret does not depend on alknet-core or alknet-storage. It is fully independent (ADR-027). - The `EncryptedData` wire format is shared with alknet-storage for type-level @@ -241,14 +439,19 @@ never leaves the secret service node. (postcard serialization). For call protocol exposure (e.g., `/head/secrets/derive`), the service is wrapped in an operation that serializes to JSON. +- `DerivedKey.private_key` must derive `Zeroize` per ADR-038. Clone is + implemented manually to zeroize the source on clone. +- secp256k1 (Ethereum) derivation is gated behind the `secp256k1` feature flag + because it requires a different derivation algorithm (BIP-0032) and an + additional dependency (`libsecp256k1`). ## Phase Progression | Phase | Scope | Notes | |-------|-------|-------| -| Phase 3 (now) | Basic crate: mnemonic, derivation, encryption, irpc protocol, service lifecycle | Core key management | +| Phase 3 (now) | Basic crate: mnemonic, derivation, encryption, irpc protocol, service lifecycle, key caching | Core key management | | Phase A | Integration with alknet-storage via `EncryptedData` wire format. CLI commands for unlock/lock/derive. `SecretStoreCredentialProvider` wiring. | Full service integration | -| Phase B | Memory hardening: `mlock`/`VirtualLock` for seed RAM, constant-time comparison, audit logging of derivation requests. | Security hardening | +| Phase B | Memory hardening: `mlock`/`VirtualLock` for seed RAM, constant-time comparison, audit logging of derivation requests. Key rotation: KDF-based key derivation using `EncryptedData.salt` with HKDF/PBKDF2. | Security hardening | | Phase C | Multi-seed support (tenant isolation): indexed `Unlock` with tenant ID. | Multi-tenancy | ## Open Questions @@ -257,13 +460,21 @@ never leaves the secret service node. per tenant)? See [open-questions.md](open-questions.md). - **OQ-SVC-03**: How does the secret service integrate with the existing - `EncryptedDataSchema` from `@alkdev/storage`? See [open-questions.md](open-questions.md). + `EncryptedDataSchema` from `@alkdev/storage`? **Resolution**: The wire format + is stable. `EncryptedData` (`key_version`, `salt`, `iv`, `data`) is shared + type-level between alknet-secret and alknet-storage. The migration path is + re-encryption with a new key version. The `salt` field is reserved for future + KDF-based key rotation (see Phase B). See [open-questions.md](open-questions.md). -- **OQ-SVC-04**: Should workers cache derived keys locally? See - [open-questions.md](open-questions.md). +- **OQ-SVC-04**: Should workers cache derived keys locally? **Resolution**: Yes. + Derived keys are cached in RAM using an LRU cache keyed by derivation path, + with a TTL of 1 hour (configurable). The cache is fully cleared and zeroized + on `Lock`. This avoids redundant derivation for frequently used keys while + ensuring that `Lock` purges all sensitive material. See [open-questions.md](open-questions.md). - **OQ-SEC-01**: Should alknet-secret use `mlock`/`VirtualLock` to prevent seed RAM from being paged to disk? See [open-questions.md](open-questions.md). + Deferred to Phase B per ADR-038. ## Design Decisions @@ -280,4 +491,6 @@ never leaves the secret service node. - [research/integration-plan.md](../research/integration-plan.md) — Phase 3.1 - [credentials.md](credentials.md) — CredentialProvider (outbound auth, consumes SecretProtocol::Decrypt) - SLIP-0010 — https://github.com/satoshilabs/slips/blob/master/slip-0010.md -- BIP39 — https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki \ No newline at end of file +- BIP39 — https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki +- BIP-0032 — https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki +- NIST SP 800-38D — AES-GCM test vectors \ No newline at end of file