chore: add Phase 3 secret-service decomposition tasks
9 atomic tasks for alknet-secret spec conformance and gap closure, derived from architect's implementation review. Dependencies form a 5-generation graph starting with spec update, then parallel implementation tasks, ending with a review gate. Tasks address: DerivedKey zeroize security, key caching with TTL, irpc protocol integration, password derivation, secp256k1/Ethereum derivation, encryption salt/KDF, crypto test vectors, and final spec conformance review.
This commit is contained in:
@@ -0,0 +1,93 @@
|
|||||||
|
---
|
||||||
|
id: crypto-test-vectors
|
||||||
|
name: Add BIP39 and SLIP-0010 known-answer test vectors for derivation correctness
|
||||||
|
status: pending
|
||||||
|
depends_on: [spec-update-secret-service]
|
||||||
|
scope: moderate
|
||||||
|
risk: low
|
||||||
|
impact: component
|
||||||
|
level: implementation
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
A crypto crate needs known-answer tests against published test vectors. The current test suite tests functional behavior (round-trip, determinism, error conditions) but not against published reference vectors. This means we don't know for certain that our BIP39 seed derivation or SLIP-0010 key derivation matches the standard.
|
||||||
|
|
||||||
|
**Required test vectors:**
|
||||||
|
|
||||||
|
### 1. BIP39 Test Vectors
|
||||||
|
|
||||||
|
From https://github.com/trezor/python-mnemonic/blob/master/vectors.json or the BIP39 reference:
|
||||||
|
|
||||||
|
- **Mnemonic → Seed**: Given a known mnemonic phrase and optional passphrase, the derived seed must match the published hex value byte-for-byte.
|
||||||
|
- Test with both 12-word and 24-word mnemonics
|
||||||
|
- Test with and without passphrase
|
||||||
|
- The `bip39` crate may already have these internally; verify with external reference
|
||||||
|
|
||||||
|
### 2. SLIP-0010 Test Vectors
|
||||||
|
|
||||||
|
From https://github.com/satoshilabs/slips/blob/master/slip-0010.md:
|
||||||
|
|
||||||
|
- **Seed → Master Key**: Given a known seed, the HMAC-SHA512 master key must match the published test vector.
|
||||||
|
- **Master Key → Child Key (Ed25519)**: Given the master key and known derivation indices, the derived child key must match the published test vector.
|
||||||
|
- **Path Derivation**: Given a known seed and path `m/74'/0'/0'/0'`, the final key must be deterministic and verified against the chain of individual derivation steps.
|
||||||
|
- SLIP-0010 test vector 1 uses the path `m/0h/1h/2h` with known seed, producing known keys at each step.
|
||||||
|
|
||||||
|
### 3. AES-256-GCM Test Vectors
|
||||||
|
|
||||||
|
From NIST SP 800-38D or equivalent:
|
||||||
|
|
||||||
|
- **Known key + known IV + known plaintext → known ciphertext**: Verify our AES-256-GCM implementation produces the expected ciphertext and tag.
|
||||||
|
- This is more of a sanity check since we use the `aes-gcm` crate which is well-tested, but it ensures our key handling (derived key → AES key) is correct.
|
||||||
|
|
||||||
|
### 4. Cross-consistency Tests
|
||||||
|
|
||||||
|
- **Mnemonic → Seed → Master Key → Derived Key at known path**: End-to-end test that starts with a known mnemonic, derives the seed, then derives keys at known paths, and verifies the result at each step.
|
||||||
|
- **Different mnemonics produce different keys**: Verify that two different mnemonics produce different keys at the same path (no accidental collisions).
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
Add a `tests/test_vectors.rs` integration test file. Test vectors are hardcoded hex strings verified against the published references.
|
||||||
|
|
||||||
|
For SLIP-0010 specifically, use the official test vectors from the SLIP-0010 specification. The SLIP-0010 spec provides:
|
||||||
|
- Test vector 1: seed → master key, then child derivations at `m/0h`, `m/0h/1h`, `m/0h/1h/2h`
|
||||||
|
- These use the "ed25519 seed" HMAC key and hardened-only derivation
|
||||||
|
|
||||||
|
For alknet-specific paths (`m/74'/0'/0'/0'`, etc.), generate test vectors once with a known mnemonic and commit the expected results. These serve as regression tests.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] `tests/test_vectors.rs` file added with BIP39 known-answer test vectors
|
||||||
|
- [ ] BIP39 test: known mnemonic + known passphrase → known seed (hex comparison)
|
||||||
|
- [ ] BIP39 test: known mnemonic + no passphrase → known seed (hex comparison)
|
||||||
|
- [ ] BIP39 test: different mnemonics produce different seeds
|
||||||
|
- [ ] SLIP-0010 test: known seed → known master key (hex comparison against SLIP-0010 spec vector)
|
||||||
|
- [ ] SLIP-0010 test: master key → derived child key at `m/0h` (matches SLIP-0010 test vector)
|
||||||
|
- [ ] SLIP-0010 test: master key → derived child key at `m/0h/1h/2h` (matches SLIP-0010 test vector)
|
||||||
|
- [ ] AES-256-GCM test: known key + known IV + known plaintext → known ciphertext (NIST vector or equivalent)
|
||||||
|
- [ ] Cross-consistency test: end-to-end mnemonic → seed → derived key at `m/74'/0'/0'/0'`
|
||||||
|
- [ ] All test vectors pass: `cargo test -p alknet-secret --test test_vectors`
|
||||||
|
- [ ] Test vectors reference their source (URL or specification section) in comments
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- docs/architecture/secret-service.md — Test vectors section (after spec update)
|
||||||
|
- SLIP-0010: https://github.com/satoshilabs/slips/blob/master/slip-0010.md
|
||||||
|
- BIP39: https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki
|
||||||
|
- BIP39 test vectors: https://github.com/trezor/python-mnemonic/blob/master/vectors.json
|
||||||
|
- AES-256-GCM: NIST SP 800-38D
|
||||||
|
- crates/alknet-secret/src/mnemonic.rs — BIP39 implementation
|
||||||
|
- crates/alknet-secret/src/derivation.rs — SLIP-0010 implementation
|
||||||
|
- crates/alknet-secret/src/encryption.rs — AES-256-GCM implementation
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
> The `bip39` and `ed25519-bip32` crates may have their own internal test vectors. Our tests verify our *wrapper* code (Mnemonic, Seed, ExtendedPrivKey) produces the same results. If the underlying crates have known-answer tests, we can reference them but should still have our own integration tests that exercise the full stack.
|
||||||
|
|
||||||
|
> For alknet-specific paths (`m/74'/...`), there are no published test vectors (74' is reserved for alknet). We generate our own "known-answer" vectors from a fixed mnemonic and commit the expected hex values as regression tests.
|
||||||
|
|
||||||
|
> Use `hex` crate (already in dev-dependencies) for encoding/decoding test vector bytes.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
> To be filled on completion
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
---
|
||||||
|
id: derive-password-implementation
|
||||||
|
name: Implement deterministic password derivation for DerivePassword
|
||||||
|
status: pending
|
||||||
|
depends_on: [spec-update-secret-service, derivedkey-zeroize-security]
|
||||||
|
scope: narrow
|
||||||
|
risk: low
|
||||||
|
impact: component
|
||||||
|
level: implementation
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
The `SecretProtocol::DerivePassword` variant exists in the protocol enum but has no corresponding service method. The spec (after update) defines deterministic password derivation as:
|
||||||
|
|
||||||
|
- **Algorithm**: HMAC-SHA512 at the derivation path `m/74'/1'/0'/{hash}'` where `{hash}'` is a site-specific hardened index
|
||||||
|
- **Output**: Truncate the derived key material to `length` bytes, encode as Base64url (URL-safe Base64 without padding)
|
||||||
|
- **Path format**: `m/74'/1'/0'/{hash}'` — SLIP-0010 hardened-only derivation
|
||||||
|
|
||||||
|
The current `SecretServiceHandle` has methods for `DeriveEd25519`, `DeriveEncryptionKey`, `DeriveEthereumKey`, `Encrypt`, and `Decrypt`, but no `derive_password`.
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
1. Add `SecretServiceHandle::derive_password(&self, path: &str, length: usize) -> Result<Vec<u8>, SecretServiceError>`
|
||||||
|
|
||||||
|
2. The implementation:
|
||||||
|
- Derive a key at the given path using SLIP-0010 (same as Ed25519 derivation)
|
||||||
|
- Take the first `length` bytes of the private key material
|
||||||
|
- Encode as Base64url (no padding) using `base64::engine::general_purpose::URL_SAFE_NO_PAD`
|
||||||
|
- The output is a deterministic password string that can be regenerated from the same seed + path
|
||||||
|
|
||||||
|
**Wait** — there's a design choice here. Should `DerivePassword` return raw bytes (`Vec<u8>`) or an encoded string (`String`)? The spec says the protocol variant returns `Vec<u8>`, but a "password" is typically a string. Let me check the protocol definition more carefully.
|
||||||
|
|
||||||
|
The protocol says:
|
||||||
|
```rust
|
||||||
|
#[rpc(tx=oneshot::Sender<Vec<u8>>)]
|
||||||
|
#[wrap(DerivePassword)]
|
||||||
|
DerivePassword { path: String, length: usize },
|
||||||
|
```
|
||||||
|
|
||||||
|
So the return type is `Vec<u8>`. The encoding to a usable password string should happen at the call site or be a separate method. For the protocol, return raw derived bytes.
|
||||||
|
|
||||||
|
**Resolution**: `derive_password()` returns `Vec<u8>` (raw derived bytes). A convenience method `derive_password_string(path: &str, length: usize) -> Result<String, SecretServiceError>` returns the Base64url-encoded string for use as an actual password. The protocol variant returns `Vec<u8>`.
|
||||||
|
|
||||||
|
3. Add `SecretProtocol::DerivePassword` dispatch to the service actor (irpc task depends on `irpc-secret-protocol-integration`).
|
||||||
|
|
||||||
|
4. The `derive_password` method in `SecretServiceHandle`:
|
||||||
|
```rust
|
||||||
|
pub fn derive_password(&self, path: &str, length: usize) -> Result<Vec<u8>, SecretServiceError> {
|
||||||
|
let inner = self.inner.read().unwrap();
|
||||||
|
if !inner.unlocked {
|
||||||
|
return Err(SecretServiceError::ServiceLocked);
|
||||||
|
}
|
||||||
|
let seed = inner.seed.as_ref().ok_or(SecretServiceError::ServiceLocked)?;
|
||||||
|
let key = derivation::derive_path_from_seed(seed.as_bytes(), path)?;
|
||||||
|
// Return first `length` bytes of private key
|
||||||
|
let result = key.private_key()[..length.min(key.private_key().len())].to_vec();
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] `SecretServiceHandle::derive_password(&self, path: &str, length: usize) -> Result<Vec<u8>, SecretServiceError>` method added
|
||||||
|
- [ ] `derive_password` returns the first `length` bytes of the derived private key at the given path
|
||||||
|
- [ ] `derive_password` requires unlocked state (returns `ServiceLocked` if locked)
|
||||||
|
- [ ] `SecretServiceHandle::derive_password_string(&self, path: &str, length: usize) -> Result<String, SecretServiceError>` convenience method returns Base64url-encoded string
|
||||||
|
- [ ] `derive_password` uses the key cache (if `key-caching-ttl` task is complete)
|
||||||
|
- [ ] Unit test: `derive_password` at a known path returns deterministic bytes
|
||||||
|
- [ ] Unit test: `derive_password` at the same path returns the same bytes (deterministic)
|
||||||
|
- [ ] Unit test: `derive_password` at a different path returns different bytes
|
||||||
|
- [ ] Unit test: `derive_password` length parameter truncates correctly
|
||||||
|
- [ ] Unit test: `derive_password_string` returns valid Base64url (no padding)
|
||||||
|
- [ ] Unit test: `derive_password` returns `ServiceLocked` error when service is locked
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- docs/architecture/secret-service.md — Key derivation, password derivation path
|
||||||
|
- crates/alknet-secret/src/service.rs — SecretServiceHandle (add derive_password)
|
||||||
|
- crates/alknet-secret/src/protocol.rs — SecretProtocol::DerivePassword variant
|
||||||
|
- crates/alknet-secret/src/derivation.rs — derive_path_from_seed, PATHS
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
> The `length` parameter in `DerivePassword` specifies bytes, not characters. Since Ed25519 derived keys are 32 bytes, the maximum useful length is 32. For longer passwords, the spec says to use Base64url encoding of the full 32 bytes, which gives a ~43-character string. Password managers typically want 16-32 byte keys encoded as ~22-43 character strings.
|
||||||
|
|
||||||
|
> The `PATHS::DEVICE_PREFIX` pattern (`m/74'/0'/0'`) allows parameterized device identity. Similarly, `site_password_path(hash)` (`m/74'/1'/0'/{hash}'`) allows site-specific passwords. Both work with the same derivation function — the path is just different.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
> To be filled on completion
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
---
|
||||||
|
id: derivedkey-zeroize-security
|
||||||
|
name: Make DerivedKey private_key Zeroize-derived and fix clone semantics for ADR-038 compliance
|
||||||
|
status: pending
|
||||||
|
depends_on: [spec-update-secret-service]
|
||||||
|
scope: narrow
|
||||||
|
risk: low
|
||||||
|
impact: component
|
||||||
|
level: implementation
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
The `DerivedKey` struct in `protocol.rs` carries `private_key: Vec<u8>` which is sensitive key material, but it derives `Clone` and `Serialize`/`Deserialize` without any zeroize protection. Per ADR-038, all sensitive material must implement `Zeroize` and be zeroized on drop.
|
||||||
|
|
||||||
|
The current code:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DerivedKey {
|
||||||
|
pub key_type: KeyType,
|
||||||
|
pub private_key: Vec<u8>, // SENSITIVE — must zeroize
|
||||||
|
pub public_key: Vec<u8>, // Not sensitive (public)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Problems:
|
||||||
|
1. `private_key` doesn't derive `Zeroize` — it stays in memory after `DerivedKey` is dropped
|
||||||
|
2. `Clone` copies `private_key` without zeroizing the source — if the clone is dropped, one copy may linger
|
||||||
|
3. The struct is `Serialize` — serializing `private_key` to JSON is a potential leak vector (logging, debug output)
|
||||||
|
|
||||||
|
**Fix approach:**
|
||||||
|
|
||||||
|
- Make `DerivedKey` implement `Zeroize` with `#[zeroize(drop)]`
|
||||||
|
- Replace `#[derive(Clone)]` with a manual `Clone` impl that zeroizes the source's `private_key` after copying (move semantics through clone — the source key is consumed, not left in memory)
|
||||||
|
- OR change the API to return `DerivedKey` by value only (no Clone) — consumers get one copy and must zeroize it when done. This is the more conventional crypto API pattern.
|
||||||
|
- Add `#[serde(skip_serializing)]` or a custom serializer that redacts `private_key` from JSON output (or use a dedicated display format that shows only the public key)
|
||||||
|
- `KeyType` and `public_key` are not sensitive and can remain as-is
|
||||||
|
|
||||||
|
**Important**: This change affects the `SecretServiceHandle` methods that return `DerivedKey`. If `DerivedKey` becomes non-Clone, those methods must return `DerivedKey` by value (which they already do — they construct a new `DerivedKey` each time). The key caching task (which adds a cache) will need to handle this carefully.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] `DerivedKey` derives `Zeroize` with `#[zeroize(drop)]` on the `private_key` field
|
||||||
|
- [ ] `DerivedKey` does NOT derive `Clone` — it's a move-only type (consumers must zeroize when done)
|
||||||
|
- [ ] `DerivedKey` serialization redacts `private_key` — JSON output shows a placeholder (e.g., `"[REDACTED]"`) instead of key bytes
|
||||||
|
- [ ] `DerivedKey::zeroize()` overwrites `private_key` with zeros
|
||||||
|
- [ ] `Drop` for `DerivedKey` calls `zeroize()` on the `private_key` field
|
||||||
|
- [ ] Existing `SecretServiceHandle` methods compile without `Clone` (they already return `DerivedKey` by value)
|
||||||
|
- [ ] Unit test: `DerivedKey` zeroes `private_key` on drop
|
||||||
|
- [ ] Unit test: `DerivedKey` serialization does NOT contain `private_key` bytes
|
||||||
|
- [ ] ADR-038 compliance: all types holding private key material derive `Zeroize`
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- docs/architecture/secret-service.md — DerivedKey specification
|
||||||
|
- docs/architecture/decisions/038-seed-lifecycle-memory-security.md — ADR-038
|
||||||
|
- crates/alknet-secret/src/protocol.rs — Current DerivedKey definition
|
||||||
|
- crates/alknet-secret/src/derivation.rs — ExtendedPrivKey (already Zeroize-derived)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
> The `ExtendedPrivKey` in `derivation.rs` already correctly implements `Zeroize` with `#[zeroize(drop)]`. This task brings the same security discipline to `DerivedKey`.
|
||||||
|
|
||||||
|
> Making `DerivedKey` non-Clone is the safer choice. In crypto APIs, returning key material by value and requiring explicit zeroization is the standard pattern. The key cache (in the caching task) will hold derived keys in an internal cache type, not in `DerivedKey` directly.
|
||||||
|
|
||||||
|
> For serialization redaction: consider a custom `Serialize` impl that serializes `private_key` as `"[REDACTED]"` for JSON display but `Deserialize` still reads the full bytes for protocol use. Alternatively, `private_key` can be skipped entirely in serialization (since `DerivedKey` is intended for local use, not wire transfer — the irpc protocol sends `DerivedKey` through postcard, not JSON). The key cache task will need to handle this.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
> To be filled on completion
|
||||||
103
tasks/integration/phase3/secret-service/encryption-salt-kdf.md
Normal file
103
tasks/integration/phase3/secret-service/encryption-salt-kdf.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
---
|
||||||
|
id: encryption-salt-kdf
|
||||||
|
name: Clarify and fix EncryptedData salt usage — use HKDF for key derivation or document as reserved
|
||||||
|
status: pending
|
||||||
|
depends_on: [spec-update-secret-service]
|
||||||
|
scope: narrow
|
||||||
|
risk: low
|
||||||
|
impact: component
|
||||||
|
level: implementation
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
The `EncryptedData` struct has a `salt` field that is generated randomly during encryption but not used in the key derivation process. The current encryption flow is:
|
||||||
|
|
||||||
|
1. Derive key from seed at path `m/74'/2'/0'/0'`
|
||||||
|
2. Use first 32 bytes of derived private key as AES-256-GCM key
|
||||||
|
3. Generate random 12-byte IV
|
||||||
|
4. Generate random 32-byte salt (stored but NOT used in key derivation)
|
||||||
|
5. Encrypt with AES-256-GCM using the derived key + random IV
|
||||||
|
6. Store `{key_version, salt, iv, data}` as `EncryptedData`
|
||||||
|
|
||||||
|
The salt is stored but serves no purpose. This is a gap because:
|
||||||
|
|
||||||
|
- Without a KDF, the same derived key is used for every encryption operation (different IVs provide per-message randomness, but the key itself is static)
|
||||||
|
- Salt-based key derivation would add an additional security layer: even if the derivation path is known, the salt provides per-encryption diversity
|
||||||
|
- The `key_version` field exists for rotation but without KDF-based key derivation, there's no mechanism to rotate to a stronger key
|
||||||
|
|
||||||
|
**The spec update (spec-update-secret-service task) decides one of two paths:**
|
||||||
|
|
||||||
|
### Option A: Use HKDF for key derivation (recommended for v1)
|
||||||
|
|
||||||
|
Replace the direct "first 32 bytes of derived key" approach with:
|
||||||
|
1. Derive master key from seed at path `m/74'/2'/0'/0'`
|
||||||
|
2. Use HKDF-SHA256 with `salt` and `info = "alknet-encryption-v{key_version}"` to derive the actual AES-256-GCM key
|
||||||
|
3. This means: same seed + same path + different salt = different AES key
|
||||||
|
|
||||||
|
Benefits: Each encryption uses a unique derived key (even with the same master key), providing forward security and key diversity. The salt is now purposeful.
|
||||||
|
|
||||||
|
### Option B: Document salt as reserved (Phase B)
|
||||||
|
|
||||||
|
Keep the current approach (direct key from derivation path) and document the salt field as "reserved for future KDF-based key derivation." Add a comment explaining that v1 doesn't use the salt.
|
||||||
|
|
||||||
|
This is simpler in v1 but defers the security improvement.
|
||||||
|
|
||||||
|
**This task implements whichever option the spec update chooses.** If the spec says "use HKDF now," implement Option A. If it says "document as reserved," implement Option B.
|
||||||
|
|
||||||
|
**If Option A (HKDF):**
|
||||||
|
|
||||||
|
1. Add `hkdf` dependency to `Cargo.toml`
|
||||||
|
2. Modify `encryption::encrypt()`:
|
||||||
|
- Generate random salt (32 bytes)
|
||||||
|
- Use HKDF-SHA256 to derive AES key from: `master_key + salt + info`
|
||||||
|
- The `info` string includes the key version for forward compatibility
|
||||||
|
3. Modify `encryption::decrypt()`:
|
||||||
|
- Use HKDF-SHA256 with the stored salt to re-derive the AES key
|
||||||
|
- Decrypt ciphertext with the derived key + stored IV
|
||||||
|
4. **Backward compatibility**: Add an `EncryptedData::version` or check if salt is empty/all-zeros to detect v1 (direct key) vs v2 (HKDF) format. Or, since key_version=1 is already in use, bump key_version to 2 for HKDF-derived keys and support both in decrypt.
|
||||||
|
|
||||||
|
**If Option B (reserved):**
|
||||||
|
|
||||||
|
1. Add documentation/comments to `encryption.rs` and `EncryptedData` explaining that the salt is reserved for future KDF
|
||||||
|
2. Add a `// TODO(Phase B)` comment on the salt generation
|
||||||
|
3. No code behavior changes
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
**If Option A (HKDF — recommended):**
|
||||||
|
|
||||||
|
- [ ] `hkdf` dependency added to `Cargo.toml`
|
||||||
|
- [ ] `encrypt()` uses HKDF-SHA256 with `salt + info = "alknet-encryption-v{key_version}"` to derive AES key
|
||||||
|
- [ ] `decrypt()` uses HKDF-SHA256 with stored `salt` to re-derive AES key
|
||||||
|
- [ ] `EncryptedData` with `key_version >= 2` uses HKDF
|
||||||
|
- [ ] `EncryptedData` with `key_version == 1` uses direct key (backward compat)
|
||||||
|
- [ ] Backward compatibility: data encrypted with v1 format can still be decrypted
|
||||||
|
- [ ] `CURRENT_KEY_VERSION` bumped to 2
|
||||||
|
- [ ] Unit test: encrypt/decrypt round-trip with HKDF (key_version 2)
|
||||||
|
- [ ] Unit test: decrypt v1-encrypted data (direct key) still works
|
||||||
|
- [ ] Unit test: different salts produce different ciphertext keys (even with same master key)
|
||||||
|
- [ ] `EncryptionKey` struct updated to carry HKDF info if needed
|
||||||
|
|
||||||
|
**If Option B (reserved):**
|
||||||
|
|
||||||
|
- [ ] `encryption.rs` has documentation explaining salt is reserved for future KDF
|
||||||
|
- [ ] `EncryptedData` struct has doc comment on `salt` field explaining reserved purpose
|
||||||
|
- [ ] `// TODO(Phase B)` comment on salt generation in `encrypt()`
|
||||||
|
- [ ] No behavior changes — existing tests pass unchanged
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- docs/architecture/secret-service.md — Encryption section (after spec update)
|
||||||
|
- crates/alknet-secret/src/encryption.rs — Current encrypt/decrypt implementation
|
||||||
|
- HKDF (RFC 5869): https://tools.ietf.org/html/rfc5869
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
> My recommendation is Option A (HKDF). It's a small amount of additional code (the `hkdf` crate is tiny and well-tested), it makes the `salt` field purposeful, and it provides per-encryption key diversity. The backward compatibility concern is manageable: decrypt based on `key_version` (v1 = direct, v2 = HKDF).
|
||||||
|
|
||||||
|
> The architect's message specifically called out: "The EncryptedData struct has a salt field but the encryption function generates a random salt per encryption without using it for key derivation. Either the salt should be used in a KDF, or the field should be documented as reserved." This task resolves that ambiguity.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
> To be filled on completion
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
---
|
||||||
|
id: irpc-secret-protocol-integration
|
||||||
|
name: Wire SecretProtocol to irpc with local SecretServiceHandle and remote dispatch
|
||||||
|
status: pending
|
||||||
|
depends_on: [spec-update-secret-service, key-caching-ttl]
|
||||||
|
scope: moderate
|
||||||
|
risk: medium
|
||||||
|
impact: component
|
||||||
|
level: implementation
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
The `SecretProtocol` enum in `protocol.rs` currently has a placeholder `SecretMessage = SecretProtocol` type alias. The spec (after update) defines two dispatch paths:
|
||||||
|
|
||||||
|
1. **Local dispatch (in-process)**: `SecretServiceHandle` — async methods that directly call into `SecretServiceInner`. No serialization overhead.
|
||||||
|
2. **Remote dispatch (in-cluster)**: `SecretProtocol` irpc client — sends `SecretMessage` via mpsc (local node) or QUIC stream (remote worker). The service runs on a head node; workers request derived keys via irpc.
|
||||||
|
|
||||||
|
Per ADR-027, irpc is always a dependency in alknet-secret (not feature-gated). Per ADR-033, irpc is one dispatch backend for OperationEnv.
|
||||||
|
|
||||||
|
**What needs to happen:**
|
||||||
|
|
||||||
|
1. **irpc crate integration**: The `irpc` crate needs to be added as a dependency to `alknet-secret`. The `#[rpc_requests]` macro must be applied to `SecretProtocol` to generate `SecretMessage` with oneshot channels for the response types.
|
||||||
|
|
||||||
|
2. **SecretMessage definition**: Replace `pub type SecretMessage = SecretProtocol;` with the irpc-generated message type. Each variant gets a `oneshot::Sender<T>` for the response:
|
||||||
|
- `DeriveEd25519 { path: String, tx: oneshot::Sender<DerivedKey> }` → `SecretMessage::DeriveEd25519`
|
||||||
|
- `DeriveEncryptionKey { path: String, tx: oneshot::Sender<DerivedKey> }`
|
||||||
|
- `DeriveEthereumKey { path: String, tx: oneshot::Sender<DerivedKey> }`
|
||||||
|
- `DerivePassword { path: String, length: usize, tx: oneshot::Sender<Vec<u8>> }`
|
||||||
|
- `Encrypt { plaintext: String, key_version: u32, tx: oneshot::Sender<EncryptedData> }`
|
||||||
|
- `Decrypt { encrypted: EncryptedData, tx: oneshot::Sender<String> }`
|
||||||
|
- `Lock { tx: oneshot::Sender<()> }`
|
||||||
|
- `Unlock { passphrase: String, tx: oneshot::Sender<()> }`
|
||||||
|
|
||||||
|
**Note**: If the `irpc` crate's `#[rpc_requests]` macro generates this automatically, use it. If irpc doesn't exist as a crate yet (it may still be in design), create the `SecretMessage` enum manually with the oneshot channels, following the pattern used by `AuthProtocol` in alknet-core.
|
||||||
|
|
||||||
|
3. **SecretServiceHandle dispatch**: The `SecretServiceHandle` methods should dispatch through the irpc channel. When assembled locally, the handle sends `SecretMessage` variants to a `tokio::sync::mpsc` channel; a task runs `SecretServiceInner` and processes messages. This replaces the current `RwLock<SecretServiceInner>` pattern with an actor-model pattern.
|
||||||
|
|
||||||
|
**OR** keep the current `RwLock<SecretServiceInner>` for local use and add a separate `SecretServiceActor` that wraps the handle in an mpsc-based message loop. The `SecretServiceHandle` stays as the primary local API. The actor is the irpc entry point.
|
||||||
|
|
||||||
|
**Prefer the simpler approach**: Keep `SecretServiceHandle` with `RwLock` for direct local use (current code). Add a `SecretServiceActor` that:
|
||||||
|
- Holds a `SecretServiceHandle`
|
||||||
|
- Runs a message loop: receives `SecretMessage`, dispatches to `SecretServiceHandle` methods, sends responses through oneshot channels
|
||||||
|
- Can be spawned as a `tokio::task` for in-process irpc
|
||||||
|
- Exposes a `tokio::sync::mpsc::Sender<SecretMessage>` as the client handle
|
||||||
|
|
||||||
|
4. **irpc feature**: Per ADR-027, irpc is always-on in alknet-secret. No feature flag needed. If the `irpc` crate exists, depend on it directly. If not, the `SecretMessage` type can be defined locally following the irpc pattern.
|
||||||
|
|
||||||
|
**Current state**: `irpc` is listed as `"0.x"` in the spec's dependencies but is not in `Cargo.toml`. The current code doesn't import irpc at all. Check whether the `irpc` crate exists in the workspace or if it needs to be defined locally.
|
||||||
|
|
||||||
|
**Critical dependency**: This task cannot proceed until we know the irpc crate's API. If it doesn't exist yet, we should define `SecretMessage` manually following the same pattern as `AuthProtocol` in alknet-core (which also uses irpc behind a feature flag).
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] `irpc` dependency added to `Cargo.toml` (or `SecretMessage` defined manually if irpc doesn't exist yet)
|
||||||
|
- [ ] `SecretMessage` enum defined with oneshot channels for each `SecretProtocol` variant's response type
|
||||||
|
- [ ] `SecretServiceActor` struct that wraps `SecretServiceHandle` and processes `SecretMessage` variants
|
||||||
|
- [ ] `SecretServiceActor::run()` method that spawns a message loop as a `tokio::task`
|
||||||
|
- [ ] `SecretServiceActor::handle(&self) -> mpsc::Sender<SecretMessage>` returns a client handle for sending messages
|
||||||
|
- [ ] Each `SecretMessage` variant dispatches to the corresponding `SecretServiceHandle` method
|
||||||
|
- [ ] `SecretServiceHandle` remains the primary local API (RwLock-based, unchanged for direct use)
|
||||||
|
- [ ] Unit test: `SecretServiceActor` processes `SecretMessage::Unlock` and responds successfully
|
||||||
|
- [ ] Unit test: `SecretMessage::DeriveEd25519` dispatched through actor returns `DerivedKey`
|
||||||
|
- [ ] Unit test: `SecretMessage::Lock` clears state and subsequent derive calls fail
|
||||||
|
- [ ] `protocol.rs` updated: `SecretMessage` is no longer a type alias, it's the irpc message type
|
||||||
|
- [ ] `lib.rs` re-exports updated to include `SecretServiceActor` and `SecretMessage`
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- docs/architecture/secret-service.md — irpc service section (after spec update)
|
||||||
|
- docs/architecture/decisions/027-crate-decomposition.md — ADR-027 (irpc always-on in alknet-secret)
|
||||||
|
- docs/architecture/decisions/033-operationenv-irpc-call-protocol.md — ADR-033 (irpc as dispatch backend)
|
||||||
|
- crates/alknet-secret/src/protocol.rs — Current SecretProtocol with placeholder SecretMessage
|
||||||
|
- crates/alknet-secret/src/service.rs — SecretServiceHandle and SecretService
|
||||||
|
- crates/alknet-core/src/auth/ — AuthProtocol pattern (reference for irpc integration)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
> This is the biggest gap identified by the architect. The spec says `#[rpc_requests]` but that macro doesn't exist in the codebase yet. Check whether `irpc` is a workspace crate or an external dependency.
|
||||||
|
|
||||||
|
> If `irpc` doesn't exist yet, create a local `SecretMessage` type following the same channel-based pattern that alknet-core uses for its irpc services. The key pattern is: each protocol variant has a corresponding message variant with a `oneshot::Sender<Response>` for the response. The service actor receives messages, processes them, and sends responses.
|
||||||
|
|
||||||
|
> The `SecretServiceHandle` with `RwLock` should remain as the primary local API. It's simpler, faster, and works well for single-process use. The `SecretServiceActor` wraps it for irpc dispatch. This two-API pattern matches the spec's "minimal deployment (local handle) vs production deployment (irpc service)" distinction.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
> To be filled on completion
|
||||||
107
tasks/integration/phase3/secret-service/key-caching-ttl.md
Normal file
107
tasks/integration/phase3/secret-service/key-caching-ttl.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
---
|
||||||
|
id: key-caching-ttl
|
||||||
|
name: Implement TTL-based key cache with LRU eviction for SecretService
|
||||||
|
status: pending
|
||||||
|
depends_on: [spec-update-secret-service, derivedkey-zeroize-security]
|
||||||
|
scope: moderate
|
||||||
|
risk: medium
|
||||||
|
impact: component
|
||||||
|
level: implementation
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
The `SecretServiceHandle` currently re-derives keys from the seed on every call. The spec (after update) requires a TTL-based key cache with LRU eviction, cleared on `Lock`. This is the resolution of OQ-SVC-04.
|
||||||
|
|
||||||
|
The current `SecretServiceInner` holds:
|
||||||
|
- `mnemonic: Option<Mnemonic>`
|
||||||
|
- `seed: Option<Seed>`
|
||||||
|
- `unlocked: bool`
|
||||||
|
|
||||||
|
It needs to add:
|
||||||
|
- `cache: HashMap<String, CachedKey>` where the key is the derivation path string
|
||||||
|
- `cache_ttl: Duration` (default 1 hour, configurable)
|
||||||
|
- LRU eviction when cache exceeds a configurable max size
|
||||||
|
|
||||||
|
**Design considerations:**
|
||||||
|
|
||||||
|
- The cache key is the derivation path string (e.g., `m/74'/0'/0'/0'`). This means caching at the path level — if you derive the same path multiple times, you get the cached key (within TTL).
|
||||||
|
- The cached value must hold the derived key material zeroize-protected, not just the public key.
|
||||||
|
- TTL is checked on access, not via a background timer. Expired entries are evicted lazily.
|
||||||
|
- `Lock` clears the cache entirely and zeroizes all cached entries.
|
||||||
|
- Cache hits avoid re-derivation from seed, which is the main performance win.
|
||||||
|
- The cache must be behind `RwLock` (already used for `SecretServiceInner`).
|
||||||
|
|
||||||
|
**Cache entry structure:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Zeroize)]
|
||||||
|
#[zeroize(drop)]
|
||||||
|
struct CachedKey {
|
||||||
|
derived_at: Instant,
|
||||||
|
key_type: KeyType,
|
||||||
|
private_key: Vec<u8>, // Zeroize-protected
|
||||||
|
public_key: Vec<u8>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cache configuration:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct CacheConfig {
|
||||||
|
pub ttl: Duration, // Default: 1 hour
|
||||||
|
pub max_entries: usize, // Default: 64
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
|
||||||
|
- On `derive_*` call: check cache. If hit and `Instant::now() - derived_at < ttl`, return cached. If expired, evict and re-derive. If miss, derive and insert.
|
||||||
|
- On `Lock`: zeroize all cache entries, clear the HashMap, zeroize seed and mnemonic (existing behavior).
|
||||||
|
- On `Encrypt`/`Decrypt`: the encryption key at `PATHS::ENCRYPTION` is also cached (same path, same behavior).
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
Add a `cache` module to `alknet-secret/src/cache.rs` implementing `KeyCache` with `get`, `insert`, `evict_expired`, `clear` (zeroize on clear).
|
||||||
|
|
||||||
|
Wire into `SecretServiceInner` behind the existing `RwLock`.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] `cache.rs` module added to `alknet-secret/src/` with `KeyCache` struct
|
||||||
|
- [ ] `CachedKey` struct with Zeroize-derived private key, `derived_at: Instant`, `key_type`, `public_key`
|
||||||
|
- [ ] `CacheConfig` struct with `ttl: Duration` (default 1 hour) and `max_entries: usize` (default 64)
|
||||||
|
- [ ] `KeyCache::get(path: &str) -> Option<&CachedKey>` returns cached entry if within TTL
|
||||||
|
- [ ] `KeyCache::insert(path: &str, key: CachedKey)` inserts, evicts LRU if over max_entries
|
||||||
|
- [ ] `KeyCache::evict_expired()` removes entries past TTL, zeroizing them
|
||||||
|
- [ ] `KeyCache::clear()` zeroizes all entries and clears the HashMap
|
||||||
|
- [ ] `SecretServiceInner` gains a `cache: KeyCache` field (behind RwLock)
|
||||||
|
- [ ] `SecretServiceHandle::new()` accepts optional `CacheConfig` (defaults applied)
|
||||||
|
- [ ] `derive_ed25519`, `derive_encryption_key`, `derive_ethereum_key` check cache before re-deriving
|
||||||
|
- [ ] `Lock` clears the cache (zeroizes all cached entries)
|
||||||
|
- [ ] Unit test: cache hit avoids re-derivation
|
||||||
|
- [ ] Unit test: cache miss derives and caches
|
||||||
|
- [ ] Unit test: expired entry is evicted on access and re-derived
|
||||||
|
- [ ] Unit test: LRU eviction when cache exceeds max_entries
|
||||||
|
- [ ] Unit test: Lock clears all cache entries and zeroizes them
|
||||||
|
- [ ] Unit test: encrypt/decrypt uses cached encryption key
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- docs/architecture/secret-service.md — Key caching subsection (after spec update)
|
||||||
|
- docs/architecture/decisions/038-seed-lifecycle-memory-security.md — Zeroize requirement
|
||||||
|
- OQ-SVC-04 — Resolved: yes, cache with TTL default 1 hour
|
||||||
|
- crates/alknet-secret/src/service.rs — SecretServiceInner (to add cache)
|
||||||
|
- crates/alknet-secret/src/lib.rs — Module re-exports
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
> This task depends on `derivedkey-zeroize-security` because `CachedKey` needs the same zeroize discipline that `DerivedKey` gets. If `DerivedKey` becomes non-Clone, `CachedKey` is a separate internal type that holds the same data but is managed within the cache.
|
||||||
|
|
||||||
|
> The LRU implementation can use `std::collections::HashMap` + a doubly-linked list (or `lru` crate for simplicity). Given the max_entries default of 64, even a simple scan-and-evict approach is fine. Prefer `lru` crate for correctness and simplicity.
|
||||||
|
|
||||||
|
> Cache configuration should be exposed through `SecretService::new()` or a `SecretServiceBuilder` pattern, not through `SecretServiceHandle::new()`. The handle wraps an already-configured service.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
> To be filled on completion
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
---
|
||||||
|
id: review-alknet-secret-spec-conformance
|
||||||
|
name: Review alknet-secret crate for spec conformance and prepare for Phase A integration
|
||||||
|
status: pending
|
||||||
|
depends_on: [spec-update-secret-service, derivedkey-zeroize-security, key-caching-ttl, irpc-secret-protocol-integration, derive-password-implementation, secp256k1-ethereum-derivation, encryption-salt-kdf, crypto-test-vectors]
|
||||||
|
scope: moderate
|
||||||
|
risk: low
|
||||||
|
impact: phase
|
||||||
|
level: review
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Review the complete alknet-secret crate for spec conformance, security properties, and readiness for Phase A integration (connecting to alknet-storage via EncryptedData wire format and alknet-core via OperationEnv/irpc).
|
||||||
|
|
||||||
|
This review covers all tasks in the secret-service task group. It verifies that the implementation matches the updated spec and that the crate is safe and correct for production use.
|
||||||
|
|
||||||
|
### Review Checklist
|
||||||
|
|
||||||
|
1. **Spec conformance**: Does every module, type, and method in the implementation match what the updated `secret-service.md` specifies?
|
||||||
|
- Mnemonic module matches spec (BIP39, Language, Seed, Zeroize)
|
||||||
|
- Derivation module matches spec (SLIP-0010, path constants, ExtendedPrivKey)
|
||||||
|
- Encryption module matches spec (AES-256-GCM, EncryptedData, salt purpose)
|
||||||
|
- Protocol module matches spec (SecretProtocol, SecretMessage, DerivedKey with Zeroize)
|
||||||
|
- Service module matches spec (SecretServiceHandle, SecretServiceActor, lifecycle)
|
||||||
|
- Cache module matches spec (KeyCache, TTL, LRU, cleared on Lock)
|
||||||
|
|
||||||
|
2. **Security properties (ADR-038)**:
|
||||||
|
- All sensitive types derive `Zeroize` with `#[zeroize(drop)]`
|
||||||
|
- `DerivedKey.private_key` is zeroized on drop (not Clone)
|
||||||
|
- `Seed` is zeroized on drop
|
||||||
|
- `Mnemonic` is zeroized on drop
|
||||||
|
- `CachedKey.private_key` is zeroized on drop
|
||||||
|
- `EncryptionKey` is zeroized on drop
|
||||||
|
- `Lock` zeroizes all cached keys and the seed
|
||||||
|
- No private key material leaks through `Debug`, `Display`, or `Serialize`
|
||||||
|
|
||||||
|
3. **irpc integration**:
|
||||||
|
- `SecretMessage` is properly defined with oneshot channels
|
||||||
|
- `SecretServiceActor` processes all message variants
|
||||||
|
- Local use via `SecretServiceHandle` still works without irpc overhead
|
||||||
|
- The actor model doesn't introduce deadlocks or race conditions
|
||||||
|
|
||||||
|
4. **Test vectors**:
|
||||||
|
- BIP39 test vectors pass
|
||||||
|
- SLIP-0010 test vectors pass
|
||||||
|
- AES-256-GCM test vectors pass
|
||||||
|
- Cross-consistency (mnemonic → seed → key) pass
|
||||||
|
|
||||||
|
5. **Feature flag**:
|
||||||
|
- `secp256k1` feature flag works correctly
|
||||||
|
- Without the flag, `derive_ethereum_key()` returns `UnsupportedKeyType`
|
||||||
|
- With the flag, BIP-0032 derivation produces correct secp256k1 keys
|
||||||
|
|
||||||
|
6. **No circular dependencies**:
|
||||||
|
- `alknet-secret` does not depend on `alknet-core` or `alknet-storage`
|
||||||
|
- Check `Cargo.toml` for any accidental dependencies
|
||||||
|
|
||||||
|
7. **Wire format compatibility**:
|
||||||
|
- `EncryptedData` serialization matches the spec (key_version, salt, iv, data — all Base64)
|
||||||
|
- `DerivedKey` serialization redacts `private_key`
|
||||||
|
- `SecretProtocol` variants serialize correctly for irpc (postcard format)
|
||||||
|
|
||||||
|
8. **Public API completeness**:
|
||||||
|
- All spec'd re-exports are present in `lib.rs`
|
||||||
|
- `SecretService`, `SecretServiceHandle`, `SecretServiceActor`, `SecretMessage` all re-exported
|
||||||
|
- `CacheConfig` is available for configuration
|
||||||
|
- Feature-gated types (`Secp256k1` items) are behind the `secp256k1` flag
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] All tests pass: `cargo test -p alknet-secret --all-features`
|
||||||
|
- [ ] No compiler warnings: `cargo clippy -p alknet-secret --all-features`
|
||||||
|
- [ ] Formatting is clean: `cargo fmt -p alknet-secret -- --check`
|
||||||
|
- [ ] Every module, type, and method in the implementation matches the updated spec
|
||||||
|
- [ ] All sensitive types implement `Zeroize` with `#[zeroize(drop)]`
|
||||||
|
- [ ] `DerivedKey` is not `Clone` and private_key is zeroized
|
||||||
|
- [ ] `DerivedKey` serialization does not expose `private_key`
|
||||||
|
- [ ] Key cache works: TTL eviction, LRU eviction, cleared on Lock
|
||||||
|
- [ ] `SecretServiceActor` processes all `SecretMessage` variants correctly
|
||||||
|
- [ ] BIP39, SLIP-0010, and AES-256-GCM test vectors pass
|
||||||
|
- [ ] `secp256k1` feature flag gates Ethereum derivation correctly
|
||||||
|
- [ ] `alknet-secret` has no dependency on `alknet-core` or `alknet-storage`
|
||||||
|
- [ ] `EncryptedData` JSON serialization matches the spec format
|
||||||
|
- [ ] `SecretProtocol` / `SecretMessage` types are correctly structured for irpc
|
||||||
|
- [ ] Public API (`lib.rs` re-exports) matches the spec's crate interface section
|
||||||
|
- [ ] Documentation comments on all public items
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- docs/architecture/secret-service.md — Updated spec
|
||||||
|
- docs/architecture/decisions/038-seed-lifecycle-memory-security.md — ADR-038
|
||||||
|
- docs/architecture/decisions/027-crate-decomposition.md — ADR-027
|
||||||
|
- All implementation tasks in this task group
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
> This review should be thorough but should not block on minor documentation phrasing. Focus on: security properties (zeroize), spec conformance (does the code match what the spec says), and integration readiness (can Phase A wire this crate to alknet-core and alknet-storage?).
|
||||||
|
|
||||||
|
> If any deviations between spec and implementation are found, document them and create follow-up tasks rather than fixing them during review.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
> To be filled on completion
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
---
|
||||||
|
id: secp256k1-ethereum-derivation
|
||||||
|
name: Add BIP-0032 secp256k1 derivation for Ethereum keys behind feature flag
|
||||||
|
status: pending
|
||||||
|
depends_on: [spec-update-secret-service, derivedkey-zeroize-security]
|
||||||
|
scope: narrow
|
||||||
|
risk: low
|
||||||
|
impact: component
|
||||||
|
level: implementation
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
The `SecretProtocol::DeriveEthereumKey` variant exists in the protocol and `SecretServiceHandle::derive_ethereum_key()` exists in the service, but the current implementation uses the same SLIP-0010 Ed25519 derivation for all key types. This is incorrect.
|
||||||
|
|
||||||
|
BIP-0032 secp256k1 derivation (used for Ethereum at path `m/44'/60'/0'/0/0`) is fundamentally different from SLIP-0010 Ed25519:
|
||||||
|
|
||||||
|
- **SLIP-0010** uses hardened-only derivation with HMAC-SHA512, producing Ed25519 keys
|
||||||
|
- **BIP-0032** supports both hardened and unhardened derivation for secp256k1 (the Ethereum path has unhardened indices: `/0/0` at the end)
|
||||||
|
- The master key derivation algorithm is different (HMAC-SHA512 with "Bitcoin seed" vs "ed25519 seed")
|
||||||
|
- The key format is different (secp256k1 private key + compressed public key)
|
||||||
|
|
||||||
|
The Ethereum path `m/44'/60'/0'/0/0` has **unhardened** indices at positions 4 and 5 (the last two `0`s), which SLIP-0010 does not support (SLIP-0010 requires all indices to be hardened).
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
1. Add `libsecp256k1` as an optional dependency behind a `secp256k1` feature flag:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[features]
|
||||||
|
secp256k1 = ["dep:libsecp256k1"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
libsecp256k1 = { version = "0.7", optional = true }
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Add a `ethereum.rs` module (behind `secp256k1` feature flag) that implements BIP-0032 secp256k1 derivation:
|
||||||
|
- `derive_secp256k1_master_key(seed: &[u8]) -> Result<Secp256k1ExtendedKey, DerivationError>`
|
||||||
|
- `derive_secp256k1_path(seed: &[u8], path: &str]) -> Result<ExtendedPrivKey, DerivationError>`
|
||||||
|
- This uses HMAC-SHA512 with key "Bitcoin seed" (different from SLIP-0010's "ed25519 seed")
|
||||||
|
- Supports both hardened (≥ 0x80000000) and unhardened indices
|
||||||
|
|
||||||
|
3. Update `SecretServiceHandle::derive_ethereum_key()` (behind `secp256k1` feature flag):
|
||||||
|
- When feature is enabled, use BIP-0032 derivation for paths starting with `m/44'`
|
||||||
|
- Return `DerivedKey { key_type: KeyType::Secp256k1, private_key, public_key }` where public_key is compressed (33 bytes)
|
||||||
|
- When feature is NOT enabled, return `SecretServiceError::UnsupportedKeyType`
|
||||||
|
|
||||||
|
4. Update `DerivationError` to include `Secp256k1(String)` and `UnsupportedKeyType` variants.
|
||||||
|
|
||||||
|
5. The `parse_derivation_path` function in `derivation.rs` already supports unhardened indices (it correctly parses `/0/0` at the end). The dispatch to SLIP-0010 vs BIP-0032 should be based on the path's coin type or an explicit parameter, not automatic path detection. Use an explicit method: `derive_ethereum_key()` always uses BIP-0032.
|
||||||
|
|
||||||
|
**Current bug**: The existing `derive_ethereum_key` method calls `derive_path_from_seed` which uses SLIP-0010 (Ed25519 only). The path `m/44'/60'/0'/0/0` has unhardened indices that SLIP-0010 doesn't handle correctly. This must be fixed.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] `libsecp256k1` dependency added behind `secp256k1` feature flag in `Cargo.toml`
|
||||||
|
- [ ] `ethereum.rs` module added (behind `secp256k1` feature flag) with BIP-0032 secp256k1 derivation
|
||||||
|
- [ ] `derive_secp256k1_master_key()` uses HMAC-SHA512 with "Bitcoin seed" key
|
||||||
|
- [ ] `derive_secp256k1_path()` supports both hardened and unhardened indices
|
||||||
|
- [ ] `SecretServiceHandle::derive_ethereum_key()` uses BIP-0032 derivation when `secp256k1` feature is enabled
|
||||||
|
- [ ] `SecretServiceHandle::derive_ethereum_key()` returns `UnsupportedKeyType` error when `secp256k1` feature is disabled
|
||||||
|
- [ ] `SecretServiceHandle::derive_ed25519()` still uses SLIP-0010 (unchanged behavior)
|
||||||
|
- [ ] `SecretServiceHandle::derive_encryption_key()` still uses SLIP-0010 (unchanged behavior)
|
||||||
|
- [ ] `derive_ethereum_key()` returns `DerivedKey { key_type: Secp256k1, private_key: 32-byte-secp256k1-key, public_key: 33-byte-compressed-point }`
|
||||||
|
- [ ] `DerivationError` gains `Secp256k1(String)` and `UnsupportedKeyType` variants
|
||||||
|
- [ ] `lib.rs` conditionally exports `ethereum` module and `Secp256k1`-related types
|
||||||
|
- [ ] Unit test: Ethereum derivation at path `m/44'/60'/0'/0/0` produces a valid secp256k1 keypair (behind `secp256k1` feature)
|
||||||
|
- [ ] Unit test: Ethereum derivation produces different keys from Ed25519 derivation at the same seed
|
||||||
|
- [ ] Unit test: Calling `derive_ethereum_key()` without `secp256k1` feature returns `UnsupportedKeyType`
|
||||||
|
- [ ] Existing Ed25519 and encryption key derivation tests still pass
|
||||||
|
- [ ] BIP-0032 known test vector: known seed → known secp256k1 master key (if available)
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- docs/architecture/secret-service.md — secp256k1 derivation note (after spec update)
|
||||||
|
- crates/alknet-secret/src/derivation.rs — Current SLIP-0010 only derivation
|
||||||
|
- crates/alknet-secret/src/service.rs — derive_ethereum_key (currently uses SLIP-0010, needs fix)
|
||||||
|
- BIP-0032: https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki
|
||||||
|
- Ethereum path EIP-84: `m/44'/60'/0'/0/0`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
> This task should be done after `derivedkey-zeroize-security` since `derive_ethereum_key` returns `DerivedKey` which will have the zeroize changes applied.
|
||||||
|
|
||||||
|
> The `secp256k1` crate in Rust is `libsecp256k1` (crate name `secp256k1`). The `ed25519-bip32` crate handles SLIP-0010 Ed25519. These are different algorithms and must not be mixed.
|
||||||
|
|
||||||
|
> For the Ethereum public key: BIP-0032 secp256k1 produces 33-byte compressed public keys. The `public_key` field in `DerivedKey` is `Vec<u8>`, so 33 bytes is fine. Document this size difference from Ed25519 (32-byte public keys).
|
||||||
|
|
||||||
|
> Consider whether `derive_path_from_seed` should be renamed to `derive_ed25519_path_from_seed` for clarity, since it's specifically SLIP-0010 Ed25519. The public API (`derive_ed25519`, `derive_encryption_key`, `derive_ethereum_key`) already makes this distinction, but the internal function name could be clearer.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
> To be filled on completion
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
---
|
||||||
|
id: spec-update-secret-service
|
||||||
|
name: Update secret-service.md spec to close implementation-identified gaps
|
||||||
|
status: pending
|
||||||
|
depends_on: []
|
||||||
|
scope: narrow
|
||||||
|
risk: low
|
||||||
|
impact: phase
|
||||||
|
level: implementation
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Update `docs/architecture/secret-service.md` to address seven gaps identified during the architect's initial implementation of alknet-secret. The tests pass and the code isn't "wrong," but the spec needs to be brought into alignment with what was learned and with what the implementation needs to be complete.
|
||||||
|
|
||||||
|
This is a **spec-first** task — no code changes here, just documentation. The spec must be complete enough that the subsequent implementation tasks can be picked up without ambiguity.
|
||||||
|
|
||||||
|
### Gaps to Close
|
||||||
|
|
||||||
|
1. **irpc integration undefined** (biggest gap): The spec says `SecretProtocol` uses `#[rpc_requests(message = SecretMessage)]` but doesn't define the concrete wiring. How does a local `SecretServiceHandle` relate to the irpc protocol? What's the `Client<SecretProtocol>` type? Where does the service run (in-process mpsc vs remote QUIC)? The current code has `pub type SecretMessage = SecretProtocol;` as a placeholder.
|
||||||
|
|
||||||
|
2. **Key caching strategy**: The security model table says "derived keys cached in RAM" but never specifies: what's the cache key? What's the TTL? Is it LRU? OQ-SVC-04 was resolved "yes, with TTL default 1 hour" but the spec doesn't reflect this resolution. Current implementation has no caching at all.
|
||||||
|
|
||||||
|
3. **`DerivedKey` security properties**: The struct carries `private_key: Vec<u8>` but doesn't derive `Zeroize`. ADR-038 says all sensitive material must zeroize. The spec should specify this.
|
||||||
|
|
||||||
|
4. **`DerivePassword` specification**: The `SecretProtocol::DerivePassword` variant exists but has no specification for how deterministic password derivation works: what hash function? What encoding? What character set?
|
||||||
|
|
||||||
|
5. **secp256k1/Ethereum derivation**: `DeriveEthereumKey` is in the protocol but the spec doesn't acknowledge that BIP-0032 secp256k1 (path `m/44'/60'/0'/0/0`) requires a fundamentally different derivation algorithm and crate than SLIP-0010 Ed25519.
|
||||||
|
|
||||||
|
6. **Test vectors requirement**: A crypto crate needs known-answer tests against published BIP39/SLIP-0010 test vectors. The spec doesn't reference any.
|
||||||
|
|
||||||
|
7. **`EncryptedData.salt` purpose**: The salt field is generated and stored but not used in key derivation. The spec should either specify that the salt is used in a KDF (PBKDF2/HKDF) to derive the AES key, or document it as reserved for future KDF-based key rotation.
|
||||||
|
|
||||||
|
### Changes to Make
|
||||||
|
|
||||||
|
In `docs/architecture/secret-service.md`:
|
||||||
|
|
||||||
|
- **Section: SecretProtocol irpc Service** — Replace the `SecretProtocol` enum definition with one that includes the irpc integration model: define `SecretServiceHandle` (local, in-process) vs `SecretProtocol` irpc client (remote), clarify the two dispatch paths, and specify that `SecretMessage` is the irpc wire type (generated by `#[rpc_requests]`).
|
||||||
|
|
||||||
|
- **Section: Security Model** — Add a "Key Caching" subsection specifying: derivation path as cache key, TTL of 1 hour (configurable), LRU eviction, cache cleared on `Lock`. Per OQ-SVC-04 resolution.
|
||||||
|
|
||||||
|
- **Section: Key Derivation** — Add a note after the `DerivedKey` struct that `private_key` must derive `Zeroize` per ADR-038. This means `DerivedKey` cannot use `#[derive(Clone)]` directly on the private key field; it needs a custom implementation that zeroizes the source on clone.
|
||||||
|
|
||||||
|
- **Section: Key Derivation** (after derivation paths table) — Add a "Password Derivation" subsection specifying: `DerivePassword` uses HMAC-SHA512 at the derivation path, truncates to `length` bytes, and encodes as Base64url (no special character set in v1). The path format is `m/74'/1'/0'/{hash}'` where `{hash}'` is a site-specific hardened index.
|
||||||
|
|
||||||
|
- **Section: Key Derivation** (after Ethereum path) — Add a "secp256k1 Derivation" note: `DeriveEthereumKey` uses BIP-0032 (not SLIP-0010) at path `m/44'/60'/0'/0/0`, requiring the `libsecp256k1` crate. This is a different derivation algorithm from Ed25519. The `alknet-secret` crate should gate this behind a `secp256k1` feature flag.
|
||||||
|
|
||||||
|
- **Section: AES-256-GCM Encryption** — Specify that `EncryptedData.salt` is currently 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 a salt-based KDF. The `salt` field is included for forward compatibility: when key rotation is implemented, the salt will be used as input to HKDF or PBKDF2 for stretch-based key derivation. For now, the salt is random and stored but not used in key derivation.
|
||||||
|
|
||||||
|
- **New Section: Test Vectors** — Require known-answer tests against:
|
||||||
|
- BIP39 test vectors (mnemonic → seed)
|
||||||
|
- SLIP-0010 test vectors (seed → master key, master key → child key at `m/74'/0'/0'/0'`)
|
||||||
|
- IEEE P802.1ASck test vectors for AES-256-GCM (or equivalent published vectors)
|
||||||
|
|
||||||
|
- **Section: Open Questions** — Mark OQ-SVC-04 as resolved (key caching: yes, TTL default 1 hour, LRU eviction). Add note about OQ-SVC-03 (EncryptedData compatibility: wire format stable, migration path is re-encrypt with new key version).
|
||||||
|
|
||||||
|
- **Dependencies** section — Add `libsecp256k1` (behind `secp256k1` feature flag) and `hkdf`/`pbkdf2` (deferred, for Phase B KDF — listed as future dependency, not current). Update `irpc` line to clarify it's required but the integration model needs specification.
|
||||||
|
|
||||||
|
- **Crate Structure** — Update to include `cache.rs` module for key caching.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] `secret-service.md` has a new subsection on irpc integration (local SecretServiceHandle vs remote SecretProtocol client, dispatch paths)
|
||||||
|
- [ ] `secret-service.md` has a "Key Caching" subsection specifying derivation path as cache key, 1-hour TTL, LRU eviction, cleared on Lock
|
||||||
|
- [ ] `secret-service.md` notes that `DerivedKey.private_key` must derive `Zeroize` per ADR-038
|
||||||
|
- [ ] `secret-service.md` has a "Password Derivation" subsection specifying HMAC-SHA512 → Base64url encoding
|
||||||
|
- [ ] `secret-service.md` has a "secp256k1 Derivation" note specifying BIP-0032 algorithm and `secp256k1` feature flag
|
||||||
|
- [ ] `secret-service.md` specifies that `EncryptedData.salt` is reserved for future KDF-based key rotation, not used in v1 key derivation
|
||||||
|
- [ ] `secret-service.md` has a "Test Vectors" section requiring BIP39, SLIP-0010, and AES-256-GCM known-answer tests
|
||||||
|
- [ ] OQ-SVC-04 is marked as resolved in `secret-service.md`
|
||||||
|
- [ ] Dependencies section updated with secp256k1 (feature-gated) and noted future KDF deps
|
||||||
|
- [ ] Crate structure diagram updated to include `cache.rs`
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- docs/architecture/secret-service.md — The spec being updated
|
||||||
|
- docs/architecture/decisions/038-seed-lifecycle-memory-security.md — ADR-038 (zeroize requirement)
|
||||||
|
- docs/architecture/decisions/027-crate-decomposition.md — ADR-027 (crate independence)
|
||||||
|
- docs/research/services.md — SecretProtocol definition source
|
||||||
|
- crates/alknet-secret/ — Current implementation (reference for what exists)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
> This is a spec-only task. No code changes. The goal is to make the spec complete enough that subsequent implementation tasks have zero ambiguity about what to build.
|
||||||
|
|
||||||
|
> The architect's message (msg_eacbd63b2001DsTCHZn04meEpB) identified exactly these 7 gaps. This task closes them in the spec so that the implementation tasks can be done against a single source of truth.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
> To be filled on completion
|
||||||
Reference in New Issue
Block a user