docs: close 7 spec gaps in secret-service.md
Address implementation-identified gaps: - Add irpc integration model (SecretServiceHandle vs Client<SecretProtocol>, 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)
This commit is contained in:
@@ -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<u8>,
|
||||
pub public_key: Vec<u8>,
|
||||
}
|
||||
```
|
||||
|
||||
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<u8>` 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<u8>,
|
||||
public_key: Vec<u8>,
|
||||
}
|
||||
|
||||
#[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<RwLock<>>` 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<SecretProtocol>` 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<SecretProtocol>`
|
||||
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
|
||||
|
||||
@@ -281,3 +492,5 @@ never leaves the secret service node.
|
||||
- [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
|
||||
- BIP-0032 — https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki
|
||||
- NIST SP 800-38D — AES-GCM test vectors
|
||||
Reference in New Issue
Block a user