chore: prep Phase 3 tasks and workspace for alknet-secret development

- Add irpc (0.16) and irpc-derive (0.16) as workspace dependencies
- Add irpc, irpc-derive, and secp256k1 (optional) to alknet-secret Cargo.toml
- Clarify encryption-salt-kdf task: Option B (document salt as reserved) is the
  chosen path per spec update, removing Option A acceptance criteria
- Update irpc-secret-protocol-integration task with concrete irpc crate details:
  real crate on crates.io v0.16, #[rpc_requests] macro, workspace config,
  AuthProtocol pattern reference, DerivedKey serialization considerations
- Fix secp256k1-ethereum-derivation task: correct crate name is secp256k1
  (not libsecp256k1), add version pin 0.29
This commit is contained in:
2026-06-10 05:57:27 +00:00
parent 9ec7627d80
commit 83ea66b5d1
6 changed files with 378 additions and 113 deletions

View File

@@ -1,6 +1,6 @@
---
id: encryption-salt-kdf
name: Clarify and fix EncryptedData salt usage — use HKDF for key derivation or document as reserved
name: Document EncryptedData salt as reserved for future KDF-based key derivation
status: pending
depends_on: [spec-update-secret-service]
scope: narrow
@@ -20,83 +20,34 @@ The `EncryptedData` struct has a `salt` field that is generated randomly during
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:
The salt is stored but serves no purpose. The spec update (spec-update-secret-service) resolves this by documenting the salt as 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. HKDF-based key derivation is deferred to Phase B.
- 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
**Decision: Option B — Document salt as reserved.** The spec update has already made this decision. This task implements Option B only.
**The spec update (spec-update-secret-service task) decides one of two paths:**
## Implementation (Option B only)
### 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
1. Add documentation to `encryption.rs` explaining that the `salt` field in `EncryptedData` is reserved for future KDF-based key derivation (Phase B). In v1, the encryption key is derived directly from the seed at path `m/74'/2'/0'/0'` without using the salt.
2. Add a doc comment on the `EncryptedData.salt` field explaining its reserved purpose and that it is not used in v1 key derivation.
3. Add a `// TODO(Phase B): Use salt in HKDF-based key derivation` comment on the salt generation in `encrypt()`.
4. No code behavior changes — existing tests must pass unchanged.
## 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
- [ ] `encryption.rs` module-level documentation explains that the salt field is reserved for future KDF-based key derivation
- [ ] `EncryptedData` struct has doc comment on `salt` field explaining reserved purpose and that it is not used in v1 key derivation
- [ ] `// TODO(Phase B)` comment on salt generation in `encrypt()`
- [ ] No behavior changes — existing tests pass unchanged
- [ ] No behavior changes — all existing tests pass unchanged
## References
- docs/architecture/secret-service.md — Encryption section (after spec update)
- docs/architecture/secret-service.md — Encryption section (after spec update, which specifies "salt is reserved for future KDF-based key rotation")
- 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 spec update task already decided on Option B. HKDF-based key derivation is deferred to Phase B. This task only documents the salt as reserved and adds TODO comments.
> 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.
> 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." The spec update chose "document as reserved" for v1.
## Summary

View File

