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

259
Cargo.lock generated
View File

@@ -136,7 +136,10 @@ dependencies = [
"ed25519-bip32", "ed25519-bip32",
"hex", "hex",
"hmac", "hmac",
"irpc",
"irpc-derive",
"rand 0.8.6", "rand 0.8.6",
"secp256k1",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
@@ -793,6 +796,15 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "convert_case"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
dependencies = [
"unicode-segmentation",
]
[[package]] [[package]]
name = "convert_case" name = "convert_case"
version = "0.11.0" version = "0.11.0"
@@ -1102,7 +1114,16 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05"
dependencies = [ dependencies = [
"derive_more-impl", "derive_more-impl 1.0.0",
]
[[package]]
name = "derive_more"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
dependencies = [
"derive_more-impl 2.1.1",
] ]
[[package]] [[package]]
@@ -1117,6 +1138,20 @@ dependencies = [
"unicode-xid", "unicode-xid",
] ]
[[package]]
name = "derive_more-impl"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
"convert_case 0.10.0",
"proc-macro2",
"quote",
"rustc_version",
"syn",
"unicode-xid",
]
[[package]] [[package]]
name = "des" name = "des"
version = "0.8.1" version = "0.8.1"
@@ -1281,6 +1316,17 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "enum-assoc"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ed8956bd5c1f0415200516e78ff07ec9e16415ade83c056c230d7b7ea0d55b7"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "enumflags2" name = "enumflags2"
version = "0.7.12" version = "0.7.12"
@@ -1621,11 +1667,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi 6.0.0", "r-efi 6.0.0",
"rand_core 0.10.1", "rand_core 0.10.1",
"wasip2", "wasip2",
"wasip3", "wasip3",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -2078,6 +2126,12 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "identity-hash"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfdd7caa900436d8f13b2346fe10257e0c05c1f1f9e351f4f5d57c03bd5f45da"
[[package]] [[package]]
name = "idna" name = "idna"
version = "1.1.0" version = "1.1.0"
@@ -2195,7 +2249,7 @@ dependencies = [
"crypto_box", "crypto_box",
"data-encoding", "data-encoding",
"der", "der",
"derive_more", "derive_more 1.0.0",
"ed25519-dalek", "ed25519-dalek",
"futures-util", "futures-util",
"hickory-resolver", "hickory-resolver",
@@ -2208,7 +2262,7 @@ dependencies = [
"iroh-quinn-proto", "iroh-quinn-proto",
"iroh-quinn-udp", "iroh-quinn-udp",
"iroh-relay", "iroh-relay",
"n0-future", "n0-future 0.1.3",
"netdev", "netdev",
"netwatch", "netwatch",
"pin-project", "pin-project",
@@ -2246,7 +2300,7 @@ checksum = "3cd952d9e25e521d6aeb5b79f2fe32a0245da36aae3569e50f6010b38a5f0923"
dependencies = [ dependencies = [
"curve25519-dalek", "curve25519-dalek",
"data-encoding", "data-encoding",
"derive_more", "derive_more 1.0.0",
"ed25519-dalek", "ed25519-dalek",
"rand_core 0.6.4", "rand_core 0.6.4",
"serde", "serde",
@@ -2332,7 +2386,7 @@ dependencies = [
"bytes", "bytes",
"cfg_aliases", "cfg_aliases",
"data-encoding", "data-encoding",
"derive_more", "derive_more 1.0.0",
"hickory-resolver", "hickory-resolver",
"http 1.4.1", "http 1.4.1",
"http-body-util", "http-body-util",
@@ -2343,7 +2397,7 @@ dependencies = [
"iroh-quinn", "iroh-quinn",
"iroh-quinn-proto", "iroh-quinn-proto",
"lru", "lru",
"n0-future", "n0-future 0.1.3",
"num_enum", "num_enum",
"pin-project", "pin-project",
"pkarr", "pkarr",
@@ -2366,6 +2420,39 @@ dependencies = [
"z32", "z32",
] ]
[[package]]
name = "irpc"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a05799eb70acd04843c327ef939233ccf80f607d30e9ca94857ac7f3d8f18b46"
dependencies = [
"futures-buffered",
"futures-util",
"irpc-derive",
"n0-error",
"n0-future 0.3.2",
"noq",
"postcard",
"rcgen 0.14.8",
"rustls",
"serde",
"smallvec",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "irpc-derive"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "445d81dbc1eed4dab6379bf7f97d12ac28ce8e6f3f7d6660c9f333b7b5d8d03b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "is_terminal_polyfill" name = "is_terminal_polyfill"
version = "1.70.2" version = "1.70.2"
@@ -2575,6 +2662,27 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "n0-error"
version = "1.0.0-rc.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "223e946a84aa91644507a6b7865cfebbb9a231ace499041c747ab0fd30408212"
dependencies = [
"n0-error-macros",
"spez",
]
[[package]]
name = "n0-error-macros"
version = "1.0.0-rc.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "565305a21e6b3bf26640ad98f05a0fda12d3ab4315394566b52a7bddb8b34828"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "n0-future" name = "n0-future"
version = "0.1.3" version = "0.1.3"
@@ -2582,7 +2690,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7bb0e5d99e681ab3c938842b96fcb41bf8a7bb4bfdb11ccbd653a7e83e06c794" checksum = "7bb0e5d99e681ab3c938842b96fcb41bf8a7bb4bfdb11ccbd653a7e83e06c794"
dependencies = [ dependencies = [
"cfg_aliases", "cfg_aliases",
"derive_more", "derive_more 1.0.0",
"futures-buffered",
"futures-lite",
"futures-util",
"js-sys",
"pin-project",
"send_wrapper",
"tokio",
"tokio-util",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-time",
]
[[package]]
name = "n0-future"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe"
dependencies = [
"cfg_aliases",
"derive_more 2.1.1",
"futures-buffered", "futures-buffered",
"futures-lite", "futures-lite",
"futures-util", "futures-util",
@@ -2634,7 +2763,7 @@ version = "3.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89b3f766e04667e6da0e181e2da4f85475d5a6513b7cf6a80bea184e224a5b42" checksum = "89b3f766e04667e6da0e181e2da4f85475d5a6513b7cf6a80bea184e224a5b42"
dependencies = [ dependencies = [
"convert_case", "convert_case 0.11.0",
"ctor", "ctor",
"napi-derive-backend", "napi-derive-backend",
"proc-macro2", "proc-macro2",
@@ -2648,7 +2777,7 @@ version = "5.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d5af30503edf933ce7377cf6d4c877a62b0f1107ea05585f1b5e430e88d5baf" checksum = "0d5af30503edf933ce7377cf6d4c877a62b0f1107ea05585f1b5e430e88d5baf"
dependencies = [ dependencies = [
"convert_case", "convert_case 0.11.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"semver", "semver",
@@ -2768,11 +2897,11 @@ dependencies = [
"atomic-waker", "atomic-waker",
"bytes", "bytes",
"cfg_aliases", "cfg_aliases",
"derive_more", "derive_more 1.0.0",
"iroh-quinn-udp", "iroh-quinn-udp",
"js-sys", "js-sys",
"libc", "libc",
"n0-future", "n0-future 0.1.3",
"netdev", "netdev",
"netlink-packet-core", "netlink-packet-core",
"netlink-packet-route 0.19.0", "netlink-packet-route 0.19.0",
@@ -2836,6 +2965,68 @@ dependencies = [
"minimal-lexical", "minimal-lexical",
] ]
[[package]]
name = "noq"
version = "1.0.0-rc.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "198b99fc085a5db1f7d259edb5ede8311e59f28cdd2687920b4313613d21a73f"
dependencies = [
"bytes",
"cfg_aliases",
"derive_more 2.1.1",
"noq-proto",
"noq-udp",
"pin-project-lite",
"rustc-hash",
"rustls",
"socket2 0.6.4",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"tracing",
"web-time",
]
[[package]]
name = "noq-proto"
version = "1.0.0-rc.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ab0ac774795ce1e42a7e61266e71f3be8110210630441169ac8dda403dd23f1"
dependencies = [
"aes-gcm",
"bytes",
"derive_more 2.1.1",
"enum-assoc",
"getrandom 0.4.2",
"identity-hash",
"lru-slab",
"rand 0.10.1",
"rand_pcg",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"sorted-index-buffer",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "noq-udp"
version = "1.0.0-rc.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3c1520eacd33fd6b009e2e70116b05508ade51db5e0d315ff8bf6b702148c2b"
dependencies = [
"cfg_aliases",
"libc",
"socket2 0.6.4",
"tracing",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@@ -3328,7 +3519,7 @@ checksum = "247dcb75747c53cc433d6d8963a064187eec4a676ba13ea33143f1c9100e754f"
dependencies = [ dependencies = [
"base64", "base64",
"bytes", "bytes",
"derive_more", "derive_more 1.0.0",
"futures-lite", "futures-lite",
"futures-util", "futures-util",
"igd-next", "igd-next",
@@ -3652,6 +3843,15 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]] [[package]]
name = "rcgen" name = "rcgen"
version = "0.13.2" version = "0.13.2"
@@ -4179,6 +4379,24 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "secp256k1"
version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
dependencies = [
"secp256k1-sys",
]
[[package]]
name = "secp256k1-sys"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "self_cell" name = "self_cell"
version = "1.2.2" version = "1.2.2"
@@ -4387,6 +4605,23 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "sorted-index-buffer"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea06cc588e43c632923a55450401b8f25e628131571d4e1baea1bdfdb2b5ed06"
[[package]]
name = "spez"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "spin" name = "spin"
version = "0.9.8" version = "0.9.8"

View File

@@ -11,4 +11,8 @@ resolver = "2"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
repository = "https://git.alk.dev/alkdev/alknet" repository = "https://git.alk.dev/alkdev/alknet"
[workspace.dependencies]
irpc = "0.16"
irpc-derive = "0.16"

View File

@@ -9,6 +9,10 @@ repository.workspace = true
[lib] [lib]
name = "alknet_secret" name = "alknet_secret"
[features]
default = []
secp256k1 = ["dep:secp256k1"]
[dependencies] [dependencies]
bip39 = { version = "2", features = ["rand"] } bip39 = { version = "2", features = ["rand"] }
ed25519-bip32 = "0.4" ed25519-bip32 = "0.4"
@@ -21,6 +25,9 @@ zeroize = { version = "1", features = ["derive"] }
hmac = "0.12" hmac = "0.12"
rand = "0.8" rand = "0.8"
base64 = "0.22" base64 = "0.22"
irpc = { workspace = true }
irpc-derive = { workspace = true }
secp256k1 = { version = "0.29", optional = true }
[dev-dependencies] [dev-dependencies]
hex = "0.4" hex = "0.4"

View File

@@ -1,6 +1,6 @@
--- ---
id: encryption-salt-kdf 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 status: pending
depends_on: [spec-update-secret-service] depends_on: [spec-update-secret-service]
scope: narrow 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 5. Encrypt with AES-256-GCM using the derived key + random IV
6. Store `{key_version, salt, iv, data}` as `EncryptedData` 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) **Decision: Option B — Document salt as reserved.** The spec update has already made this decision. This task implements Option B only.
- 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:** ## Implementation (Option B only)
### Option A: Use HKDF for key derivation (recommended for v1) 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.
Replace the direct "first 32 bytes of derived key" approach with: 3. Add a `// TODO(Phase B): Use salt in HKDF-based key derivation` comment on the salt generation in `encrypt()`.
1. Derive master key from seed at path `m/74'/2'/0'/0'` 4. No code behavior changes — existing tests must pass unchanged.
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 ## Acceptance Criteria
**If Option A (HKDF — recommended):** - [ ] `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
- [ ] `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()` - [ ] `// 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 ## 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 - crates/alknet-secret/src/encryption.rs — Current encrypt/decrypt implementation
- HKDF (RFC 5869): https://tools.ietf.org/html/rfc5869
## Notes ## 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 ## 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. 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. 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: **Key irpc concepts:**
- `DeriveEd25519 { path: String, tx: oneshot::Sender<DerivedKey> }``SecretMessage::DeriveEd25519` - `#[rpc_requests(message = SecretMessage)]` on the `SecretProtocol` enum generates a `SecretMessage` enum where each variant wraps the inner type in `WithChannels<Inner, SecretProtocol>`
- `DeriveEncryptionKey { path: String, tx: oneshot::Sender<DerivedKey> }` - Each variant annotated with `#[rpc(tx=oneshot::Sender<T>)]` gets a `oneshot::Sender<T>` channel for responses
- `DeriveEthereumKey { path: String, tx: oneshot::Sender<DerivedKey> }` - `Client<SecretProtocol>` is the client type that can send messages locally (via mpsc) or remotely (via noq/QUIC)
- `DerivePassword { path: String, length: usize, tx: oneshot::Sender<Vec<u8>> }` - The `rpc` feature in irpc is enabled by default and includes the remote transport (postcard + noq)
- `Encrypt { plaintext: String, key_version: u32, tx: oneshot::Sender<EncryptedData> }` - The `derive` feature in irpc enables the `#[rpc_requests]` macro
- `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. **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: ### What needs to happen
- 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. 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 ## Acceptance Criteria
- [ ] `irpc` dependency added to `Cargo.toml` (or `SecretMessage` defined manually if irpc doesn't exist yet) - [ ] `irpc` and `irpc-derive` added as workspace dependencies in root `Cargo.toml`
- [ ] `SecretMessage` enum defined with oneshot channels for each `SecretProtocol` variant's response type - [ ] `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` struct that wraps `SecretServiceHandle` and processes `SecretMessage` variants
- [ ] `SecretServiceActor::run()` method that spawns a message loop as a `tokio::task` - [ ] `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 - [ ] `SecretServiceActor::spawn()` method that returns a `Client<SecretProtocol>` for sending messages
- [ ] Each `SecretMessage` variant dispatches to the corresponding `SecretServiceHandle` method - [ ] 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) - [ ] `SecretServiceHandle` remains the primary local API (RwLock-based, unchanged for direct use)
- [ ] Unit test: `SecretServiceActor` processes `SecretMessage::Unlock` and responds successfully - [ ] Unit test: `SecretServiceActor` processes `SecretMessage::Unlock` and responds successfully
- [ ] Unit test: `SecretMessage::DeriveEd25519` dispatched through actor returns `DerivedKey` - [ ] Unit test: `SecretMessage::DeriveEd25519` dispatched through actor returns `DerivedKey`
- [ ] Unit test: `SecretMessage::Lock` clears state and subsequent derive calls fail - [ ] 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 - [ ] `protocol.rs` updated: `SecretMessage` is the irpc-generated message type, not a type alias
- [ ] `lib.rs` re-exports updated to include `SecretServiceActor` and `SecretMessage` - [ ] `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 ## 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) - 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/protocol.rs — Current SecretProtocol with placeholder SecretMessage
- crates/alknet-secret/src/service.rs — SecretServiceHandle and SecretService - 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 ## 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. > 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 ## Summary
> To be filled on completion > 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:** **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 ```toml
[features] [features]
secp256k1 = ["dep:libsecp256k1"] secp256k1 = ["dep:secp256k1"]
[dependencies] [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: 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_master_key(seed: &[u8]) -> Result<Secp256k1ExtendedKey, DerivationError>`
- `derive_secp256k1_path(seed: &[u8], path: &str]) -> Result<ExtendedPrivKey, 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 ## 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 - [ ] `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_master_key()` uses HMAC-SHA512 with "Bitcoin seed" key
- [ ] `derive_secp256k1_path()` supports both hardened and unhardened indices - [ ] `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. > 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). > 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).