@@ -16,54 +16,117 @@ The `SecretProtocol` enum in `protocol.rs` currently has a placeholder `SecretMe
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.
Per ADR-027, irpc is always-on in alknet-secret (not feature-gated). Per ADR-033, irpc is one dispatch backend for OperationEnv.
**What needs to happen:**
### irpc Crate Details
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.
The `irpc` crate (version 0.16.0 on crates.io) provides the `#[rpc_requests]` derive macro that generates message enums with channel types. This is the same pattern used by n0/iroh projects.
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<()> }`
**Key irpc concepts:**
- `#[rpc_requests(message = SecretMessage)]` on the `SecretProtocol` enum generates a `SecretMessage` enum where each variant wraps the inner type in `WithChannels<Inner, SecretProtocol>`
- Each variant annotated with `#[rpc(tx=oneshot::Sender<T>)]` gets a `oneshot::Sender<T>` channel for responses
- `Client<SecretProtocol>` is the client type that can send messages locally (via mpsc) or remotely (via noq/QUIC)
- The `rpc` feature in irpc is enabled by default and includes the remote transport (postcard + noq)
- The `derive` feature in irpc enables the `#[rpc_requests]` macro
**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.
**Current pattern in alknet-core for reference:**
- `AuthProtocol` in alknet-core (`crates/alknet-core/src/auth/auth_protocol.rs`) is currently a plain enum with synchronous methods on `AuthServiceImpl` — it does NOT use `#[rpc_requests]` yet. The alknet-core irpc feature flag exists but is empty. This is because alknet-core's irpc integration hasn't been implemented yet.
- alknet-secret should use the actual `irpc` crate with `#[rpc_requests]` since it's the first crate to do the irpc integration properly.
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.
**Workspace configuration:**
- `irpc = "0.16"` needs to be added to the workspace `Cargo.toml` `[workspace.dependencies]` section
- `alknet-secret/Cargo.toml` needs `irpc = { workspace = true }` and `irpc-derive = { workspace = true }` (the derive macro is in a separate crate)
**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.
**irpc dependency requirements:**
- `irpc` with default features brings in `noq` (QUIC transport), `postcard` (serialization), and `tokio`. These are acceptable for alknet-secret.
- The `derive` feature is needed for `#[rpc_requests]`.
**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
### What needs to happen
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.
1. **Add irpc as a workspace dependency**: Add `irpc = "0.16"` and `irpc-derive = "0.16"` to the workspace `Cargo.toml` `[workspace.dependencies]` section. Add `irpc = { workspace = true }` and `irpc-derive = { workspace = true }` to `alknet-secret/Cargo.toml`.
**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.
2. **Replace `SecretMessage` type alias with irpc-generated type**: Apply `#[rpc_requests(message = SecretMessage)]` to `SecretProtocol` with appropriate `#[rpc(tx=oneshot::Sender<T>)]` attributes on each variant. This generates:
- `SecretMessage` enum with `WithChannels` wrappers
- `Channels<SecretProtocol>` impl for each variant type
- `From<VariantType> for SecretProtocol` impls
- `Service` and `RemoteService` impls for `SecretProtocol`
**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).
3. **Update SecretProtocol enum for irpc**: The current enum has plain variants like `DeriveEd25519 { path: String }`. With irpc's `#[wrap]` attribute, each variant gets a wrapper struct:
```rust
#[rpc_requests(message = SecretMessage)]
#[derive(Debug, Serialize, Deserialize)]
pub enum SecretProtocol {
#[rpc(tx=oneshot::Sender<DerivedKey>)]
#[wrap(DeriveEd25519)]
DeriveEd25519 { path: String },
#[rpc(tx=oneshot::Sender<DerivedKey>)]
#[wrap(DeriveEncryptionKey)]
DeriveEncryptionKey { path: String },
#[rpc(tx=oneshot::Sender<DerivedKey>)]
#[wrap(DeriveEthereumKey)]
DeriveEthereumKey { path: String },
#[rpc(tx=oneshot::Sender<Vec<u8>>)]
#[wrap(DerivePassword)]
DerivePassword { path: String, length: usize },
#[rpc(tx=oneshot::Sender<EncryptedData>)]
#[wrap(Encrypt)]
Encrypt { plaintext: String, key_version: u32 },
#[rpc(tx=oneshot::Sender<String>)]
#[wrap(Decrypt)]
Decrypt { encrypted: EncryptedData },
#[rpc(tx=oneshot::Sender<()>)]
#[wrap(Lock)]
Lock,
#[rpc(tx=oneshot::Sender<()>)]
#[wrap(Unlock)]
Unlock { passphrase: String },
}
```
4. **Create SecretServiceActor**: Wrap `SecretServiceHandle` in an actor that processes `SecretMessage` variants and sends responses through the oneshot channels. The actor runs as a `tokio::task`:
```rust
pub struct SecretServiceActor {
handle: SecretServiceHandle,
}
impl SecretServiceActor {
pub async fn run(mut self, mut rx: tokio::sync::mpsc::Receiver<SecretMessage>) { ... }
pub fn handle(&self) -> &SecretServiceHandle { &self.handle }
}
```
5. **Keep SecretServiceHandle as primary local API**: The `RwLock<SecretServiceInner>` pattern stays for direct in-process use. The actor wraps it for irpc dispatch.
6. **Update public API**: Re-export `SecretMessage`, `SecretServiceActor`, and `Client<SecretProtocol>` from `lib.rs`.
7. **Handle DerivedKey serialization for irpc**: Since `DerivedKey` will become non-Clone (per `derivedkey-zeroize-security` task) and needs custom serialization that redacts `private_key`, ensure the irpc wire format works correctly. The `#[wrap]` structs and `SecretMessage` need to serialize/deserialize `DerivedKey` — since irpc uses `postcard` for remote transport, the `Serialize`/`Deserialize` impls must handle the redacted `private_key` field appropriately. For local (mpsc) transport, `DerivedKey` is sent through oneshot channels without serialization, so the redacted serialization only matters for remote transport.
## 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
- [ ] `irpc` and `irpc-derive` added as workspace dependencies in root `Cargo.toml`
- [ ] `irpc` and `irpc-derive` added to `alknet-secret/Cargo.toml` as workspace dependencies
- [ ] `SecretProtocol` enum annotated with `#[rpc_requests(message = SecretMessage)]` and `#[rpc(tx=...)]` attributes
- [ ] `SecretMessage` is no longer a type alias — it's the irpc-generated message 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
- [ ] `SecretServiceActor::spawn()` method that returns a `Client<SecretProtocol>` for sending messages
- [ ] Each `SecretMessage` variant dispatches to the corresponding `SecretServiceHandle` method and sends response through oneshot channel
- [ ] `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`
- [ ] `protocol.rs` updated: `SecretMessage` is the irpc-generated message type, not a type alias
- [ ] `lib.rs` re-exports updated to include `SecretServiceActor` and `Client<SecretProtocol>`
- [ ] `cargo test -p alknet-secret` passes with all existing tests
- [ ] `cargo clippy -p alknet-secret -- -D warnings` passes
- [ ] `cargo fmt -p alknet-secret -- --check` passes
## References
@@ -72,16 +135,19 @@ Per ADR-027, irpc is always a dependency in alknet-secret (not feature-gated). P
- 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)
- irpc crate (crates.io v0.16) — `#[rpc_requests]` derive macro, `Client<S>` type, `WithChannels`, `Channels` trait
- crates/alknet-core/src/auth/auth_protocol.rs — AuthProtocol pattern (reference, but note: NOT using irpc yet)
## 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.
> The irpc crate is on crates.io at version 0.16.0. Use `irpc = "0.16"` and `irpc-derive = "0.16"` as workspace dependencies. Do NOT use a local path 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 `#[rpc_requests]` macro generates: (1) a `SecretMessage` enum with `WithChannels` wrappers for each variant, (2) `Channels<SecretProtocol>` impls, (3) `From` impls, (4) `Service` and `RemoteService` impls. See the irpc crate docs and examples for the exact generated code structure.
> 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.
> Since `DerivedKey` is becoming non-Clone with redacted serialization (per `derivedkey-zeroize-security`), the irpc integration needs to handle this. For local (mpsc) transport, `DerivedKey` moves through oneshot channels without serialization — no issue. For remote (postcard) transport, `DerivedKey` needs proper Serialize/Deserialize. The custom serialization should serialize `private_key` as bytes (not redacted) for postcard since it's a binary format used for in-cluster Rust-to-Rust communication — the redaction is for JSON/debug output only.
## Summary
> To be filled on completion

View File

@@ -24,16 +24,18 @@ The Ethereum path `m/44'/60'/0'/0/0` has **unhardened** indices at positions 4 a
**Implementation:**
1. Add `libsecp256k1` as an optional dependency behind a `secp256k1` feature flag:
1. Add the `secp256k1` crate (Rust bindings to libsecp256k1) as an optional dependency behind a `secp256k1` feature flag:
```toml
[features]
secp256k1 = ["dep:libsecp256k1"]
secp256k1 = ["dep:secp256k1"]
[dependencies]
libsecp256k1 = { version = "0.7", optional = true }
secp256k1 = { version = "0.29", optional = true }
```
**Note**: The Rust crate is named `secp256k1` on crates.io (it wraps the C library `libsecp256k1`). Do not use `libsecp256k1` — that is the C library name, not the Rust crate name.
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>`
@@ -53,7 +55,7 @@ The Ethereum path `m/44'/60'/0'/0/0` has **unhardened** indices at positions 4 a
## Acceptance Criteria
- [ ] `libsecp256k1` dependency added behind `secp256k1` feature flag in `Cargo.toml`
- [ ] `secp256k1` crate (Rust bindings to libsecp256k1) 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
@@ -82,7 +84,7 @@ The Ethereum path `m/44'/60'/0'/0/0` has **unhardened** indices at positions 4 a
> 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.
> The `secp256k1` crate on crates.io (version 0.29+) provides Rust bindings to the C library `libsecp256k1`. 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).