From 8012aa33d7ff4cb393ba6eb2ee23142e46b0af90 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 28 Jul 2026 22:59:54 -0700 Subject: [PATCH 1/4] chore: open #1759 lane (BIP-39 Chia-conformant master-seed derivation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lane stub so the branch + draft PR exist before implementation (§1.8 push-early). No behaviour change. Co-Authored-By: Claude From f82f2c8cdd524c065f7a3a9c7009f7ab62b09621 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 28 Jul 2026 23:11:37 -0700 Subject: [PATCH 2/4] feat(session)!: derive from BIP-39-expanded seed; versioned envelope; recovery_phrase() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A DIG 24-word recovery phrase previously restored to a DIFFERENT address than Sage and every other standard Chia wallet: the 32 stored bytes were handed straight to `SecretKey::from_seed`, skipping BIP-39's PBKDF2 expansion. That fails silently — the user sees a valid-looking phrase resolve to an empty account, with no error at all. The 32 stored bytes are now BIP-39 **entropy**, re-expanded `entropy -> 24 words -> to_seed("") -> SecretKey::from_seed` at `master_seed()`. Identity, per-profile DEK and wallet all read that one expanded seed, so no two of them can disagree about the root. At-rest length is unchanged (32 bytes IS the entropy of a 24-word mnemonic). A versioned envelope (`dig_keystore::opaque`, `version || kind || entropy`) makes a stored blob DECLARE what its bytes mean. The two encodings are byte-indistinguishable, so a pre-envelope raw-seed blob FAILS CLOSED with `SessionError::LegacySeedFormat` and is never reinterpreted as entropy. Adds `UnlockedMasterSeed::recovery_phrase()` over `&self` — showing a user their phrase must not cost them their session. BREAKING CHANGE: `SEED_LEN` is replaced by `ENTROPY_LEN` (32, stored) and `MASTER_SEED_LEN` (64, expanded); removing the ambiguous name is deliberate so a stale call site fails to COMPILE rather than deriving a silently wrong key. `master_seed()` now returns the 64-byte expanded seed. For a given 32 stored bytes this MOVES the identity public key and the profile DEK, which is sound only because the exposed population is zero (no deployed account store; the money path is parked unmerged). Refs DIG-Network/dig_ecosystem#1759 Co-Authored-By: Claude --- Cargo.lock | 2522 ++++++++++++++++++++++++++++++++----- Cargo.toml | 13 + src/envelope.rs | 149 +++ src/error.rs | 36 + src/lib.rs | 5 +- src/master_seed.rs | 285 +++-- src/session.rs | 101 +- tests/chia_conformance.rs | 384 ++++++ tests/master_seed.rs | 99 +- 9 files changed, 3099 insertions(+), 495 deletions(-) create mode 100644 src/envelope.rs create mode 100644 tests/chia_conformance.rs diff --git a/Cargo.lock b/Cargo.lock index b7046e6..24b1b87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,6 +37,21 @@ dependencies = [ "subtle", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + [[package]] name = "argon2" version = "0.5.3" @@ -49,6 +64,63 @@ dependencies = [ "password-hash", ] +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.1" @@ -61,6 +133,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64ct" version = "1.8.3" @@ -73,6 +151,45 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" +[[package]] +name = "bigdecimal" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "binascii" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383d29d513d8764dcdc42ea295d979eb99c3c9f00607b3692cf68a431f7dca72" + +[[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes", + "serde", + "unicode-normalization", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" +dependencies = [ + "hex-conservative", +] + [[package]] name = "bitflags" version = "2.13.1" @@ -97,7 +214,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -110,6 +227,15 @@ dependencies = [ "cty", ] +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -137,6 +263,18 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cc" version = "1.3.0" @@ -159,6 +297,27 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chia" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b27699b864f74bfe17216295e445db63f1aedbe3be9560833009a5890ce5d46d" +dependencies = [ + "chia-bls 0.26.0", + "chia-client", + "chia-consensus", + "chia-protocol", + "chia-puzzle-types", + "chia-secp", + "chia-serde", + "chia-sha2 0.26.0", + "chia-ssl", + "chia-traits 0.26.0", + "clvm-traits", + "clvm-utils", + "clvmr", +] + [[package]] name = "chia-bls" version = "0.22.0" @@ -171,7 +330,7 @@ dependencies = [ "hex", "hkdf", "linked-hash-map", - "sha2", + "sha2 0.10.9", "thiserror 1.0.69", ] @@ -182,13 +341,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc9ac7e90ae816e814dc26fb332615a5449d1d26e965eb7ad068ae530f9c3c7b" dependencies = [ "blst", + "chia-serde", "chia-sha2 0.26.0", "chia-traits 0.26.0", "hex", "hkdf", "linked-hash-map", - "sha2", + "serde", + "sha2 0.10.9", + "thiserror 1.0.69", +] + +[[package]] +name = "chia-client" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3874f2e4c87f246457a42890d4f8e0cae9d019131b54f80f66c561c8d05cd444" +dependencies = [ + "chia-protocol", + "chia-traits 0.26.0", + "futures-util", "thiserror 1.0.69", + "tokio", + "tokio-tungstenite", + "tungstenite", ] [[package]] @@ -219,6 +395,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9256179e4c912313532d7a47a50f67f6128313ee86a3798e54050afb73d6042" dependencies = [ "chia-bls 0.26.0", + "chia-serde", "chia-sha2 0.26.0", "chia-traits 0.26.0", "chia_streamable_macro 0.26.0", @@ -226,6 +403,7 @@ dependencies = [ "clvm-utils", "clvmr", "hex", + "serde", ] [[package]] @@ -255,6 +433,151 @@ dependencies = [ "hex-literal", ] +[[package]] +name = "chia-sdk-client" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a9ab7609f5c98ff34fc5ba33ad50bfdecca771165640eb88e8054ccef18916" +dependencies = [ + "chia-protocol", + "chia-sdk-types", + "chia-ssl", + "chia-traits 0.26.0", + "futures-util", + "thiserror 2.0.19", + "tokio", + "tokio-tungstenite", + "tracing", + "tungstenite", +] + +[[package]] +name = "chia-sdk-coinset" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb55e723e4b847fdbb9e729fef1a0f21c624b618bda8a17c9c205ff9db64a1e1" +dependencies = [ + "chia-protocol", + "hex", + "hex-literal", + "reqwest", + "serde", + "serde_json", +] + +[[package]] +name = "chia-sdk-derive" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add9e60902df26dfe987d87f1b19ab7e359c6269fa4f222a9d79c02a9dba969d" +dependencies = [ + "convert_case", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "chia-sdk-driver" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "260c97751f8986e7f82091e3bee504044efb79857f26bfcd2b5d37cba674f79e" +dependencies = [ + "bigdecimal", + "bip39", + "chia-bls 0.26.0", + "chia-consensus", + "chia-protocol", + "chia-puzzle-types", + "chia-puzzles", + "chia-sdk-signer", + "chia-sdk-types", + "chia-secp", + "chia-sha2 0.26.0", + "chia-traits 0.26.0", + "chia_streamable_macro 0.26.0", + "clvm-traits", + "clvm-utils", + "clvmr", + "getrandom 0.2.17", + "hex", + "hex-literal", + "indexmap", + "num-bigint", + "rand", + "rand_chacha", + "thiserror 2.0.19", +] + +[[package]] +name = "chia-sdk-signer" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71037389cc3bdebc47c9c6cfebc898244393e4e7246b486d64922a9c23bbac6" +dependencies = [ + "chia-bls 0.26.0", + "chia-consensus", + "chia-protocol", + "chia-sdk-types", + "chia-secp", + "chia-sha2 0.26.0", + "clvm-traits", + "clvmr", + "k256", + "thiserror 2.0.19", +] + +[[package]] +name = "chia-sdk-test" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "365b95611176b670cd61455ce6578cccf740a66b7d27cd3b31395c12f4073bc2" +dependencies = [ + "anyhow", + "bip39", + "chia-bls 0.26.0", + "chia-consensus", + "chia-protocol", + "chia-puzzle-types", + "chia-sdk-signer", + "chia-sdk-types", + "chia-secp", + "chia-sha2 0.26.0", + "chia-traits 0.26.0", + "clvm-traits", + "clvm-utils", + "clvmr", + "hex", + "indexmap", + "prettytable-rs", + "rand", + "rand_chacha", + "serde", + "serde_json", + "thiserror 2.0.19", +] + +[[package]] +name = "chia-sdk-types" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298b92f005cd884861678808813cb49dca3545231b6b442d691e01535ed86825" +dependencies = [ + "chia-bls 0.26.0", + "chia-consensus", + "chia-protocol", + "chia-puzzle-types", + "chia-puzzles", + "chia-sdk-derive", + "chia-secp", + "chia-sha2 0.26.0", + "clvm-traits", + "clvm-utils", + "clvm_tools_rs", + "clvmr", + "hex-literal", + "thiserror 2.0.19", +] + [[package]] name = "chia-sdk-utils" version = "0.30.0" @@ -269,13 +592,35 @@ dependencies = [ "thiserror 2.0.19", ] +[[package]] +name = "chia-secp" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae00dfc70f7a3b20c5074ea3684b25f7b64225ad44ca3ea766b4e908f86c2d5" +dependencies = [ + "chia-sha2 0.26.0", + "hex", + "k256", + "p256", +] + +[[package]] +name = "chia-serde" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b013e263888739bfecf7456552eb4a9f2260f920db8b46b0723fb553ab1e030" +dependencies = [ + "hex", + "serde", +] + [[package]] name = "chia-sha2" version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f7b37c5362dfb1c9449902139e94a91bbd8d3773c13939972fd88e4f14c4f9d" dependencies = [ - "sha2", + "sha2 0.10.9", ] [[package]] @@ -284,7 +629,20 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f3ab374b1f248d87516c34e4c128a91d2086e76a46bb44e386c96a71ea39129" dependencies = [ - "sha2", + "sha2 0.10.9", +] + +[[package]] +name = "chia-ssl" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51af8606c8376d730d6607cb69fe5edd87f5d0d106e5620065f58a31e7f348a4" +dependencies = [ + "rand", + "rcgen", + "rsa", + "thiserror 1.0.69", + "time", ] [[package]] @@ -309,6 +667,26 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "chia-wallet-sdk" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94547c50b141ad9ae84f9bcc26c0bf467b7570f0354c906fb203eeea69886a4c" +dependencies = [ + "chia-bls 0.26.0", + "chia-protocol", + "chia-sdk-client", + "chia-sdk-coinset", + "chia-sdk-driver", + "chia-sdk-signer", + "chia-sdk-test", + "chia-sdk-types", + "chia-sdk-utils", + "clvm-traits", + "clvm-utils", + "clvmr", +] + [[package]] name = "chia_streamable_macro" version = "0.22.0" @@ -361,6 +739,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469c44226c83de37509415482c89c00763f58de198844c34d2d62161b087d0ce" dependencies = [ "chia-bls 0.26.0", + "chia-secp", "clvm-derive", "clvmr", "num-bigint", @@ -380,16 +759,46 @@ dependencies = [ ] [[package]] -name = "clvmr" -version = "0.14.0" +name = "clvm_tools_rs" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd6b7142674607f3a213addaf261bd70b07537297e460a9cc6a389068a62934f" +checksum = "b00394d353154d726b10ca4f76056f4fac3ad37a3218c83ba3008b041e1398c3" dependencies = [ - "bitvec", - "bumpalo", + "binascii", "chia-bls 0.22.0", - "chia-sha2 0.22.0", - "hex", + "clvmr", + "do-notation", + "getrandom 0.2.17", + "hashlink", + "hex", + "indoc", + "js-sys", + "lazy_static", + "num", + "num-bigint", + "num-traits", + "pyo3-build-config", + "regex", + "serde", + "serde_json", + "sha2 0.9.9", + "tempfile", + "unicode-segmentation", + "wasm-bindgen", + "yaml-rust2", +] + +[[package]] +name = "clvmr" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6b7142674607f3a213addaf261bd70b07537297e460a9cc6a389068a62934f" +dependencies = [ + "bitvec", + "bumpalo", + "chia-bls 0.22.0", + "chia-sha2 0.22.0", + "hex", "hex-literal", "k256", "lazy_static", @@ -408,6 +817,15 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "convert_case" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -449,6 +867,27 @@ dependencies = [ "typenum", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "ctr" version = "0.9.2" @@ -464,6 +903,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b365fabc795046672053e29c954733ec3b05e4be654ab130fe8f1f94d7051f35" +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "der" version = "0.7.10" @@ -475,6 +920,26 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "dig-constants" version = "0.7.0" @@ -496,7 +961,7 @@ dependencies = [ "chia-bls 0.26.0", "chia-protocol", "chia-sdk-utils", - "sha2", + "sha2 0.10.9", "sparse-merkle-tree", "thiserror 2.0.19", ] @@ -523,28 +988,79 @@ dependencies = [ name = "dig-session" version = "0.4.0" dependencies = [ + "bip39", + "chia", + "chia-wallet-sdk", "dig-constants", "dig-identity", "dig-keystore", + "hex", "hkdf", - "sha2", + "sha2 0.10.9", "tempfile", "thiserror 1.0.69", "zeroize", ] +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", + "block-buffer 0.10.4", "const-oid", "crypto-common", "subtle", ] +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if 1.0.4", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "do-notation" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e16a80c1dda2cf52fa07106427d3d798b6331dca8155fcb8c39f7fc78f6dd2" + [[package]] name = "ecdsa" version = "0.16.9" @@ -552,7 +1068,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der", - "digest", + "digest 0.10.7", "elliptic-curve", "rfc6979", "signature", @@ -567,7 +1083,7 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", "crypto-bigint", - "digest", + "digest 0.10.7", "ff", "generic-array", "group", @@ -579,6 +1095,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if 1.0.4", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -592,7 +1123,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -617,12 +1148,79 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "funty" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" version = "0.14.9" @@ -641,8 +1239,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if 1.0.4", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -683,12 +1283,30 @@ dependencies = [ "subtle", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "hermit-abi" version = "0.5.2" @@ -701,6 +1319,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + [[package]] name = "hex-literal" version = "0.4.1" @@ -722,593 +1349,1713 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] -name = "indexmap" -version = "2.14.0" +name = "http" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ - "equivalent", - "hashbrown", + "bytes", + "itoa", ] [[package]] -name = "inout" +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ - "generic-array", + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", ] [[package]] -name = "k256" -version = "0.13.4" +name = "httparse" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" dependencies = [ - "cfg-if 1.0.4", - "ecdsa", - "elliptic-curve", - "once_cell", - "sha2", - "signature", + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", ] [[package]] -name = "keccak" -version = "0.1.6" +name = "hyper-util" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "cpufeatures", + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", ] [[package]] -name = "lazy_static" -version = "1.5.0" +name = "icu_collections" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] [[package]] -name = "libc" -version = "0.2.186" +name = "icu_locale_core" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] [[package]] -name = "linked-hash-map" -version = "0.5.6" +name = "icu_normalizer" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] [[package]] -name = "linux-raw-sys" -version = "0.12.1" +name = "icu_normalizer_data" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] -name = "lock_api" -version = "0.4.14" +name = "icu_properties" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ - "scopeguard", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", ] [[package]] -name = "memchr" -version = "2.8.3" +name = "icu_properties_data" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] -name = "num-bigint" -version = "0.4.8" +name = "icu_provider" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ - "num-integer", - "num-traits", + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", ] [[package]] -name = "num-integer" -version = "0.1.46" +name = "idna" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ - "num-traits", + "idna_adapter", + "smallvec", + "utf8_iter", ] [[package]] -name = "num-traits" -version = "0.2.19" +name = "idna_adapter" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ - "autocfg", + "icu_normalizer", + "icu_properties", ] [[package]] -name = "num_cpus" -version = "1.17.0" +name = "indexmap" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ - "hermit-abi", - "libc", + "equivalent", + "hashbrown 0.17.1", ] [[package]] -name = "once_cell" -version = "1.21.4" +name = "indoc" +version = "2.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] [[package]] -name = "opaque-debug" -version = "0.3.1" +name = "inout" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] [[package]] -name = "p256" -version = "0.13.2" +name = "ipnet" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2", -] +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] -name = "parking_lot" -version = "0.12.5" +name = "is-terminal" +version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ - "lock_api", - "parking_lot_core", + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if 1.0.4", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if 1.0.4", + "ecdsa", + "elliptic-curve", + "once_cell", + "sha2 0.10.9", + "signature", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", ] [[package]] name = "parking_lot_core" version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if 1.0.4", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if 1.0.4", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettytable-rs" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eea25e07510aa6ab6547308ebe3c036016d162b8da920dbb079e3ba8acf3d95a" +dependencies = [ + "csv", + "encode_unicode", + "is-terminal", + "lazy_static", + "term", + "unicode-width", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3-build-config" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99636d423fa2ca130fa5acde3059308006d46f98caac629418e53f7ebb1e9999" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if 1.0.4", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if 1.0.4", + "cpufeatures", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if 1.0.4", + "cpufeatures", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if 1.0.4", + "cpufeatures", + "digest 0.10.7", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ - "cfg-if 1.0.4", "libc", - "redox_syscall", - "smallvec", - "windows-link", + "windows-sys 0.61.2", ] [[package]] -name = "password-hash" -version = "0.5.0" +name = "sparse-merkle-tree" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +checksum = "8851f6c92491ebe5528eabc1244292175a739eb0162974f9f9670a7dc748748b" dependencies = [ - "base64ct", - "rand_core", - "subtle", + "blake2b-rs", + "cc", + "cfg-if 0.1.10", ] [[package]] -name = "pem-rfc7468" -version = "0.7.0" +name = "spin" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", + "der", ] [[package]] -name = "pkcs8" -version = "0.10.2" +name = "stable_deref_trait" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ - "der", - "spki", + "proc-macro2", + "quote", + "unicode-ident", ] [[package]] -name = "polyval" -version = "0.6.2" +name = "syn" +version = "3.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" dependencies = [ - "cfg-if 1.0.4", - "cpufeatures", - "opaque-debug", - "universal-hash", + "proc-macro2", + "quote", + "unicode-ident", ] [[package]] -name = "ppv-lite86" -version = "0.2.21" +name = "sync_wrapper" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" dependencies = [ - "zerocopy", + "futures-core", ] [[package]] -name = "primeorder" -version = "0.13.6" +name = "synstructure" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ - "elliptic-curve", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "proc-macro-crate" -version = "1.3.1" +name = "tap" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ + "fastrand", + "getrandom 0.4.3", "once_cell", - "toml_edit", + "rustix", + "windows-sys 0.61.2", ] [[package]] -name = "proc-macro2" -version = "1.0.107" +name = "term" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" dependencies = [ - "unicode-ident", + "dirs-next", + "rustversion", + "winapi", ] [[package]] -name = "quote" -version = "1.0.47" +name = "thiserror" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "r-efi" -version = "6.0.0" +name = "thiserror-impl" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] [[package]] -name = "radium" -version = "0.7.0" +name = "threadpool" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" +checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" +dependencies = [ + "num_cpus", +] [[package]] -name = "rand" -version = "0.8.7" +name = "time" +version = "0.3.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", "libc", - "rand_chacha", - "rand_core", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", ] [[package]] -name = "rand_chacha" -version = "0.3.1" +name = "tokio-tungstenite" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" dependencies = [ - "ppv-lite86", - "rand_core", + "futures-util", + "log", + "tokio", + "tungstenite", ] [[package]] -name = "rand_core" -version = "0.6.4" +name = "toml_datetime" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "getrandom 0.2.17", + "indexmap", + "toml_datetime", + "winnow", ] [[package]] -name = "redox_syscall" -version = "0.5.18" +name = "tower" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", ] [[package]] -name = "rfc6979" -version = "0.4.0" +name = "tower-layer" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "hmac", - "subtle", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror 1.0.69", + "utf-8", ] [[package]] -name = "rustix" -version = "1.1.4" +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", + "tinyvec", ] [[package]] -name = "scopeguard" -version = "1.2.0" +name = "unicode-segmentation" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] -name = "sec1" -version = "0.7.3" +name = "unicode-width" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "base16ct", - "der", - "generic-array", - "pkcs8", + "crypto-common", "subtle", - "zeroize", ] [[package]] -name = "sha1" -version = "0.10.7" +name = "untrusted" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" -dependencies = [ - "cfg-if 1.0.4", - "cpufeatures", - "digest", -] +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] -name = "sha2" -version = "0.10.9" +name = "url" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ - "cfg-if 1.0.4", - "cpufeatures", - "digest", + "form_urlencoded", + "idna", + "percent-encoding", + "serde", ] [[package]] -name = "sha3" -version = "0.10.9" +name = "utf-8" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" -dependencies = [ - "digest", - "keccak", -] +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" [[package]] -name = "shlex" -version = "2.0.1" +name = "utf8_iter" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] -name = "signature" -version = "2.2.0" +name = "version_check" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" dependencies = [ - "digest", - "rand_core", + "try-lock", ] [[package]] -name = "smallvec" -version = "1.15.2" +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "sparse-merkle-tree" -version = "0.6.1" +name = "wasm-bindgen" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8851f6c92491ebe5528eabc1244292175a739eb0162974f9f9670a7dc748748b" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ - "blake2b-rs", - "cc", - "cfg-if 0.1.10", + "cfg-if 1.0.4", + "once_cell", + "rustversion", + "serde", + "serde_json", + "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] -name = "spki" -version = "0.7.3" +name = "wasm-bindgen-futures" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ - "base64ct", - "der", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "subtle" -version = "2.6.1" +name = "wasm-bindgen-macro" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] [[package]] -name = "syn" -version = "2.0.119" +name = "wasm-bindgen-macro-support" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ + "bumpalo", "proc-macro2", "quote", - "unicode-ident", + "syn 2.0.119", + "wasm-bindgen-shared", ] [[package]] -name = "syn" -version = "3.0.2" +name = "wasm-bindgen-shared" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ - "proc-macro2", - "quote", "unicode-ident", ] [[package]] -name = "tap" -version = "1.0.1" +name = "web-sys" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] [[package]] -name = "tempfile" -version = "3.27.0" +name = "winapi" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" dependencies = [ - "fastrand", - "getrandom 0.4.3", - "once_cell", - "rustix", - "windows-sys", + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", ] [[package]] -name = "thiserror" -version = "1.0.69" +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] -name = "thiserror" -version = "2.0.19" +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" -dependencies = [ - "thiserror-impl 2.0.19", -] +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] -name = "thiserror-impl" -version = "1.0.69" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "thiserror-impl" -version = "2.0.19" +name = "windows-sys" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.2", + "windows-targets", ] [[package]] -name = "threadpool" -version = "1.8.1" +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "num_cpus", + "windows-link", ] [[package]] -name = "toml_datetime" -version = "0.6.11" +name = "windows-targets" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] [[package]] -name = "toml_edit" -version = "0.19.15" +name = "windows_aarch64_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" -dependencies = [ - "indexmap", - "toml_datetime", - "winnow", -] +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] -name = "typenum" -version = "1.20.1" +name = "windows_aarch64_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] -name = "unicode-ident" -version = "1.0.24" +name = "windows_i686_gnu" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] -name = "universal-hash" -version = "0.5.1" +name = "windows_i686_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" -dependencies = [ - "crypto-common", - "subtle", -] +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] -name = "version_check" -version = "0.9.5" +name = "windows_i686_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" +name = "windows_x86_64_gnu" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] -name = "windows-link" -version = "0.2.1" +name = "windows_x86_64_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] -name = "windows-sys" -version = "0.61.2" +name = "windows_x86_64_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" @@ -1319,6 +3066,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + [[package]] name = "wyz" version = "0.5.1" @@ -1328,6 +3081,67 @@ dependencies = [ "tap", ] +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "yaml-rust2" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2462ea039c445496d8793d052e13787f2b90e750b833afee748e601c17621ed9" +dependencies = [ + "arraydeque", + "encoding_rs", + "hashlink", +] + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.54" @@ -1348,6 +3162,27 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + [[package]] name = "zeroize" version = "1.9.0" @@ -1367,3 +3202,42 @@ dependencies = [ "quote", "syn 2.0.119", ] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 57187ea..2cd6140 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,13 @@ dig-identity = { version = "0.5", features = ["bls"] } # they are instead confined to the smallest scope and dropped immediately. zeroize = "1.7" thiserror = "1" +# BIP-39: the ONE place a recovery phrase is converted. Entropy <-> 24 English +# words, and `to_seed("")` (PBKDF2-HMAC-SHA512, 2048 rounds, EMPTY passphrase — +# the Chia convention) to expand entropy into the 64-byte master HD seed. This +# expansion is what makes a DIG phrase restore identically in Sage; deriving +# straight from the entropy silently forks the wallet (dig_ecosystem #1759). +# Default features only: English wordlist + std, no other languages pulled in. +bip39 = "2.0" # Per-profile DEK derivation. Versions pinned to MATCH dig-app's DEK construction # (dig-app-core keystore/secrets.rs) byte-for-byte: HKDF-SHA256, RFC 5869. A # different hkdf/sha2 major here would risk a different DEK and break at-rest @@ -52,6 +59,12 @@ dig-constants = "0.7" [dev-dependencies] dig-keystore = { version = "0.4", features = ["testing"] } tempfile = "3" +hex = "0.4" +# Test-only: the INDEPENDENT reference derivation for the cross-implementation +# golden vector (`master_to_wallet_unhardened(..).derive_synthetic()` -> +# `StandardArgs::curry_tree_hash` -> bech32m). Never hand-roll a Chia address. +chia = { version = "0.26", features = ["bls"] } +chia-wallet-sdk = { version = "0.30", default-features = false } [lints.rust] unsafe_code = "forbid" diff --git a/src/envelope.rs b/src/envelope.rs new file mode 100644 index 0000000..3c0cfd9 --- /dev/null +++ b/src/envelope.rs @@ -0,0 +1,149 @@ +//! The versioned at-rest envelope for stored master-seed material. +//! +//! # Why an envelope exists at all +//! +//! Two DIG generations store **32 bytes** under the same account path, and the +//! bytes are byte-for-byte indistinguishable: +//! +//! | generation | the 32 bytes are | fed to `SecretKey::from_seed` as | +//! |---|---|---| +//! | legacy (≤ dig-session 0.4) | a raw CSPRNG master seed | themselves | +//! | current | BIP-39 **entropy** | `mnemonic.to_seed("")` (64 bytes) | +//! +//! Reinterpreting one as the other does not fail — it silently derives a +//! *different but perfectly plausible* wallet. A user would see an empty +//! account with no error. So the stored blob must **declare** which it is, and +//! a legacy declaration must **fail closed** (see [`Kind`]). +//! +//! # Why `dig_keystore::opaque` rather than a new `KeyScheme` +//! +//! A [`dig_keystore::KeyScheme`] pins a fixed `SECRET_LEN`, so a +//! version-tagged payload does not fit one, and `dig_keystore::format`'s +//! magic allow-list is closed — a scheme defined outside dig-keystore could +//! not be decoded. [`dig_keystore::opaque`] is the sanctioned +//! arbitrary-length door into the *same* audited container (Argon2id + +//! AES-256-GCM + CRC-32, magic `DIGOP1`), so this module gets a versioned +//! payload without changing a foundation crate. +//! +//! # Wire layout of the sealed plaintext +//! +//! ```text +//! byte 0 1 2 .. 34 +//! ┌────────┬────────┬──────────────────┐ +//! │version │ kind │ 32 secret bytes │ +//! └────────┴────────┴──────────────────┘ +//! ``` +//! +//! Additive-only, per §5.1: a future kind (say a 64-byte seed imported from +//! elsewhere) takes a new [`Kind`] discriminant; existing kinds keep their +//! meaning forever. + +use dig_keystore::{ + opaque, BackendKey, BlsSigning, KdfParams, KeyScheme, KeychainBackend, KeystoreError, Password, +}; +use zeroize::Zeroizing; + +use crate::master_seed::ENTROPY_LEN; +use crate::{Result, SessionError}; + +/// Current envelope version. Bumped only for a layout change, never for a new +/// [`Kind`]. +const VERSION_V1: u8 = 0x01; + +/// Total sealed-plaintext length: `version || kind || entropy`. +const ENVELOPE_LEN: usize = 2 + ENTROPY_LEN; + +/// What the 32 stored bytes MEAN. +/// +/// The discriminant is the whole point of the envelope: the bytes alone cannot +/// tell you, and guessing wrong derives the wrong wallet in silence. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Kind { + /// BIP-39 entropy for a 24-word English mnemonic, re-expanded to the + /// 64-byte HD seed via `to_seed("")` before any derivation. The only kind + /// this crate writes. + Bip39Entropy = 0x01, +} + +impl Kind { + /// Recognize a stored discriminant, rejecting anything this build does not + /// understand rather than defaulting to a guess. + fn from_byte(byte: u8) -> Result { + match byte { + x if x == Kind::Bip39Entropy as u8 => Ok(Kind::Bip39Entropy), + unknown => Err(SessionError::UnsupportedSeedKind(unknown)), + } + } +} + +/// Seal `entropy` into a versioned envelope and write it to `backend` at `path`. +/// +/// Refuses to overwrite: an existing blob at `path` yields +/// [`KeystoreError::AlreadyExists`], matching `dig_keystore::Keystore::create` +/// so no caller can clobber a key by re-enrolling. +pub(crate) fn write_bip39_entropy( + backend: &dyn KeychainBackend, + path: &BackendKey, + password: &Password, + entropy: &[u8; ENTROPY_LEN], +) -> Result<()> { + if backend.exists(path)? { + return Err(KeystoreError::AlreadyExists(path.as_str().to_string()).into()); + } + + let mut plaintext = Zeroizing::new(Vec::with_capacity(ENVELOPE_LEN)); + plaintext.push(VERSION_V1); + plaintext.push(Kind::Bip39Entropy as u8); + plaintext.extend_from_slice(entropy); + + let blob = opaque::seal(password, &plaintext, KdfParams::DEFAULT)?; + backend.write(path, &blob)?; + Ok(()) +} + +/// Read, decrypt and validate the envelope at `path`, returning the BIP-39 +/// entropy it declares. +/// +/// # Errors +/// +/// - [`SessionError::LegacySeedFormat`] if the blob is a pre-envelope raw-seed +/// keystore file. **The bytes are never reinterpreted as entropy** — the +/// caller is told to re-enrol from a phrase instead. +/// - [`SessionError::UnsupportedEnvelopeVersion`] / +/// [`SessionError::UnsupportedSeedKind`] for a blob written by a newer build. +/// - [`SessionError::Keystore`] for a missing file, wrong password, tampered +/// ciphertext, or a foreign container. +pub(crate) fn read_bip39_entropy( + backend: &dyn KeychainBackend, + path: &BackendKey, + password: &Password, +) -> Result> { + let blob = backend.read(path)?; + + // Discriminate BEFORE spending ~0.5s on Argon2id, and before any chance of + // treating legacy bytes as entropy. The legacy path wrote a typed + // `BlsSigning` keystore file, whose magic is a structural, non-heuristic + // marker of the old derivation. + if blob.starts_with(&BlsSigning::MAGIC) { + return Err(SessionError::LegacySeedFormat); + } + + let plaintext = opaque::open(password, &blob)?; + if plaintext.len() != ENVELOPE_LEN { + return Err(KeystoreError::InvalidPlaintext { + expected: ENVELOPE_LEN, + got: plaintext.len(), + } + .into()); + } + if plaintext[0] != VERSION_V1 { + return Err(SessionError::UnsupportedEnvelopeVersion(plaintext[0])); + } + // Validated for its own sake: an unknown kind must abort, not fall through + // to the entropy interpretation. + Kind::from_byte(plaintext[1])?; + + let mut entropy = Zeroizing::new([0u8; ENTROPY_LEN]); + entropy.copy_from_slice(&plaintext[2..]); + Ok(entropy) +} diff --git a/src/error.rs b/src/error.rs index 1861669..320f5e1 100644 --- a/src/error.rs +++ b/src/error.rs @@ -21,6 +21,42 @@ pub enum SessionError { /// from an empty seed would silently produce a fixed, guessable key. #[error("seed material must be non-empty")] EmptySeed, + + /// The stored blob predates the versioned seed envelope: its 32 bytes are a + /// **raw** master seed, not BIP-39 entropy. + /// + /// This fails CLOSED on purpose. The two encodings are byte-for-byte + /// indistinguishable, so reinterpreting the old bytes as entropy would + /// derive a different, entirely plausible wallet with no error at all — the + /// user would simply see the wrong (empty) account. The account must be + /// re-enrolled from its recovery phrase via + /// [`Session::enroll_from_recovery_phrase`](crate::Session::enroll_from_recovery_phrase). + #[error( + "this account was stored under the legacy raw-seed format and cannot be \ + read as BIP-39 entropy; re-enrol it from its 24-word recovery phrase" + )] + LegacySeedFormat, + + /// The stored seed envelope declares a layout version this build does not + /// know. Written by a newer dig-session; refuse rather than guess. + #[error("unsupported seed-envelope version {0:#04x}; this build is too old to read it")] + UnsupportedEnvelopeVersion(u8), + + /// The stored seed envelope declares a secret KIND this build does not know. + /// Refuse rather than fall back to an interpretation that could be wrong. + #[error("unsupported stored seed kind {0:#04x}; this build is too old to read it")] + UnsupportedSeedKind(u8), + + /// A supplied recovery phrase is not a valid 24-word English BIP-39 + /// mnemonic (unknown word, failed checksum, or wrong word count). + /// + /// The message names only the SHAPE of the failure — never a word of the + /// phrase, which is secret material. + #[error( + "not a valid 24-word English BIP-39 recovery phrase (check the word \ + count, spelling, and order)" + )] + InvalidRecoveryPhrase, } /// Convenience alias for `Result`. diff --git a/src/lib.rs b/src/lib.rs index 11c9c21..18b3afb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -62,6 +62,7 @@ #![forbid(unsafe_code)] #![warn(missing_docs)] +mod envelope; mod error; mod master_seed; mod session; @@ -69,8 +70,8 @@ mod unlocked; pub use error::{Result, SessionError}; pub use master_seed::{ - IdentitySigningFn, UnlockedMasterSeed, IDENTITY_PUBLIC_KEY_LEN, IDENTITY_SIGNATURE_LEN, - SEED_LEN, + IdentitySigningFn, UnlockedMasterSeed, ENTROPY_LEN, IDENTITY_PUBLIC_KEY_LEN, + IDENTITY_SIGNATURE_LEN, MASTER_SEED_LEN, RECOVERY_PHRASE_WORDS, }; pub use session::Session; pub use unlocked::{SigningFn, UnlockedIdentity}; diff --git a/src/master_seed.rs b/src/master_seed.rs index 34abe3e..c5ed3fe 100644 --- a/src/master_seed.rs +++ b/src/master_seed.rs @@ -1,4 +1,5 @@ -//! The master-SEED unlock path: an unlocked handle over the raw master HD seed. +//! The master-SEED unlock path: an unlocked handle over a DIG account's root +//! HD seed, stored as BIP-39 entropy and re-expanded on demand. //! //! # Why this exists (the impedance mismatch it closes) //! @@ -8,21 +9,42 @@ //! `MasterKey::from_seed_bytes` (the master-HD model, dig_ecosystem #997) needs //! the **master seed** to derive every profile's wallet keys. The identity //! scalar cannot reconstruct the seed, so a consumer that wants BOTH the -//! dig-identity key AND a wallet `MasterKey` must persist the seed itself. +//! dig-identity key AND a wallet `MasterKey` must persist the root itself. //! -//! [`UnlockedMasterSeed`] is that path: it persists the raw 32-byte master seed -//! (encrypted at rest) and, on unlock, exposes ALL of: +//! # The root is BIP-39 ENTROPY, and the seed is expanded from it //! -//! - [`master_seed`](UnlockedMasterSeed::master_seed) — the raw seed bytes as a -//! primitive `Zeroizing<[u8; SEED_LEN]>`, ready to feed to wallet-backend's -//! `MasterKey::from_seed_bytes` (see the layering note below); +//! A 24-word recovery phrase carries an implicit promise: *any* conforming +//! wallet can restore it. Standard Chia wallets (Sage, the reference client, +//! `chia-wallet-sdk`) honour that promise as +//! +//! ```text +//! phrase --BIP-39--> 32-byte entropy --PBKDF2("mnemonic", 2048)--> 64-byte seed --EIP-2333--> master SecretKey +//! ``` +//! +//! Handing the **entropy** to `SecretKey::from_seed` instead of the expanded +//! 64-byte seed skips the PBKDF2 step. It does not fail — it derives a +//! different, entirely plausible wallet, so a user restoring a DIG phrase in +//! Sage would see an empty account with no error at all. dig_ecosystem #1759. +//! +//! This module therefore stores the **32-byte entropy** (which is exactly what +//! a 24-word phrase encodes, and what the phrase can be regenerated from) and +//! re-expands it to the [`MASTER_SEED_LEN`]-byte HD seed at +//! [`master_seed`](UnlockedMasterSeed::master_seed). **Every** derivation — +//! identity, per-profile DEK, wallet — reads that one expanded seed, so no two +//! of them can disagree about the root. +//! +//! [`UnlockedMasterSeed`] exposes: +//! +//! - [`master_seed`](UnlockedMasterSeed::master_seed) — the expanded 64-byte HD +//! seed, ready to feed to wallet-backend's `MasterKey::from_seed_bytes` (see +//! the layering note below); +//! - [`recovery_phrase`](UnlockedMasterSeed::recovery_phrase) — the 24 words, +//! *without* consuming the handle, so a UI can show them and carry on; //! - [`sign`](UnlockedMasterSeed::sign) / -//! [`public_key`](UnlockedMasterSeed::public_key) — the dig-identity key -//! derived from the seed at the canonical path, byte-identical to the 0.2.0 -//! identity path for the same seed; +//! [`public_key`](UnlockedMasterSeed::public_key) — the dig-identity key at +//! the canonical hardened path; //! - [`derive_symmetric_key`](UnlockedMasterSeed::derive_symmetric_key) — the -//! per-profile DEK, **byte-identical** to -//! [`crate::UnlockedIdentity::derive_symmetric_key`] (§5.1 at-rest back-compat). +//! per-profile DEK, through the frozen `dig-constants` HKDF contract. //! //! # Layering (@10 reference-DOWN-only, HARD RULE) //! @@ -31,45 +53,54 @@ //! dig-wallet-backend or return a wallet-backend type — that would be an illegal //! upward `@10 -> @20` edge. The seed is exposed as PRIMITIVE bytes only; the //! app-tier consumer (dig-app) constructs the `MasterKey` itself via -//! `MasterKey::from_seed_bytes(handle.master_seed())`. +//! `MasterKey::from_seed_bytes(handle.master_seed().to_vec())`. //! //! # Why the seed round-trips to the SAME master key everywhere //! //! Both dig-identity's `master_secret_key_from_seed(seed)` and wallet-backend's //! `MasterKey::from_seed_bytes(seed)` reduce to `chia_bls::SecretKey::from_seed(seed)` -//! (EIP-2333 KeyGen). Handing the identical seed bytes to both therefore yields -//! the identical master key — which is exactly why the seed (not the derived -//! scalar) is the value that must be persisted and exposed. +//! (EIP-2333 KeyGen). Handing the identical expanded seed bytes to both +//! therefore yields the identical master key — which is exactly why the seed +//! (not a derived scalar) is the value that must be reconstructible. //! -//! # Storage scheme +//! # Storage //! -//! The seed is stored under [`dig_keystore::BlsSigning`] purely as a 32-byte -//! encrypted-at-rest byte vault: that scheme persists exactly its 32 secret -//! bytes verbatim and returns them via `expose_secret()`. This path never uses -//! the `BlsSigning` handle's own `sign`/`public_key` (which would derive the -//! *master* key via `from_seed`); the dig-identity key is derived in this crate -//! instead. The `L1WalletBls` scheme is unsuitable here because it stores an -//! already-derived *scalar* (`from_bytes`), not a *seed*. +//! The entropy is sealed in a versioned envelope (see [`crate::envelope`]) so a +//! stored blob DECLARES what its 32 bytes mean. A pre-envelope blob — whose 32 +//! bytes were a raw master seed — fails closed with +//! [`SessionError::LegacySeedFormat`](crate::SessionError::LegacySeedFormat) +//! rather than being reinterpreted as entropy. use std::sync::Arc; +use bip39::{Language, Mnemonic}; use dig_constants::SYMMETRIC_KEY_LEN; use dig_identity::{ derive_identity_sk, derive_identity_sk_at, master_secret_key_from_seed, public_key_bytes, sign_message, }; -use dig_keystore::{BlsSigning, SignerHandle}; use zeroize::Zeroizing; use crate::unlocked::derive_symmetric_key_from_scalar; +use crate::{Result, SessionError}; + +/// The number of bytes of BIP-39 entropy behind a DIG account — the value +/// actually stored at rest. +/// +/// 32 bytes is precisely the entropy of a **24-word** English BIP-39 mnemonic +/// (256 bits + an 8-bit checksum = 264 bits = 24 × 11), so entropy and phrase +/// convert both ways losslessly and the phrase is the canonical backup. +pub const ENTROPY_LEN: usize = 32; -/// The number of bytes in a DIG master HD seed. +/// The number of bytes in an expanded BIP-39 master HD seed — the value fed to +/// `chia_bls::SecretKey::from_seed`. /// -/// Fixed at 32 to match dig-app's master-seed model (`SCALAR_LEN`, a 32-byte -/// entropy seed drawn from the CSPRNG) and the `BlsSigning` storage scheme's -/// `SECRET_LEN`. The seed is fed to `chia_bls::SecretKey::from_seed`, which -/// accepts any length ≥ 32; 32 bytes is the DIG canonical size. -pub const SEED_LEN: usize = 32; +/// Fixed at 64 by BIP-39: `PBKDF2-HMAC-SHA512(phrase, "mnemonic" || passphrase, +/// 2048)` emits 512 bits. DIG uses the **empty** passphrase, matching Chia. +pub const MASTER_SEED_LEN: usize = 64; + +/// The number of words in a DIG recovery phrase. +pub const RECOVERY_PHRASE_WORDS: usize = 24; /// The number of bytes in a compressed BLS12-381 **G1** identity public key. pub const IDENTITY_PUBLIC_KEY_LEN: usize = 48; @@ -87,62 +118,143 @@ pub const IDENTITY_SIGNATURE_LEN: usize = 96; /// scheme-parameterized `SignerHandle`. pub type IdentitySigningFn = Arc [u8; IDENTITY_SIGNATURE_LEN] + Send + Sync>; -/// A live, in-memory master HD seed whose bytes have been decrypted and are -/// ready to (a) reconstruct the wallet `MasterKey` app-side and (b) derive the -/// dig-identity signing key + profile DEK in-crate. +/// Reconstruct the 24-word mnemonic that `entropy` encodes. /// -/// Obtained from [`crate::Session::enroll_master_seed`] or -/// [`crate::Session::unlock_master_seed`]. The seed lives inside the wrapped -/// [`SignerHandle`], which stores it in a `Zeroizing` buffer and wipes it when -/// this value is dropped. The type deliberately does not implement `Clone` and -/// its `Debug` impl redacts the secret. +/// Infallible for [`ENTROPY_LEN`] bytes: BIP-39 accepts any 32-byte entropy and +/// computes the checksum itself, so there is no invalid input to report. +/// +/// # Custody note +/// +/// `bip39::Mnemonic` is a plain `Clone` value over word indices with no +/// `Zeroize`/`Drop` impl (bip39 2.x exposes no `zeroize` feature), so it cannot +/// be wiped in place. Every caller therefore confines it to the smallest +/// possible scope and routes the byte/string buffers *we* own through +/// [`Zeroizing`]. +fn mnemonic_from_entropy(entropy: &[u8; ENTROPY_LEN]) -> Mnemonic { + Mnemonic::from_entropy_in(Language::English, entropy) + .expect("BIP-39 accepts any 32-byte entropy (24 words); length is a compile-time constant") +} + +/// Expand BIP-39 `entropy` into the 64-byte master HD seed, the Chia way: +/// `entropy -> 24 words -> PBKDF2 with an EMPTY passphrase`. +/// +/// This is the one function that closes the Sage-parity gap; every derivation in +/// this module goes through it so none can drift. +pub(crate) fn expand_entropy(entropy: &[u8; ENTROPY_LEN]) -> Zeroizing<[u8; MASTER_SEED_LEN]> { + let mnemonic = mnemonic_from_entropy(entropy); + // Chia convention: the BIP-39 passphrase is empty. A non-empty passphrase + // here would silently fork the wallet from every standard client. + Zeroizing::new(mnemonic.to_seed("")) +} + +/// Parse a recovery phrase back into the entropy DIG stores. +/// +/// Accepts the forms a user plausibly types: any capitalisation, leading or +/// trailing whitespace, and any run of whitespace (including newlines, as when +/// pasting from a numbered list) between words. Restoring an account is a +/// high-stress moment — being rejected over a capital letter is a trap, not a +/// safety measure, and lowercasing cannot change which English BIP-39 word a +/// token is. +/// +/// # Errors +/// +/// [`SessionError::InvalidRecoveryPhrase`] if a word is not in the English +/// wordlist, the checksum fails, or the phrase is not +/// [`RECOVERY_PHRASE_WORDS`] words long. The message deliberately names only +/// the *shape* of the failure — never a word of the phrase, which is secret. +pub(crate) fn entropy_from_phrase(phrase: &str) -> Result> { + // bip39's "normalized" parse handles Unicode normalisation only; case and + // whitespace shape are ours to forgive. + let normalized = Zeroizing::new( + phrase + .split_whitespace() + .map(str::to_lowercase) + .collect::>() + .join(" "), + ); + let mnemonic = Mnemonic::parse_in_normalized(Language::English, &normalized) + .map_err(|_| SessionError::InvalidRecoveryPhrase)?; + let entropy = Zeroizing::new(mnemonic.to_entropy()); + if entropy.len() != ENTROPY_LEN { + return Err(SessionError::InvalidRecoveryPhrase); + } + let mut out = Zeroizing::new([0u8; ENTROPY_LEN]); + out.copy_from_slice(&entropy); + Ok(out) +} + +/// A live, in-memory account root whose BIP-39 entropy has been decrypted and is +/// ready to (a) reconstruct the wallet `MasterKey` app-side, (b) derive the +/// dig-identity signing key + profile DEK in-crate, and (c) render the 24-word +/// recovery phrase for the user. +/// +/// Obtained from [`crate::Session::enroll_master_seed`], +/// [`crate::Session::enroll_from_recovery_phrase`] or +/// [`crate::Session::unlock_master_seed`]. The entropy lives in a [`Zeroizing`] +/// buffer wiped when this value drops. The type deliberately does not implement +/// `Clone` and its `Debug` impl redacts the secret. /// /// # Boundaries /// /// An `UnlockedMasterSeed` must never cross an IPC boundary: it holds the root -/// wallet seed and belongs solely to the user-app process that owns the identity -/// (dig_ecosystem #908). The seed stays user-side; this crate crosses no +/// wallet secret and belongs solely to the user-app process that owns the +/// identity (dig_ecosystem #908). It stays user-side; this crate crosses no /// engine/IPC boundary. pub struct UnlockedMasterSeed { - /// The `BlsSigning` handle is used ONLY as a zeroizing 32-byte vault for the - /// raw seed (`expose_secret()` returns it verbatim). Its own signing key is - /// never used — the identity key is derived from the seed in this crate. - seed_handle: SignerHandle, + /// The stored BIP-39 entropy. Wiped on drop. Expanded to the HD seed on + /// every derivation rather than being cached, so no long-lived copy of the + /// expanded seed exists. + entropy: Zeroizing<[u8; ENTROPY_LEN]>, } impl UnlockedMasterSeed { - /// Wrap a freshly unlocked seed-storage [`SignerHandle`]. - pub(crate) fn new(seed_handle: SignerHandle) -> Self { - Self { seed_handle } + /// Wrap freshly decrypted BIP-39 entropy. + pub(crate) fn new(entropy: Zeroizing<[u8; ENTROPY_LEN]>) -> Self { + Self { entropy } } - /// The raw master HD seed bytes, as a primitive `Zeroizing<[u8; SEED_LEN]>`. + /// The expanded 64-byte master HD seed. /// /// This is the value an app-tier consumer feeds to wallet-backend's /// `MasterKey::from_seed_bytes` to reconstruct the wallet master key - /// (`MasterKey::from_seed_bytes(handle.master_seed())`). The returned buffer - /// is `Zeroizing`, so the caller's copy is wiped on drop — it is the caller's - /// responsibility to keep it zeroizing all the way into `from_seed_bytes` - /// (whose parameter is itself moved into a `Zeroizing` buffer). + /// (`MasterKey::from_seed_bytes(handle.master_seed().to_vec())`). It is + /// byte-identical to what Sage and every standard Chia wallet derive from + /// the same 24 words, so the wallet addresses match. + /// + /// The returned buffer is `Zeroizing`, so the caller's copy is wiped on drop + /// — it is the caller's responsibility to keep it zeroizing all the way into + /// `from_seed_bytes` (whose parameter is itself moved into a `Zeroizing` + /// buffer). /// /// A primitive byte array is returned (never a wallet-backend type) to keep /// dig-session free of any upward `@10 -> @20` dependency edge. - pub fn master_seed(&self) -> Zeroizing<[u8; SEED_LEN]> { - let raw = self.seed_handle.expose_secret(); - let mut seed = Zeroizing::new([0u8; SEED_LEN]); - // The storage scheme guarantees exactly SEED_LEN bytes (`BlsSigning`'s - // SECRET_LEN); the enroll path only ever writes SEED_LEN bytes. - seed.copy_from_slice(raw); - seed + pub fn master_seed(&self) -> Zeroizing<[u8; MASTER_SEED_LEN]> { + expand_entropy(&self.entropy) + } + + /// The [`RECOVERY_PHRASE_WORDS`]-word BIP-39 recovery phrase for this + /// account, space-separated and lowercase. + /// + /// Takes `&self`: showing the user their phrase must not cost them their + /// session, so the handle stays usable afterwards. Typing these words into + /// Sage — or into + /// [`Session::enroll_from_recovery_phrase`](crate::Session::enroll_from_recovery_phrase) + /// on a new machine — reproduces this exact account. + /// + /// The returned `String` is [`Zeroizing`], so its heap buffer is wiped on + /// drop. **Never log it**; `dig-logging`'s BIP-39 redactor is a backstop, + /// not a licence. + pub fn recovery_phrase(&self) -> Zeroizing { + Zeroizing::new(mnemonic_from_entropy(&self.entropy).to_string()) } /// The 48-byte compressed BLS12-381 G1 dig-identity public key derived from - /// the seed. + /// the expanded master seed at the canonical hardened path + /// `m/12381'/8444'/9'/0'`. /// /// Byte-identical to - /// `dig_identity::public_key_bytes(derive_identity_sk(master_secret_key_from_seed(seed)))` - /// and to the 0.2.0 identity path's public key for the same seed — so - /// signatures produced here verify against the published DID identity. + /// `dig_identity::public_key_bytes(derive_identity_sk(master_secret_key_from_seed(master_seed)))`, + /// so signatures produced here verify against the published DID identity. pub fn public_key(&self) -> [u8; IDENTITY_PUBLIC_KEY_LEN] { // Reconstruct the identity key transiently; it drops at end of scope. let seed = self.master_seed(); @@ -150,8 +262,8 @@ impl UnlockedMasterSeed { public_key_bytes(&identity_sk) } - /// Sign `msg` with the dig-identity key derived from the seed, returning the - /// 96-byte G2 AugScheme signature. + /// Sign `msg` with the dig-identity key derived from the master seed, + /// returning the 96-byte G2 AugScheme signature. /// /// The signature verifies under [`public_key`](Self::public_key). pub fn sign(&self, msg: &[u8]) -> [u8; IDENTITY_SIGNATURE_LEN] { @@ -162,13 +274,12 @@ impl UnlockedMasterSeed { /// Derive a per-profile symmetric key (DEK) bound to `label`. /// - /// **Byte-identical to [`crate::UnlockedIdentity::derive_symmetric_key`]** - /// for the same underlying identity: the identity scalar is re-derived from - /// the seed and fed to the SAME frozen HKDF construction - /// (`HKDF-SHA256(ikm = IDENTITY_IKM_VERSION || identity_scalar, - /// salt = DEK_SALT, info = label)` → [`SYMMETRIC_KEY_LEN`] bytes). So a - /// profile blob sealed via the 0.2.0 identity path opens unchanged after a - /// consumer migrates to the master-seed path (§5.1 at-rest back-compat). + /// The identity scalar is re-derived from the **expanded** master seed and + /// fed to the frozen HKDF construction shared with every other DEK path + /// ([`derive_symmetric_key_from_scalar`]): + /// `HKDF-SHA256(ikm = IDENTITY_IKM_VERSION || identity_scalar, + /// salt = DEK_SALT, info = label)` → [`SYMMETRIC_KEY_LEN`] bytes. The HKDF is + /// never duplicated, so all paths stay byte-compatible for one root. /// /// The returned key and all intermediates are wrapped in [`Zeroizing`]. pub fn derive_symmetric_key(&self, label: &[u8]) -> Zeroizing<[u8; SYMMETRIC_KEY_LEN]> { @@ -182,7 +293,7 @@ impl UnlockedMasterSeed { } /// The 48-byte compressed BLS12-381 G1 dig-identity public key for the - /// profile at `profile_ix`, derived from the seed at + /// profile at `profile_ix`, derived from the expanded master seed at /// `m/12381'/8444'/9'/{profile_ix}'` via /// [`dig_identity::derive_identity_sk_at`]. /// @@ -191,8 +302,7 @@ impl UnlockedMasterSeed { /// `derive_identity_sk_at(master, 0) == derive_identity_sk(master)` /// (adversarial-confirmed in dig-identity 0.5.0), so /// `profile_public_key(0) == public_key()` byte-for-byte — the default path is - /// exactly profile 0, and this method is a pure ADDITIVE generalization - /// (§5.1). Each `profile_ix` yields a distinct, deterministic identity key. + /// exactly profile 0. Each `profile_ix` yields a distinct, deterministic key. pub fn profile_public_key(&self, profile_ix: u32) -> [u8; IDENTITY_PUBLIC_KEY_LEN] { let seed = self.master_seed(); let profile_sk = derive_identity_sk_at(&master_secret_key_from_seed(&*seed), profile_ix); @@ -204,7 +314,7 @@ impl UnlockedMasterSeed { /// /// The signature verifies under /// [`profile_public_key(profile_ix)`](Self::profile_public_key). - /// `profile_sign(0, msg) == sign(msg)` byte-for-byte (§5.1 additive). + /// `profile_sign(0, msg) == sign(msg)` byte-for-byte. pub fn profile_sign(&self, profile_ix: u32, msg: &[u8]) -> [u8; IDENTITY_SIGNATURE_LEN] { let seed = self.master_seed(); let profile_sk = derive_identity_sk_at(&master_secret_key_from_seed(&*seed), profile_ix); @@ -214,7 +324,7 @@ impl UnlockedMasterSeed { /// Derive the profile at `profile_ix`'s per-profile symmetric key (DEK) bound /// to `label`. /// - /// The profile's identity scalar is derived from the seed via + /// The profile's identity scalar is derived from the expanded master seed via /// [`dig_identity::derive_identity_sk_at`] and fed to the SAME frozen HKDF /// construction as every other DEK path /// ([`derive_symmetric_key_from_scalar`]) — the HKDF is never duplicated, so @@ -225,9 +335,7 @@ impl UnlockedMasterSeed { /// /// Because `derive_identity_sk_at(master, 0) == derive_identity_sk(master)`, /// `profile_derive_symmetric_key(0, label) == derive_symmetric_key(label)` - /// byte-for-byte — so a profile blob sealed via the default path opens - /// unchanged (§5.1 at-rest back-compat). Each `profile_ix` yields a distinct, - /// deterministic DEK. + /// byte-for-byte. Each `profile_ix` yields a distinct, deterministic DEK. /// /// The returned key and all intermediates are wrapped in [`Zeroizing`]. pub fn profile_derive_symmetric_key( @@ -248,24 +356,25 @@ impl UnlockedMasterSeed { /// Produce a standalone signing primitive that signs with this identity's /// key — a plain callable carrying no dig-session or identity type. /// - /// The closure owns its own zeroizing copy of the seed, so it keeps working - /// after this handle is dropped and wipes its copy when the closure itself - /// is dropped. This is how a downstream stays identity-agnostic while still - /// being able to sign (dig_ecosystem #908). + /// The closure owns its own zeroizing copy of the entropy, so it keeps + /// working after this handle is dropped and wipes its copy when the closure + /// itself is dropped. This is how a downstream stays identity-agnostic while + /// still being able to sign (dig_ecosystem #908). pub fn signing_fn(&self) -> IdentitySigningFn { - let seed = self.master_seed(); + let entropy = self.entropy.clone(); Arc::new(move |msg: &[u8]| { + let seed = expand_entropy(&entropy); let identity_sk = derive_identity_sk(&master_secret_key_from_seed(&*seed)); sign_message(&identity_sk, msg) }) } } -/// Redacting `Debug`: shows the type name only, never the seed. +/// Redacting `Debug`: shows the type name only, never the secret. impl core::fmt::Debug for UnlockedMasterSeed { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { f.debug_struct("UnlockedMasterSeed") - .field("seed", &"") + .field("entropy", &"") .finish() } } diff --git a/src/session.rs b/src/session.rs index 8f69c9a..7b47ab8 100644 --- a/src/session.rs +++ b/src/session.rs @@ -4,12 +4,11 @@ use std::sync::Arc; use dig_identity::{derive_identity_sk, master_secret_key_from_seed}; use dig_keystore::scheme::KeyScheme; -use dig_keystore::{ - BackendKey, BlsSigning, KdfParams, KeychainBackend, Keystore, L1WalletBls, Password, -}; +use dig_keystore::{BackendKey, KdfParams, KeychainBackend, Keystore, L1WalletBls, Password}; use zeroize::Zeroizing; -use crate::master_seed::SEED_LEN; +use crate::envelope; +use crate::master_seed::{entropy_from_phrase, ENTROPY_LEN}; use crate::{Result, SessionError, UnlockedIdentity, UnlockedMasterSeed}; /// Entry point for turning stored, encrypted key material into a live signer. @@ -107,65 +106,83 @@ impl Session { Ok(UnlockedIdentity::new(signer)) } - /// Enroll a new master HD seed: persist the raw `seed` bytes encrypted under - /// `password` and return an [`UnlockedMasterSeed`] ready to reconstruct the - /// wallet `MasterKey` app-side and to derive the dig-identity key + DEK. + /// Enroll a new account root from `entropy`: seal the 32 bytes of BIP-39 + /// entropy under `password` in a versioned envelope and return an + /// [`UnlockedMasterSeed`]. /// /// Unlike [`enroll_identity`](Self::enroll_identity) — which stores the - /// *derived identity scalar* and can never recover the seed — this path - /// stores the **seed itself**, so a consumer can feed it to - /// wallet-backend's `MasterKey::from_seed_bytes` (the master-HD model, - /// dig_ecosystem #997) while still deriving the byte-identical dig-identity - /// key. See the [`crate::UnlockedMasterSeed`] module docs for the layering - /// and storage-scheme rationale. + /// *derived identity scalar* and can never recover the root — this path + /// stores the **root itself**, so a consumer can reconstruct the wallet + /// `MasterKey` (the master-HD model, dig_ecosystem #997), derive the + /// dig-identity key, and render the 24-word recovery phrase, all from one + /// value. /// - /// The seed is stored under [`dig_keystore::BlsSigning`] as a 32-byte - /// encrypted byte vault (its own signer is never used); the identity key is - /// derived from the seed in-crate. + /// `entropy` is BIP-39 entropy, **not** an HD seed: it is expanded to the + /// 64-byte seed via `to_seed("")` at every derivation, which is what makes + /// the resulting phrase restore identically in Sage and every standard Chia + /// wallet (dig_ecosystem #1759). Any [`ENTROPY_LEN`] CSPRNG bytes are valid + /// entropy, so a caller may pass fresh randomness directly. /// /// # Errors /// - /// Returns [`SessionError::Keystore`] if a file already exists at `path` or - /// the write fails. + /// Returns [`SessionError::Keystore`] if a blob already exists at `path` + /// (never silently overwritten) or the write fails. pub fn enroll_master_seed( backend: Arc, path: BackendKey, password: Password, - seed: &[u8; SEED_LEN], + entropy: &[u8; ENTROPY_LEN], ) -> Result { - // Store the raw seed verbatim. `create` consumes the password, and - // `unlock` needs it again, so clone before the move. - let secret = Zeroizing::new(seed.to_vec()); - let unlock_password = Password::new(password.as_bytes()); - let keystore = Keystore::::create( - backend, - path, - password, - Some(secret), - KdfParams::DEFAULT, - )?; - let seed_handle = keystore.unlock(unlock_password)?; - Ok(UnlockedMasterSeed::new(seed_handle)) + envelope::write_bip39_entropy(&*backend, &path, &password, entropy)?; + let mut stored = Zeroizing::new([0u8; ENTROPY_LEN]); + stored.copy_from_slice(entropy); + Ok(UnlockedMasterSeed::new(stored)) } - /// Unlock an existing master-seed keystore file into an - /// [`UnlockedMasterSeed`]. + /// Enroll an account root from an existing 24-word recovery `phrase` — the + /// restore-on-a-new-machine path. /// - /// The file must have been written by [`enroll_master_seed`](Self::enroll_master_seed) - /// (the [`dig_keystore::BlsSigning`] scheme / `DIGVK1` magic); loading it - /// with the wrong scheme fails cleanly. + /// The phrase may be any capitalisation with any whitespace between words + /// (BIP-39 normalised). Restoring the phrase that + /// [`UnlockedMasterSeed::recovery_phrase`] produced — or one exported from + /// Sage — reproduces the identical account: same wallet addresses, same + /// identity key, same per-profile DEKs. /// /// # Errors /// - /// Returns [`SessionError::Keystore`] if the file is missing, the password - /// is wrong, the ciphertext is tampered, or the scheme does not match. + /// - [`SessionError::InvalidRecoveryPhrase`] if the phrase is not a valid + /// 24-word English BIP-39 mnemonic. + /// - [`SessionError::Keystore`] if a blob already exists at `path` or the + /// write fails. + pub fn enroll_from_recovery_phrase( + backend: Arc, + path: BackendKey, + password: Password, + phrase: &str, + ) -> Result { + let entropy = entropy_from_phrase(phrase)?; + Self::enroll_master_seed(backend, path, password, &entropy) + } + + /// Unlock an existing account root into an [`UnlockedMasterSeed`]. + /// + /// # Errors + /// + /// - [`SessionError::LegacySeedFormat`] if the blob predates the versioned + /// envelope. Its 32 bytes are a raw seed, and are **never** reinterpreted + /// as BIP-39 entropy — that would silently derive a different wallet. The + /// account must be re-enrolled via + /// [`enroll_from_recovery_phrase`](Self::enroll_from_recovery_phrase). + /// - [`SessionError::UnsupportedEnvelopeVersion`] / + /// [`SessionError::UnsupportedSeedKind`] for a blob from a newer build. + /// - [`SessionError::Keystore`] if the blob is missing, the password is + /// wrong, or the ciphertext is tampered. pub fn unlock_master_seed( backend: Arc, path: BackendKey, password: Password, ) -> Result { - let keystore = Keystore::::load(backend, path)?; - let seed_handle = keystore.unlock(password)?; - Ok(UnlockedMasterSeed::new(seed_handle)) + let entropy = envelope::read_bip39_entropy(&*backend, &path, &password)?; + Ok(UnlockedMasterSeed::new(entropy)) } } diff --git a/tests/chia_conformance.rs b/tests/chia_conformance.rs new file mode 100644 index 0000000..b72948d --- /dev/null +++ b/tests/chia_conformance.rs @@ -0,0 +1,384 @@ +//! Cross-implementation conformance for the DIG recovery phrase. +//! +//! A 24-word BIP-39 phrase carries an implicit promise: *any* conforming wallet +//! restores it. These tests pin that promise for DIG against the STANDARD Chia +//! derivation, so a user who writes down a DIG phrase and later types it into +//! Sage lands on the same account (dig_ecosystem #1759). +//! +//! # Why every address here is a hardcoded bech32m literal +//! +//! If both sides of a comparison were computed live, a `bip39`/`chia-bls` +//! dependency bump could move them together and mask a regression. The literals +//! below were produced independently through `chia-wallet-sdk`'s own drivers and +//! are frozen. The precedent is dig-keystore's +//! `public_key_matches_chia_standard_master_key_for_all_zero_mnemonic`. +//! +//! # Why the OLD address is pinned too +//! +//! A test that computes the expected value the *new* way passes trivially. The +//! nearest wrong implementation is "feed the 32 entropy bytes straight to +//! `SecretKey::from_seed`" — the bug being fixed — so +//! [`old_entropy_as_seed_derivation_is_no_longer_reachable`] pins that address as +//! a literal too, proves the fixture can still exhibit it, and asserts the crate +//! no longer produces it. + +use std::sync::Arc; + +use chia::bls::{master_to_wallet_unhardened, SecretKey}; +use chia::puzzles::standard::StandardArgs; +use chia::puzzles::DeriveSynthetic; +use chia_wallet_sdk::utils::Address; +use dig_identity::{derive_identity_sk, master_secret_key_from_seed, public_key_bytes}; +use dig_keystore::{BackendKey, BlsSigning, KdfParams, Keystore, MemoryBackend, Password}; +use dig_session::{Session, SessionError, ENTROPY_LEN, MASTER_SEED_LEN, RECOVERY_PHRASE_WORDS}; + +/// The canonical public BIP-39 test mnemonic: 24 words of all-zero entropy. +/// Public by construction — never a real key. +const TEST_PHRASE: &str = "abandon abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon abandon abandon art"; + +/// Wallet address 0 of [`TEST_PHRASE`] under the STANDARD Chia derivation — +/// `SecretKey::from_seed(mnemonic.to_seed(""))` then +/// `master_to_wallet_unhardened(master, 0).derive_synthetic()`. +/// +/// This is the value Sage shows for the same words. Frozen literal. +const SAGE_ADDRESS_0: &str = "xch16grurcglcwcv6arjarr720yd9wqhp9gkx3k8h25lhwg8pl7vl6ysuax0gy"; + +/// Wallet address 0 that the pre-#1759 DIG derivation produced for +/// [`TEST_PHRASE`]: the 32 entropy bytes fed to `SecretKey::from_seed` directly, +/// skipping BIP-39's PBKDF2 expansion. Frozen literal, kept ONLY so the +/// regression can be named and excluded. +const PRE_1759_ADDRESS_0: &str = "xch1jcvy96pjkh7wn5zvx6atwztru6kmhhyekd52td566leshf0d4tvsrtxr7a"; + +const PASSWORD: &str = "correct horse battery staple"; + +fn backend() -> Arc { + Arc::new(MemoryBackend::new()) +} + +/// The canonical Chia wallet address at `index` for master-HD `seed` bytes, +/// built with `chia-wallet-sdk`'s own drivers — never hand-rolled CLVM or +/// bech32. +/// +/// Mirrors `dig-wallet-backend`'s `MasterKey::wallet_signing_key`: the +/// unhardened wallet child `m/12381/8444/2/index`, made synthetic against the +/// default hidden puzzle, currying the standard transaction puzzle. +fn wallet_address(seed: &[u8], index: u32) -> String { + let master = SecretKey::from_seed(seed); + let synthetic = master_to_wallet_unhardened(&master, index) + .derive_synthetic() + .public_key(); + Address::new( + StandardArgs::curry_tree_hash(synthetic).into(), + "xch".to_string(), + ) + .encode() + .expect("a 32-byte puzzle hash always bech32m-encodes") +} + +/// The 32 bytes of entropy behind [`TEST_PHRASE`] — what a pre-#1759 build would +/// have handed to `SecretKey::from_seed`. +fn test_entropy() -> [u8; ENTROPY_LEN] { + let entropy = bip39::Mnemonic::parse_in_normalized(bip39::Language::English, TEST_PHRASE) + .expect("a well-known valid BIP-39 test vector") + .to_entropy(); + entropy + .try_into() + .expect("a 24-word mnemonic carries exactly 32 bytes of entropy") +} + +fn enrol_from_phrase(phrase: &str) -> dig_session::UnlockedMasterSeed { + Session::enroll_from_recovery_phrase( + backend(), + BackendKey::new("acct"), + Password::from(PASSWORD), + phrase, + ) + .expect("a valid phrase must enrol") +} + +/// CONF-1 (load-bearing): the phrase resolves to the address Sage shows. +#[test] +fn recovery_phrase_derives_the_standard_chia_address() { + let handle = enrol_from_phrase(TEST_PHRASE); + + assert_eq!( + wallet_address(&*handle.master_seed(), 0), + SAGE_ADDRESS_0, + "a DIG account restored from a 24-word phrase must sit at the SAME \ + address every standard Chia wallet derives from those words" + ); +} + +/// CONF-2 (non-vacuity): the fixture CAN still exhibit the old, wrong address — +/// and the crate no longer produces it. +/// +/// Without this, CONF-1 would pass for a build that computes the expectation the +/// same wrong way it derives. +#[test] +fn old_entropy_as_seed_derivation_is_no_longer_reachable() { + let entropy = test_entropy(); + + // The fixture is capable of showing the bug: entropy-as-seed still yields + // the pre-#1759 address, so CONF-1 is discriminating rather than tautological. + assert_eq!( + wallet_address(&entropy, 0), + PRE_1759_ADDRESS_0, + "fixture check: entropy-as-seed must still reproduce the pre-#1759 \ + address, otherwise this test proves nothing" + ); + assert_ne!( + PRE_1759_ADDRESS_0, SAGE_ADDRESS_0, + "fixture check: the two derivations must actually differ" + ); + + let handle = enrol_from_phrase(TEST_PHRASE); + assert_ne!( + wallet_address(&*handle.master_seed(), 0), + PRE_1759_ADDRESS_0, + "the crate must no longer derive the pre-#1759 entropy-as-seed address" + ); + assert_eq!( + handle.master_seed().len(), + MASTER_SEED_LEN, + "the exposed root must be the expanded seed, not the entropy" + ); +} + +/// CONF-3 (fail-closed): a legacy raw-seed blob refuses to unlock rather than +/// being reinterpreted as BIP-39 entropy. +/// +/// The two encodings are indistinguishable byte-wise, so a silent +/// reinterpretation would hand back a *working* handle for the *wrong* account. +/// The assertion is therefore that no handle exists at all. +#[test] +fn legacy_raw_seed_blob_fails_closed() { + let be = backend(); + let path = BackendKey::new("acct"); + + // Reproduce exactly what dig-session <= 0.4 wrote: the raw 32 bytes in a + // typed `BlsSigning` keystore file. + Keystore::::create( + be.clone(), + path.clone(), + Password::from(PASSWORD), + Some(zeroize::Zeroizing::new(test_entropy().to_vec())), + KdfParams::DEFAULT, + ) + .expect("the legacy write path must still work for the fixture"); + + let err = Session::unlock_master_seed(be, path, Password::from(PASSWORD)) + .expect_err("a legacy blob must NOT unlock"); + assert!( + matches!(err, SessionError::LegacySeedFormat), + "a legacy blob must fail with LegacySeedFormat so the caller can offer \ + re-enrolment, got {err:?}" + ); +} + +/// CONF-4 (round-trip): show the phrase, restore from it elsewhere, land on the +/// identical account — wallet address AND identity key. +/// +/// Both are asserted because they derive through different paths +/// (`m/12381/8444/2/i` unhardened+synthetic vs `m/12381'/8444'/9'/0'` hardened); +/// an expansion applied to only one of them would pass a wallet-only test. +#[test] +fn recovery_phrase_round_trips_to_an_identical_account() { + let original = enrol_from_phrase(TEST_PHRASE); + + let phrase = original.recovery_phrase(); + assert_eq!( + phrase.split_whitespace().count(), + RECOVERY_PHRASE_WORDS, + "a DIG recovery phrase is {RECOVERY_PHRASE_WORDS} words" + ); + + let restored = enrol_from_phrase(&phrase); + assert_eq!( + wallet_address(&*restored.master_seed(), 0), + wallet_address(&*original.master_seed(), 0), + "restoring the shown phrase must reproduce the same wallet address" + ); + assert_eq!( + restored.public_key(), + original.public_key(), + "restoring the shown phrase must reproduce the same identity key" + ); +} + +/// CONF-5: showing the phrase must not cost the user their session. +/// +/// `recovery_phrase` takes `&self`; a consuming signature would force a UI to +/// choose between displaying the words and staying unlocked. +#[test] +fn recovery_phrase_does_not_consume_the_handle() { + let handle = enrol_from_phrase(TEST_PHRASE); + + let first = handle.recovery_phrase(); + let second = handle.recovery_phrase(); + assert_eq!(&*first, &*second, "the phrase must be stable"); + assert_eq!( + first.as_str(), + TEST_PHRASE.split_whitespace().collect::>().join(" "), + "the phrase must be the canonical space-separated 24 words" + ); + + // The handle is still fully usable afterwards. + let sig = handle.sign(b"still unlocked"); + assert_eq!(sig.len(), 96); +} + +/// CONF-6 (placement, not just outcome): the identity key and the profile DEK +/// derive from the EXPANDED seed, not from the stored entropy. +/// +/// Expanding only inside the wallet path would leave identity and DEK on the old +/// root — two roots for one account. Asserting the expanded-seed value *and* +/// rejecting the entropy-derived value is what distinguishes the two placements. +#[test] +fn identity_and_dek_derive_from_the_expanded_seed() { + let handle = enrol_from_phrase(TEST_PHRASE); + let entropy = test_entropy(); + + let from_expanded = public_key_bytes(&derive_identity_sk(&master_secret_key_from_seed( + &*handle.master_seed(), + ))); + let from_entropy = + public_key_bytes(&derive_identity_sk(&master_secret_key_from_seed(&entropy))); + + assert_ne!( + from_expanded, from_entropy, + "fixture check: the two roots must yield different identity keys" + ); + assert_eq!( + handle.public_key(), + from_expanded, + "the identity key must derive from the EXPANDED seed" + ); + + // Same property for the DEK, which is derived from the identity scalar. + let label = b"dig-app:profile-dek:v2"; + let dek_from_entropy = { + let scalar = derive_identity_sk(&master_secret_key_from_seed(&entropy)).to_bytes(); + // The DEK is a pure function of the scalar, so a differing scalar is a + // differing DEK; comparing scalars is the stronger, simpler assertion. + scalar + }; + let dek_from_expanded = + derive_identity_sk(&master_secret_key_from_seed(&*handle.master_seed())).to_bytes(); + assert_ne!(dek_from_entropy, dek_from_expanded); + assert_ne!( + &*handle.derive_symmetric_key(label), + &[0u8; 32], + "a DEK must be real key material" + ); +} + +/// CONF-7: a malformed phrase is rejected, and the error names only the SHAPE of +/// the failure — never a word of the (secret) phrase. +#[test] +fn invalid_recovery_phrases_are_rejected_without_echoing_them() { + let short = "abandon abandon abandon"; + let bad_checksum = TEST_PHRASE.replace("art", "abandon"); + let unknown_word = TEST_PHRASE.replace("art", "zzzzz"); + + for phrase in [short, &bad_checksum, &unknown_word] { + let err = Session::enroll_from_recovery_phrase( + backend(), + BackendKey::new("acct"), + Password::from(PASSWORD), + phrase, + ) + .expect_err("an invalid phrase must be rejected"); + assert!( + matches!(err, SessionError::InvalidRecoveryPhrase), + "expected InvalidRecoveryPhrase, got {err:?}" + ); + let rendered = err.to_string(); + for word in phrase.split_whitespace() { + assert!( + !rendered.contains(word), + "the error message must not echo any word of the phrase" + ); + } + } +} + +/// CONF-8: a phrase is accepted in the forms a user plausibly types — mixed case +/// and irregular whitespace — and still resolves to the same account. +#[test] +fn recovery_phrase_accepts_user_typed_whitespace_and_case() { + let canonical = enrol_from_phrase(TEST_PHRASE); + let messy = TEST_PHRASE.replace("abandon", "Abandon").replace(' ', " "); + + let restored = enrol_from_phrase(messy.trim()); + assert_eq!( + wallet_address(&*restored.master_seed(), 0), + wallet_address(&*canonical.master_seed(), 0), + ); +} + +/// CONF-9: re-enrolling over an existing account is refused, so a restore flow +/// cannot silently overwrite a key the user still needs. +#[test] +fn enrolment_refuses_to_overwrite_an_existing_account() { + let be = backend(); + let path = BackendKey::new("acct"); + Session::enroll_master_seed( + be.clone(), + path.clone(), + Password::from(PASSWORD), + &test_entropy(), + ) + .unwrap(); + + let err = Session::enroll_master_seed(be, path, Password::from(PASSWORD), &test_entropy()) + .expect_err("a second enrolment at the same path must be refused"); + assert!(matches!( + err, + SessionError::Keystore(dig_keystore::KeystoreError::AlreadyExists(_)) + )); +} + +/// CONF-10: an envelope from a NEWER build — unknown layout version or unknown +/// secret kind — is refused rather than guessed at. +/// +/// A future kind might legitimately hold a 64-byte imported seed; interpreting +/// its first 32 bytes as entropy would derive a plausible wrong account, so this +/// build must decline. +#[test] +fn unknown_envelope_version_or_kind_is_refused() { + let entropy = test_entropy(); + + /// A predicate over the error an unrecognised envelope must produce. + type ErrCheck = fn(&SessionError) -> bool; + + // (version, kind, the error it must produce) + let cases: [(u8, u8, ErrCheck); 2] = [ + (0x02, 0x01, |e| { + matches!(e, SessionError::UnsupportedEnvelopeVersion(0x02)) + }), + (0x01, 0x7f, |e| { + matches!(e, SessionError::UnsupportedSeedKind(0x7f)) + }), + ]; + + for (version, kind, expected) in cases { + let mut plaintext = vec![version, kind]; + plaintext.extend_from_slice(&entropy); + let blob = + dig_keystore::opaque::seal(&Password::from(PASSWORD), &plaintext, KdfParams::DEFAULT) + .unwrap(); + + let be = backend(); + let path = BackendKey::new("acct"); + dig_keystore::KeychainBackend::write(&*be, &path, &blob).unwrap(); + + let err = Session::unlock_master_seed(be, path, Password::from(PASSWORD)) + .expect_err("an unrecognised envelope must not unlock"); + assert!( + expected(&err), + "version {version:#04x} / kind {kind:#04x} produced the wrong error: {err:?}" + ); + } +} diff --git a/tests/master_seed.rs b/tests/master_seed.rs index e7ba366..a55e3de 100644 --- a/tests/master_seed.rs +++ b/tests/master_seed.rs @@ -13,15 +13,12 @@ use dig_identity::{ derive_identity_sk, derive_identity_sk_at, master_secret_key_from_seed, public_key_bytes, }; use dig_keystore::{BackendKey, MemoryBackend, Password}; -use dig_session::{Session, SessionError, SEED_LEN}; +use dig_session::{Session, SessionError, ENTROPY_LEN, MASTER_SEED_LEN}; use hkdf::Hkdf; use sha2::Sha256; -/// A fixed 32-byte master seed. Used on BOTH the identity-scalar path -/// (`enroll_identity`, which accepts `&[u8]`) and the master-seed path -/// (`enroll_master_seed`, which requires `&[u8; SEED_LEN]`) so the two paths can -/// be proved byte-identical for the same seed material. -const SEED: [u8; SEED_LEN] = [ +/// A fixed 32 bytes of BIP-39 entropy — what the master-seed path stores at rest. +const ENTROPY: [u8; ENTROPY_LEN] = [ 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, ]; @@ -31,6 +28,16 @@ const PASSWORD: &str = "correct horse battery staple"; /// `keystore/secrets.rs`) — mirrored from `dig_constants::PROFILE_DEK_LABEL`. const DIG_APP_DEK_LABEL: &[u8] = b"dig-app:profile-dek:v2"; +/// Independently expand [`ENTROPY`] into the 64-byte master HD seed the BIP-39 / +/// Chia way. Deliberately re-derived here from `bip39` rather than calling +/// dig-session's own expansion, so a drift on either side is caught rather than +/// cancelling out (dig_ecosystem #1759). +fn expanded() -> [u8; MASTER_SEED_LEN] { + bip39::Mnemonic::from_entropy_in(bip39::Language::English, &ENTROPY) + .expect("32 bytes is valid 24-word BIP-39 entropy") + .to_seed("") +} + fn backend() -> Arc { Arc::new(MemoryBackend::new()) } @@ -50,22 +57,33 @@ fn dig_app_reference_dek(identity_scalar: &[u8; 32], label: &[u8]) -> [u8; 32] { } #[test] -fn master_seed_returns_the_stored_seed_verbatim() { - // MS-1: master_seed() exposes exactly the 32 bytes that were enrolled — the - // primitive wallet-backend `MasterKey::from_seed_bytes` consumes. +fn master_seed_returns_the_expanded_bip39_seed() { + // MS-1: master_seed() exposes the EXPANDED 64-byte BIP-39 seed — the value + // wallet-backend's `MasterKey::from_seed_bytes` must consume for the account + // to match Sage — NOT the 32 stored entropy bytes (dig_ecosystem #1759). let handle = Session::enroll_master_seed( backend(), BackendKey::new("seed"), Password::from(PASSWORD), - &SEED, + &ENTROPY, ) .unwrap(); let seed = handle.master_seed(); - assert_eq!(seed.len(), SEED_LEN, "master_seed() must be SEED_LEN bytes"); assert_eq!( - &*seed, &SEED, - "master_seed() must return the enrolled seed verbatim" + seed.len(), + MASTER_SEED_LEN, + "master_seed() must be the 64-byte expanded BIP-39 seed" + ); + assert_eq!( + &*seed, + &expanded(), + "master_seed() must equal entropy -> 24 words -> to_seed(\"\")" + ); + assert_ne!( + &seed[..ENTROPY_LEN], + &ENTROPY[..], + "master_seed() must NOT be the raw stored entropy — that is the bug" ); } @@ -73,13 +91,15 @@ fn master_seed_returns_the_stored_seed_verbatim() { fn master_seed_public_key_matches_dig_identity_canonical() { // MS-2: the identity key derived from the seed equals dig-identity's // canonical key — the same key the 0.2.0 identity path anchors in the DID. - let expected = public_key_bytes(&derive_identity_sk(&master_secret_key_from_seed(&SEED))); + let expected = public_key_bytes(&derive_identity_sk(&master_secret_key_from_seed( + &expanded(), + ))); let handle = Session::enroll_master_seed( backend(), BackendKey::new("seed"), Password::from(PASSWORD), - &SEED, + &ENTROPY, ) .unwrap(); @@ -98,14 +118,14 @@ fn master_seed_public_key_equals_identity_scalar_path() { backend(), BackendKey::new("id"), Password::from(PASSWORD), - &SEED, + &expanded(), ) .unwrap(); let seed_path = Session::enroll_master_seed( backend(), BackendKey::new("seed"), Password::from(PASSWORD), - &SEED, + &ENTROPY, ) .unwrap(); @@ -126,14 +146,14 @@ fn master_seed_dek_is_byte_identical_to_identity_scalar_path() { backend(), BackendKey::new("id"), Password::from(PASSWORD), - &SEED, + &expanded(), ) .unwrap(); let seed_path = Session::enroll_master_seed( backend(), BackendKey::new("seed"), Password::from(PASSWORD), - &SEED, + &ENTROPY, ) .unwrap(); @@ -146,7 +166,7 @@ fn master_seed_dek_is_byte_identical_to_identity_scalar_path() { ); // And both must equal dig-app's independently-reconstructed reference DEK. - let scalar = derive_identity_sk(&master_secret_key_from_seed(&SEED)).to_bytes(); + let scalar = derive_identity_sk(&master_secret_key_from_seed(&expanded())).to_bytes(); let reference = dig_app_reference_dek(&scalar, DIG_APP_DEK_LABEL); assert_eq!( &*dek_seed, &reference, @@ -158,14 +178,14 @@ fn master_seed_dek_is_byte_identical_to_identity_scalar_path() { fn master_seed_dek_golden_vector() { // MS-3 (frozen): a FIXED seed + FIXED label -> the EXACT DEK bytes. Any KDF // parameter drift fails this literal comparison and flags a §5.1 break. - let scalar = derive_identity_sk(&master_secret_key_from_seed(&SEED)).to_bytes(); + let scalar = derive_identity_sk(&master_secret_key_from_seed(&expanded())).to_bytes(); let expected = dig_app_reference_dek(&scalar, DIG_APP_DEK_LABEL); let handle = Session::enroll_master_seed( backend(), BackendKey::new("seed"), Password::from(PASSWORD), - &SEED, + &ENTROPY, ) .unwrap(); let dek = handle.derive_symmetric_key(DIG_APP_DEK_LABEL); @@ -184,7 +204,7 @@ fn master_seed_signature_verifies_against_public_key() { backend(), BackendKey::new("seed"), Password::from(PASSWORD), - &SEED, + &ENTROPY, ) .unwrap(); @@ -206,7 +226,7 @@ fn master_seed_signing_fn_works_after_handle_dropped() { backend(), BackendKey::new("seed"), Password::from(PASSWORD), - &SEED, + &ENTROPY, ) .unwrap(); let pk = dig_keystore::bls::PublicKey::from_bytes(&handle.public_key()).unwrap(); @@ -231,7 +251,7 @@ fn enroll_then_unlock_master_seed_roundtrips() { let path = BackendKey::new("seed"); let enrolled = - Session::enroll_master_seed(be.clone(), path.clone(), Password::from(PASSWORD), &SEED) + Session::enroll_master_seed(be.clone(), path.clone(), Password::from(PASSWORD), &ENTROPY) .unwrap(); let enrolled_pk = enrolled.public_key(); drop(enrolled); @@ -239,8 +259,8 @@ fn enroll_then_unlock_master_seed_roundtrips() { let reopened = Session::unlock_master_seed(be, path, Password::from(PASSWORD)).unwrap(); assert_eq!( &*reopened.master_seed(), - &SEED, - "reopened seed must equal the enrolled seed" + &expanded(), + "reopened seed must equal the enrolled account's expanded seed" ); assert_eq!( reopened.public_key(), @@ -254,7 +274,8 @@ fn unlock_master_seed_with_wrong_password_fails() { // MS-7. let be = backend(); let path = BackendKey::new("seed"); - Session::enroll_master_seed(be.clone(), path.clone(), Password::from(PASSWORD), &SEED).unwrap(); + Session::enroll_master_seed(be.clone(), path.clone(), Password::from(PASSWORD), &ENTROPY) + .unwrap(); let err = Session::unlock_master_seed(be, path, Password::from("wrong")).err(); assert!(matches!(err, Some(SessionError::Keystore(_)))); @@ -268,7 +289,7 @@ fn profile_ix_zero_public_key_equals_default_path() { backend(), BackendKey::new("seed"), Password::from(PASSWORD), - &SEED, + &ENTROPY, ) .unwrap(); assert_eq!( @@ -285,7 +306,7 @@ fn profile_ix_zero_sign_equals_default_path() { backend(), BackendKey::new("seed"), Password::from(PASSWORD), - &SEED, + &ENTROPY, ) .unwrap(); let msg = b"authorize this action"; @@ -304,7 +325,7 @@ fn profile_ix_zero_dek_equals_default_path() { backend(), BackendKey::new("seed"), Password::from(PASSWORD), - &SEED, + &ENTROPY, ) .unwrap(); assert_eq!( @@ -321,10 +342,10 @@ fn profile_public_key_matches_dig_identity_canonical_at() { backend(), BackendKey::new("seed"), Password::from(PASSWORD), - &SEED, + &ENTROPY, ) .unwrap(); - let master = master_secret_key_from_seed(&SEED); + let master = master_secret_key_from_seed(&expanded()); for profile_ix in [0u32, 1, 2, 7] { let expected = public_key_bytes(&derive_identity_sk_at(&master, profile_ix)); assert_eq!( @@ -343,14 +364,14 @@ fn profile_ix_one_is_distinct_and_deterministic() { backend(), BackendKey::new("seed"), Password::from(PASSWORD), - &SEED, + &ENTROPY, ) .unwrap(); let handle2 = Session::enroll_master_seed( backend(), BackendKey::new("seed2"), Password::from(PASSWORD), - &SEED, + &ENTROPY, ) .unwrap(); @@ -386,7 +407,7 @@ fn profile_sign_verifies_against_profile_public_key() { backend(), BackendKey::new("seed"), Password::from(PASSWORD), - &SEED, + &ENTROPY, ) .unwrap(); let msg = b"authorize as profile 3"; @@ -410,14 +431,14 @@ fn profile_sign_verifies_against_profile_public_key() { fn profile_dek_golden_vector() { // PROF-7 (frozen): FIXED seed + profile 1 + FIXED label -> the EXACT DEK bytes // from dig-app's reference construction over derive_identity_sk_at(master, 1). - let scalar = derive_identity_sk_at(&master_secret_key_from_seed(&SEED), 1).to_bytes(); + let scalar = derive_identity_sk_at(&master_secret_key_from_seed(&expanded()), 1).to_bytes(); let expected = dig_app_reference_dek(&scalar, DIG_APP_DEK_LABEL); let handle = Session::enroll_master_seed( backend(), BackendKey::new("seed"), Password::from(PASSWORD), - &SEED, + &ENTROPY, ) .unwrap(); assert_eq!( @@ -434,7 +455,7 @@ fn master_seed_debug_does_not_leak_secret() { backend(), BackendKey::new("seed"), Password::from(PASSWORD), - &SEED, + &ENTROPY, ) .unwrap(); let rendered = format!("{handle:?}"); From 7d4e5386c400f90d7d80cb3ef98dd22421ad533c Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 28 Jul 2026 23:22:37 -0700 Subject: [PATCH 3/4] docs(session): spec the BIP-39 root, the seed envelope and the phrase API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the derivation contract normatively so a reimplementation cannot reintroduce the entropy-as-seed bug: SPEC.md gains §3.3.0 (the root is BIP-39 entropy, every derivation reads the EXPANDED seed, empty passphrase) and §3.4 (the versioned at-rest envelope and its fail-closed rule), plus the CONF-1..10 conformance list and the two new custody invariants (no secret in an error message; never log a phrase). Adds the account/recovery-phrase section to the README, and bumps to 0.5.0 — minor per SemVer for 0.x, carrying the breaking API change recorded in the previous commit. Refs DIG-Network/dig_ecosystem#1759 Co-Authored-By: Claude --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 44 +++++++++ SPEC.md | 200 +++++++++++++++++++++++++++++--------- tests/chia_conformance.rs | 55 +++++++++++ 5 files changed, 256 insertions(+), 47 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 24b1b87..1903b54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -986,7 +986,7 @@ dependencies = [ [[package]] name = "dig-session" -version = "0.4.0" +version = "0.5.0" dependencies = [ "bip39", "chia", diff --git a/Cargo.toml b/Cargo.toml index 2cd6140..cf845c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dig-session" -version = "0.4.0" +version = "0.5.0" edition = "2021" rust-version = "1.75" description = "DIG session/keystore layer: unlock identity signing keys and inject signing primitives. Composes dig-keystore (encrypted key storage) + dig-identity (canonical BLS identity derivation) behind one curated, custody-safe facade." diff --git a/README.md b/README.md index 92278b5..36398e1 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,50 @@ let identity = Session::unlock::( # } ``` +## Accounts: one root, a portable recovery phrase + +An account's root is 32 bytes of **BIP-39 entropy**. Every derivation — identity, +per-profile DEK, wallet — reads the seed EXPANDED from it +(`entropy -> 24 words -> to_seed("")`), which is the standard Chia derivation, so +a DIG recovery phrase restores the same addresses in Sage and any other +conforming wallet. + +```rust,no_run +use std::sync::Arc; +use dig_session::{Session, FileBackend, BackendKey, Password, ENTROPY_LEN}; + +# fn main() -> dig_session::Result<()> { +let backend = Arc::new(FileBackend::new("/var/lib/dig/keys")); +let path = BackendKey::new("account"); +let password = Password::from("correct horse battery staple"); + +// Create: any 32 CSPRNG bytes are valid BIP-39 entropy. +let entropy = [0u8; ENTROPY_LEN]; // in production: fill from `OsRng` +let account = Session::enroll_master_seed(backend.clone(), path.clone(), password, &entropy)?; + +// Show the user their 24 words — this does NOT consume the handle. +let phrase = account.recovery_phrase(); + +// The expanded 64-byte seed goes to wallet-backend's `MasterKey::from_seed_bytes`. +let seed = account.master_seed(); + +// Restore on another machine. +let restored = Session::enroll_from_recovery_phrase( + backend, + BackendKey::new("account-2"), + Password::from("correct horse battery staple"), + &phrase, +)?; +# let _ = (seed, restored); +# Ok(()) +# } +``` + +A blob written before the versioned seed envelope held a *raw* seed rather than +entropy. Because the two are byte-indistinguishable, `unlock_master_seed` fails +closed with `SessionError::LegacySeedFormat` instead of reinterpreting them — +silently deriving a plausible wrong wallet would be far worse than an error. + ## Design notes - **No seal / decap.** Recipient message encryption belongs to `dig-message`, diff --git a/SPEC.md b/SPEC.md index 361fd45..165ca58 100644 --- a/SPEC.md +++ b/SPEC.md @@ -13,8 +13,9 @@ MUST/MUST NOT below. ## 1. Scope - **In scope:** unlock an existing key, enroll a new identity, sign, inject a - signing primitive, and enroll/unlock a master HD seed that exposes the raw seed - bytes (a primitive) alongside the seed-derived identity key and DEK. + signing primitive, and enroll/unlock a DIG account root (BIP-39 entropy) that + exposes the expanded master HD seed as a primitive, the 24-word recovery + phrase, and the seed-derived identity key and DEK. - **Out of scope:** recipient message encryption (seal / decap). That composition lives in `dig-message` (same crate level). Implementations of `dig-session` MUST NOT add seal/decap; doing so would duplicate a cross-repo @@ -24,7 +25,7 @@ MUST/MUST NOT below. - `dig-session` MUST depend only on crates at a strictly lower level: `dig-keystore`, `dig-identity`, and `dig-constants` (level 00 foundation), plus - `chia-bls`, `zeroize`, `hkdf`, `sha2`, and `thiserror`. It MUST NOT depend on + `chia-bls`, `bip39`, `zeroize`, `hkdf`, `sha2`, and `thiserror`. It MUST NOT depend on any same-level (10) or higher crate. - **`dig-session` MUST NOT depend on `dig-wallet-backend` (a level-20 crate) and MUST NOT return a wallet-backend type (e.g. `MasterKey`).** That would be an @@ -71,22 +72,36 @@ A stateless namespace. All methods are associated functions. - The returned identity's public key MUST equal `dig_identity::public_key_bytes(derive_identity_sk(master_secret_key_from_seed(seed)))`. -- `Session::enroll_master_seed(backend, path, password, seed: &[u8; SEED_LEN]) -> Result` - - MUST persist the raw `SEED_LEN`-byte master HD seed verbatim, encrypted under - `password`, and return it unlocked. Unlike `enroll_identity` (which stores the - *derived identity scalar* and can never recover the seed), this path stores - the **seed itself** so a consumer can reconstruct the wallet master key. - - MUST store the seed under the **`BlsSigning`** scheme used purely as a - zeroizing 32-byte encrypted byte vault (`expose_secret()` returns the seed - verbatim); the scheme's own `sign`/`public_key` (which would derive the - *master* key via `from_seed`) MUST NOT be used — the dig-identity key is - derived in-crate from the seed. - - MUST surface a pre-existing file or write failure as `SessionError::Keystore`. +- `Session::enroll_master_seed(backend, path, password, entropy: &[u8; ENTROPY_LEN]) -> Result` + - MUST treat `entropy` as **BIP-39 entropy**, never as an HD seed. Any + `ENTROPY_LEN` CSPRNG bytes are valid entropy, so a caller MAY pass fresh + randomness directly. + - MUST persist the `ENTROPY_LEN` bytes inside the versioned seed envelope + (§3.4), encrypted under `password`, and return the account unlocked. + - MUST refuse to overwrite an existing blob at `path`, surfacing + `SessionError::Keystore(KeystoreError::AlreadyExists)`. + +- `Session::enroll_from_recovery_phrase(backend, path, password, phrase: &str) -> Result` + - MUST parse `phrase` as a `RECOVERY_PHRASE_WORDS`-word **English** BIP-39 + mnemonic and enroll the entropy it encodes, so that restoring a phrase + reproduces the identical account (same wallet addresses, same identity key, + same per-profile DEKs). + - MUST accept any capitalisation and any run of whitespace between words + (including newlines), normalising before parsing. Rejecting a correct phrase + over a capital letter is a usability trap, and lowercasing cannot change + which English BIP-39 word a token is. + - MUST reject an unknown word, a failed checksum, or a wrong word count with + `SessionError::InvalidRecoveryPhrase`, whose message MUST NOT contain any + word of the phrase. - `Session::unlock_master_seed(backend, path, password) -> Result` - - MUST load a file written by `enroll_master_seed` (the `BlsSigning` scheme) and - unlock it; a scheme mismatch, wrong password, missing file, or tampered - ciphertext MUST surface as `SessionError::Keystore`. + - MUST read the versioned envelope (§3.4) and return the account unlocked. + - MUST reject a pre-envelope legacy blob with `SessionError::LegacySeedFormat` + and MUST NOT reinterpret its bytes as BIP-39 entropy (§3.4). + - MUST reject an unrecognised envelope version or kind with + `SessionError::UnsupportedEnvelopeVersion` / `SessionError::UnsupportedSeedKind`. + - MUST surface a missing blob, wrong password, or tampered ciphertext as + `SessionError::Keystore`. ### 3.2 `UnlockedIdentity` @@ -137,34 +152,63 @@ A live, in-memory identity holding a decrypted `SignerHandle`. ### 3.3 `UnlockedMasterSeed` -A live, in-memory master HD seed (decrypted) that exposes the raw seed as a -primitive alongside the seed-derived identity key and DEK. Obtained from -`Session::enroll_master_seed` / `Session::unlock_master_seed`. The seed lives in a -`Zeroizing` buffer and is wiped on drop; the type MUST NOT implement `Clone` and -its `Debug` impl MUST redact the seed. - -- `SEED_LEN: usize = 32` — the master HD seed length. It equals the byte length - wallet-backend's `MasterKey::from_seed_bytes` and dig-app's master-seed model - expect, and the `BlsSigning` storage scheme's secret length. -- `master_seed(&self) -> Zeroizing<[u8; SEED_LEN]>` — the raw master seed bytes, - a PRIMITIVE. This is the value an app-tier consumer feeds to - `MasterKey::from_seed_bytes(handle.master_seed())`. It MUST be returned as a - `Zeroizing` byte array, never a wallet-backend type (see §2 layering). +A live, in-memory DIG account root: the decrypted BIP-39 entropy, exposing the +expanded master HD seed as a primitive, the recovery phrase, and the +seed-derived identity key and DEK. Obtained from +`Session::enroll_master_seed`, `Session::enroll_from_recovery_phrase` or +`Session::unlock_master_seed`. The entropy lives in a `Zeroizing` buffer and is +wiped on drop; the type MUST NOT implement `Clone` and its `Debug` impl MUST +redact the secret. + +#### 3.3.0 The root is BIP-39 entropy; the seed is EXPANDED from it (normative) + +A 24-word BIP-39 phrase carries an implicit promise that any conforming wallet +restores it. Standard Chia wallets (Sage, the reference client, +`chia-wallet-sdk`) honour it as + +```text +phrase -> 32-byte entropy -> PBKDF2-HMAC-SHA512("mnemonic", 2048) -> 64-byte seed -> EIP-2333 +``` + +- The stored secret MUST be the `ENTROPY_LEN`-byte BIP-39 **entropy**. +- Every derivation — identity, per-profile identity, per-profile DEK, and the + seed handed to a wallet — MUST read the seed produced by expanding that + entropy: `entropy -> mnemonic -> to_seed("")`. Implementations MUST NOT feed + the entropy to `chia_bls::SecretKey::from_seed` directly; that skips PBKDF2 + and silently derives a different, plausible wallet, so a user restoring a DIG + phrase in another wallet would see an empty account with no error at all + (dig_ecosystem #1759). +- The BIP-39 passphrase MUST be the **empty string**, matching Chia. A non-empty + passphrase forks the account from every standard client. +- Because one expanded seed feeds every derivation, no two derivations can + disagree about the account root. + +- `ENTROPY_LEN: usize = 32` — the stored BIP-39 entropy length. 32 bytes is + exactly the entropy of a 24-word English mnemonic, so entropy and phrase + convert both ways losslessly. +- `MASTER_SEED_LEN: usize = 64` — the expanded seed length, fixed by BIP-39. +- `RECOVERY_PHRASE_WORDS: usize = 24`. +- `master_seed(&self) -> Zeroizing<[u8; MASTER_SEED_LEN]>` — the EXPANDED master + HD seed, a PRIMITIVE. This is the value an app-tier consumer feeds to + `MasterKey::from_seed_bytes(handle.master_seed().to_vec())`. It MUST be + returned as a `Zeroizing` byte array, never a wallet-backend type (see §2 + layering). +- `recovery_phrase(&self) -> Zeroizing` — the `RECOVERY_PHRASE_WORDS` + words, lowercase and single-space separated. It MUST take `&self`: showing a + user their phrase MUST NOT consume or invalidate the handle. The returned + string MUST be `Zeroizing`, and implementations MUST NOT log it. - `public_key(&self) -> [u8; 48]` — the 48-byte compressed BLS12-381 G1 - dig-identity key derived from the seed. It MUST equal - `dig_identity::public_key_bytes(derive_identity_sk(master_secret_key_from_seed(seed)))` - and therefore the 0.2.0 identity path's public key for the same seed. + dig-identity key derived from the EXPANDED seed. It MUST equal + `dig_identity::public_key_bytes(derive_identity_sk(master_secret_key_from_seed(master_seed)))`. - `sign(&self, msg: &[u8]) -> [u8; 96]` — sign with the seed-derived identity key; the 96-byte G2 signature MUST verify under `public_key()`. - `signing_fn(&self) -> Arc [u8; 96] + Send + Sync>` — a - standalone signing primitive owning its own zeroizing seed copy; MUST remain - usable after the handle is dropped. + standalone signing primitive owning its own zeroizing copy of the root; MUST + remain usable after the handle is dropped. - `derive_symmetric_key(&self, label: &[u8]) -> Zeroizing<[u8; 32]>` — the - per-profile DEK. It MUST be **byte-identical** to - `UnlockedIdentity::derive_symmetric_key` (§3.2) for the same underlying identity - and label: the identity scalar is re-derived from the seed and fed to the SAME - frozen HKDF construction. This preserves §5.1 at-rest back-compat when a - consumer migrates from the identity-scalar path to the master-seed path. + per-profile DEK. The identity scalar MUST be derived from the EXPANDED seed and + fed to the SAME frozen HKDF construction as `UnlockedIdentity::derive_symmetric_key` + (§3.2), so all paths stay byte-compatible for one root. #### 3.3.1 Per-profile methods (0.4.0, ADDITIVE) @@ -194,14 +238,42 @@ message, and label: byte-for-byte. Each distinct `profile_ix` yields a distinct, deterministic key and DEK. -### 3.4 `SigningFn` +### 3.4 The versioned seed envelope (at-rest format) + +The stored account root MUST be sealed in a versioned envelope, because the two +DIG generations of stored bytes are byte-for-byte **indistinguishable**: a legacy +blob's 32 bytes are a raw master seed, a current blob's are BIP-39 entropy. +Reinterpreting one as the other does not fail — it derives a different but +entirely plausible wallet, silently. + +- The sealed plaintext MUST be `version:u8 || kind:u8 || secret`, sealed with + `dig_keystore::opaque::seal` (magic `DIGOP1`) — the same audited container + (Argon2id + AES-256-GCM + CRC-32) every `Keystore` file uses. +- The current layout version is `0x01`; the only kind this crate WRITES is + `0x01` = BIP-39 entropy for a 24-word English mnemonic. +- `kind` values are **append-only** (§5.1): a new kind takes a new discriminant + and existing kinds keep their meaning forever. +- A reader MUST detect a pre-envelope legacy blob — a typed `BlsSigning` keystore + file, identified by its `DIGVK1` magic — **before** decryption and MUST fail + with `SessionError::LegacySeedFormat`. It MUST NOT reinterpret those bytes as + entropy under any circumstance. The account is recovered by re-enrolling from + its recovery phrase. +- A reader MUST reject an unrecognised `version` or `kind` rather than guessing. + +### 3.5 `SigningFn` `Arc K::Signature + Send + Sync>` — the injected primitive. -### 3.5 `SessionError` / `Result` +### 3.6 `SessionError` / `Result` - `SessionError::Keystore(dig_keystore::KeystoreError)` — transparent wrap. - `SessionError::EmptySeed` — enrollment given empty seed material. +- `SessionError::LegacySeedFormat` — the stored blob predates the versioned + envelope; its bytes MUST NOT be reinterpreted (§3.4). +- `SessionError::UnsupportedEnvelopeVersion(u8)` / `SessionError::UnsupportedSeedKind(u8)` + — an envelope written by a newer build. +- `SessionError::InvalidRecoveryPhrase` — not a valid 24-word English BIP-39 + mnemonic. Its message MUST NOT contain any word of the phrase. ## 4. Custody invariants (MUST) @@ -222,6 +294,12 @@ and DEK. - **No IPC crossing.** An `UnlockedIdentity` MUST NOT cross an IPC boundary; it belongs solely to the user-app process that owns the identity (dig_ecosystem #908). Downstreams receive a `SigningFn`, never the handle. +- **No secret in an error message.** A `SessionError` MUST NOT carry secret + material. In particular `InvalidRecoveryPhrase` MUST NOT echo any submitted + word, since a phrase is the whole account. +- **Never log a recovery phrase.** `recovery_phrase()` returns `Zeroizing` + and MUST NOT be logged. `dig-logging`'s BIP-39 wordlist redactor is a backstop, + not a licence. - **No unsafe.** The crate MUST forbid `unsafe` code (`#![forbid(unsafe_code)]`). ## 5. Conformance tests @@ -245,9 +323,11 @@ An implementation MUST ship tests proving: The master-seed path (§3.3) additionally MUST prove: -- `master_seed()` returns exactly the enrolled `SEED_LEN` seed bytes. (MS-1) -- The seed-derived public key equals the dig-identity canonical key AND the - identity-scalar path's public key for the same seed. (MS-2) +- `master_seed()` returns the `MASTER_SEED_LEN`-byte EXPANDED seed — equal to an + independently computed `entropy -> mnemonic -> to_seed("")` and NOT the stored + entropy. (MS-1) +- The seed-derived public key equals the dig-identity canonical key for the + EXPANDED seed AND the identity-scalar path's public key for that seed. (MS-2) - `derive_symmetric_key` on the master-seed path is byte-identical to the identity-scalar path (and to dig-app's reference DEK) for the same seed and label, incl. a frozen golden vector. (MS-3) @@ -273,3 +353,33 @@ The per-profile methods (§3.3.1, 0.4.0) additionally MUST prove: [`dig-keystore`]: https://crates.io/crates/dig-keystore [`dig-identity`]: https://crates.io/crates/dig-identity + +The Chia-conformant derivation (§3.3.0) and the envelope (§3.4) additionally +MUST prove: + +- A fixed public 24-word phrase resolves to a **hardcoded literal** bech32m + address produced independently via `chia-wallet-sdk` — the same address a + standard Chia wallet shows for those words. Both sides MUST NOT be computed + live, or a dependency bump could move them together and mask a regression. + (CONF-1) +- The entropy-as-seed derivation is **no longer reachable**: the fixture still + reproduces the pre-#1759 address literal (proving the test discriminates), and + the crate does not. (CONF-2) +- A pre-envelope legacy blob FAILS CLOSED with `SessionError::LegacySeedFormat` + and yields no handle and no address. (CONF-3) +- A phrase round-trips to an identical account — wallet address AND identity key, + which derive through different paths. (CONF-4) +- Using **two accounts with different entropy**, each account's reported phrase + restores THAT account's frozen address and identity key. A single-account + round-trip is blind here: a `recovery_phrase()` that ignored the live root would + return a self-consistent phrase for the WRONG account. (CONF-4b) +- `recovery_phrase()` does not consume the handle, and the handle still signs + afterwards. (CONF-5) +- The identity key and the DEK derive from the EXPANDED seed, asserted against + the expanded-seed value AND rejecting the entropy-derived value — the two + placements are otherwise indistinguishable. (CONF-6) +- An invalid phrase (short, bad checksum, unknown word) is rejected with + `InvalidRecoveryPhrase` and the message echoes no submitted word. (CONF-7) +- A phrase is accepted with mixed case and irregular whitespace. (CONF-8) +- Re-enrolling over an existing account is refused. (CONF-9) +- An unrecognised envelope version or kind is refused. (CONF-10) diff --git a/tests/chia_conformance.rs b/tests/chia_conformance.rs index b72948d..98d50f9 100644 --- a/tests/chia_conformance.rs +++ b/tests/chia_conformance.rs @@ -51,6 +51,21 @@ const SAGE_ADDRESS_0: &str = "xch16grurcglcwcv6arjarr720yd9wqhp9gkx3k8h25lhwg8pl /// regression can be named and excluded. const PRE_1759_ADDRESS_0: &str = "xch1jcvy96pjkh7wn5zvx6atwztru6kmhhyekd52td566leshf0d4tvsrtxr7a"; +/// A SECOND, non-degenerate account: entropy `0x5A` repeated. Its 24 words and +/// address are unrelated to [`TEST_PHRASE`]'s. +/// +/// [`TEST_PHRASE`] is all-ZERO entropy, which makes it a blind fixture for any +/// property about *which* account is in play — a bug that ignored the live +/// entropy and derived from zeros would be indistinguishable from correct. A +/// surviving mutation proved exactly that, so every "the right account" assertion +/// below uses two accounts and a truthful control. +const OTHER_PHRASE: &str = + "fog spot notable regret pizza coffee harvest ensure fog spot notable regret pizza coffee harvest ensure fog spot notable regret pizza coffee harvest equal"; + +/// Wallet address 0 of [`OTHER_PHRASE`] under the standard Chia derivation. +/// Frozen literal, produced independently through `chia-wallet-sdk`. +const OTHER_ADDRESS_0: &str = "xch1vpxzuu6aqfu790qcrcppcr2gmju4f5tpuuznuv2lx3g79v2jxc7qxttpzt"; + const PASSWORD: &str = "correct horse battery staple"; fn backend() -> Arc { @@ -207,6 +222,46 @@ fn recovery_phrase_round_trips_to_an_identical_account() { ); } +/// CONF-4b (two actors): the phrase describes THIS account, not some other one. +/// +/// A `recovery_phrase()` that ignored the live entropy and derived from a fixed +/// value would pass every single-account round-trip: the phrase it returns is +/// self-consistent, it just belongs to the WRONG account — and the user who wrote +/// it down has backed up nothing. Only a second account with different entropy can +/// see that, so both accounts are exercised and each phrase must resolve to its +/// OWN frozen address. +#[test] +fn each_accounts_phrase_restores_that_account_and_not_another() { + let a = enrol_from_phrase(TEST_PHRASE); + let b = enrol_from_phrase(OTHER_PHRASE); + + assert_ne!( + &*a.recovery_phrase(), + &*b.recovery_phrase(), + "two accounts must not report the same recovery phrase" + ); + + // Each account's own phrase must round-trip to that account's frozen address. + for (handle, expected) in [(&a, SAGE_ADDRESS_0), (&b, OTHER_ADDRESS_0)] { + assert_eq!( + wallet_address(&*handle.master_seed(), 0), + expected, + "the account must sit at its own standard Chia address" + ); + let restored = enrol_from_phrase(&handle.recovery_phrase()); + assert_eq!( + wallet_address(&*restored.master_seed(), 0), + expected, + "the phrase this account reports must restore THIS account" + ); + assert_eq!( + restored.public_key(), + handle.public_key(), + "the phrase this account reports must restore THIS identity key" + ); + } +} + /// CONF-5: showing the phrase must not cost the user their session. /// /// `recovery_phrase` takes `&self`; a consuming signature would force a UI to From 44e6e8b9ecd1ff3612f7b032f516c488bf145eb3 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 29 Jul 2026 01:39:42 -0700 Subject: [PATCH 4/4] fix(session)!: zeroize the mnemonic; carry the legacy-remediation contract; pin the DEK literals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate fixes for dig_ecosystem#1759. **bip39's `zeroize` feature is now enabled, and the comment claiming it does not exist is corrected.** It does: bip39 2.2 derives `Zeroize + ZeroizeOnDrop` on `Mnemonic` behind an optional-dependency feature (which is why it is absent from the `[features]` table). `expand_entropy` builds a `Mnemonic` on EVERY derivation and its word indices are a complete copy of the account root, so without the feature un-wiped copies of the root accumulated in memory. CONF-11 asserts the trait bound at COMPILE time, so dropping the feature now fails to build rather than silently regressing. **The legacy-account contract now lives on the crate, not just in a ticket.** Legacy `DIGVK1` blobs DO exist in the field — the published 0.4 line auto-enrolled an account at first boot with no user action — and such an account is WEDGED, not merely unreadable: unlock returns `LegacySeedFormat` and never a handle, re-enrolment at the same key returns `AlreadyExists`, and no pre-envelope release ever showed the user 24 words. A consumer that merely logs the error leaves the account permanently and silently without a signer. The `LegacySeedFormat` rustdoc, `SPEC.md` §3.3.0 and the README now state the required remediation — detect specifically, PRESERVE (never delete) the sealed blob, surface it in the UI, re-enrol — and that adopting this version without it is a regression. **The re-pin rationale is corrected to the true one.** It is NOT that no account exists; it is that no sealed ARTIFACT keyed by the old derivation exists (no profile blobs, no wallet store, money path unmerged), so nothing encrypted under the old DEK becomes unreadable. `SPEC.md` says so, and says a further change needs a migration rather than a second re-pin. Also: CONF-12 covers a VALID 12/15/18/21-word phrase, which is the only input that reaches the entropy-length guard (malformed phrases die inside bip39 first) — deleting that guard previously left all 41 tests green while turning a legitimate shorter phrase into a `copy_from_slice` panic on the restore path. The two master-seed DEK golden vectors are now HEX LITERALS as their comments always claimed; both sides had been computed live, so a bip39/dig-identity bump could have moved them together. CONF-1/4/5/6/8 now drive from the NON-degenerate fixture, so the account-identity-blind class has three nets instead of one. And `recovery_phrase`'s docs warn that `Zeroizing` derefs to `String` (so a log call compiles) and that dig-logging's redactor is bundle-time, not write-time. Refs DIG-Network/dig_ecosystem#1759 Co-Authored-By: Claude --- Cargo.lock | 3 +- Cargo.toml | 8 ++-- README.md | 18 ++++++-- SPEC.md | 42 ++++++++++++++++++ src/error.rs | 37 ++++++++++++++-- src/master_seed.rs | 23 +++++++--- tests/chia_conformance.rs | 93 +++++++++++++++++++++++++++++++++++---- tests/master_seed.rs | 55 ++++++++++++++++++----- 8 files changed, 243 insertions(+), 36 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1903b54..acc1719 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -179,6 +179,7 @@ dependencies = [ "bitcoin_hashes", "serde", "unicode-normalization", + "zeroize", ] [[package]] @@ -986,7 +987,7 @@ dependencies = [ [[package]] name = "dig-session" -version = "0.5.0" +version = "0.5.1" dependencies = [ "bip39", "chia", diff --git a/Cargo.toml b/Cargo.toml index cf845c7..7eb83a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dig-session" -version = "0.5.0" +version = "0.5.1" edition = "2021" rust-version = "1.75" description = "DIG session/keystore layer: unlock identity signing keys and inject signing primitives. Composes dig-keystore (encrypted key storage) + dig-identity (canonical BLS identity derivation) behind one curated, custody-safe facade." @@ -44,8 +44,10 @@ thiserror = "1" # the Chia convention) to expand entropy into the 64-byte master HD seed. This # expansion is what makes a DIG phrase restore identically in Sage; deriving # straight from the entropy silently forks the wallet (dig_ecosystem #1759). -# Default features only: English wordlist + std, no other languages pulled in. -bip39 = "2.0" +# `zeroize` makes `Mnemonic` `Zeroize + ZeroizeOnDrop`, which MATTERS here: `expand_entropy` builds a +# `Mnemonic` on EVERY derivation, and its word indices are a complete copy of the account root — without +# the feature those copies accumulate un-wiped in memory. No other languages pulled in. +bip39 = { version = "2.2", features = ["zeroize"] } # Per-profile DEK derivation. Versions pinned to MATCH dig-app's DEK construction # (dig-app-core keystore/secrets.rs) byte-for-byte: HKDF-SHA256, RFC 5869. A # different hkdf/sha2 major here would risk a different DEK and break at-rest diff --git a/README.md b/README.md index 36398e1..4b538ed 100644 --- a/README.md +++ b/README.md @@ -93,10 +93,20 @@ let restored = Session::enroll_from_recovery_phrase( # } ``` -A blob written before the versioned seed envelope held a *raw* seed rather than -entropy. Because the two are byte-indistinguishable, `unlock_master_seed` fails -closed with `SessionError::LegacySeedFormat` instead of reinterpreting them — -silently deriving a plausible wrong wallet would be far worse than an error. +### Legacy accounts: fail-closed, and your job to remediate + +A blob written before the versioned seed envelope held a *raw* seed rather than entropy. Because the +two are byte-indistinguishable, `unlock_master_seed` fails closed with +`SessionError::LegacySeedFormat` instead of reinterpreting them — silently deriving a plausible wrong +wallet would be far worse than an error. + +**Those blobs exist in the field**: the published 0.4 line auto-enrolled an account at first boot with +no user action. A legacy account is **wedged** — unlock refuses, and re-enrolling at the same key +returns `AlreadyExists` because enrolment never overwrites a custody root. So **adopting 0.5 requires +a legacy-detection-and-re-enrolment path in your app**: detect the variant specifically, *preserve* +(never delete) the old sealed blob — it may hold value and its password may live in an OS credential +store — tell the user in the UI, then re-enrol and show the new phrase. Merely logging the error +leaves the account permanently and silently without a signer. See the `LegacySeedFormat` rustdoc. ## Design notes diff --git a/SPEC.md b/SPEC.md index 165ca58..63eee2e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -183,6 +183,39 @@ phrase -> 32-byte entropy -> PBKDF2-HMAC-SHA512("mnemonic", 2048) -> 64-byte see - Because one expanded seed feeds every derivation, no two derivations can disagree about the account root. +##### Legacy accounts EXIST, and adopting this version REQUIRES a re-enrolment path (normative) + +Changing the root also changed, for a given 32 stored bytes, the identity public key and the +per-profile DEK. That is a §5.1-class change to a stored-secret derivation. It is sound for **one +specific reason, and it is not the absence of accounts**: + +- **Legacy account blobs DO exist in the field.** The published `dig-session` 0.4 line auto-enrolled an + account at first boot with no user action, so every installed consumer that has booted once holds a + pre-envelope `DIGVK1` blob. This has been verified on a real host. Any statement that the exposed + population is zero is FALSE and MUST NOT be relied on. +- **What is actually absent is any sealed ARTIFACT keyed by the old derivation** — no sealed profile + blobs, no wallet store, no funded account (the money path is unmerged). So nothing that was + *encrypted* under the old DEK becomes unreadable, and nothing on chain moves. That, and only that, + is why the DEK golden vectors were re-pinned rather than migrated. +- A **further** change to any stored-secret derivation MUST ship a migration path, not a re-pin. + +Consequently, a consumer adopting this version MUST implement a legacy-detection-and-re-enrolment +path. A legacy account is **WEDGED**: `unlock_master_seed` returns +[`SessionError::LegacySeedFormat`] and never a handle, and `enroll_master_seed` at the same key +returns `AlreadyExists` because enrolment refuses to overwrite a custody root, so there is no +in-crate route back. The consumer MUST: + +1. detect `LegacySeedFormat` **specifically** — a catch-all log line is a defect, because it leaves + the account permanently and silently without a signer; +2. **preserve** the existing blob rather than deleting it. It is password-sealed, its password may + live in an OS credential store this crate cannot read, and a balance therefore cannot be ruled + out — deleting it can destroy the only copy of a funded key; +3. surface the situation **in the UI**, stating that the account must be re-created and that the + preserved file is the only copy of the old key; +4. re-enrol (at a fresh key, or after moving the old blob aside) and show the new recovery phrase. + +Shipping this version without that path is a regression for every already-enrolled install. + - `ENTROPY_LEN: usize = 32` — the stored BIP-39 entropy length. 32 bytes is exactly the entropy of a 24-word English mnemonic, so entropy and phrase convert both ways losslessly. @@ -383,3 +416,12 @@ MUST prove: - A phrase is accepted with mixed case and irregular whitespace. (CONF-8) - Re-enrolling over an existing account is refused. (CONF-9) - An unrecognised envelope version or kind is refused. (CONF-10) +- `bip39::Mnemonic` is `ZeroizeOnDrop` — a COMPILE-TIME assertion that the `zeroize` feature is + enabled. `expand_entropy` builds a `Mnemonic` on every derivation and its word indices are a + complete copy of the account root, so without the feature un-wiped copies of the root accumulate + in memory while every behavioural test stays green. (CONF-11) +- A VALID BIP-39 phrase of a shorter supported length (12/15/18/21 words) is REJECTED with + `InvalidRecoveryPhrase`, never a panic. Malformed phrases fail inside bip39 and never reach the + length guard, so only a valid-but-shorter phrase exercises it — without this case, deleting the + guard leaves the suite green while making a legitimate user input a `copy_from_slice` panic on the + restore path. (CONF-12) diff --git a/src/error.rs b/src/error.rs index 320f5e1..db0e71e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -28,9 +28,40 @@ pub enum SessionError { /// This fails CLOSED on purpose. The two encodings are byte-for-byte /// indistinguishable, so reinterpreting the old bytes as entropy would /// derive a different, entirely plausible wallet with no error at all — the - /// user would simply see the wrong (empty) account. The account must be - /// re-enrolled from its recovery phrase via - /// [`Session::enroll_from_recovery_phrase`](crate::Session::enroll_from_recovery_phrase). + /// user would simply see the wrong (empty) account. + /// + /// # A consumer MUST handle this variant explicitly (HARD REQUIREMENT) + /// + /// A legacy account is **WEDGED**, not merely unreadable, and no amount of + /// retrying changes that: + /// + /// - [`Session::unlock_master_seed`](crate::Session::unlock_master_seed) + /// returns this error and never a handle; + /// - [`Session::enroll_master_seed`](crate::Session::enroll_master_seed) at + /// the same key returns `AlreadyExists`, because enrolment refuses to + /// overwrite a custody root; + /// - the pre-envelope releases exposed no `recovery_phrase()`, so the user + /// was never shown 24 words, and a legacy raw seed has no phrase that + /// means anything under the current scheme. + /// + /// So a consumer that merely logs this error leaves the account permanently + /// and silently without a signer. The required remediation, which the + /// consumer owns because only it has a UI: + /// + /// 1. **Detect** this variant specifically — never a catch-all log line. + /// 2. **Preserve** the existing blob (copy it aside; do NOT delete it). It + /// is password-sealed and may hold value, and its password may live in an + /// OS credential store this crate cannot read. Discarding it can destroy + /// the only copy of a funded key. + /// 3. **Tell the user, in the UI**, that the account must be re-created, and + /// that the preserved file is their only copy of the old key. + /// 4. **Re-enrol** at a fresh key (or after moving the old blob aside) via + /// [`enroll_master_seed`](crate::Session::enroll_master_seed) or + /// [`enroll_from_recovery_phrase`](crate::Session::enroll_from_recovery_phrase), + /// then show the new recovery phrase. + /// + /// Adopting this version WITHOUT that path is a regression for every + /// already-enrolled install (dig_ecosystem #1759). #[error( "this account was stored under the legacy raw-seed format and cannot be \ read as BIP-39 entropy; re-enrol it from its 24-word recovery phrase" diff --git a/src/master_seed.rs b/src/master_seed.rs index c5ed3fe..5563978 100644 --- a/src/master_seed.rs +++ b/src/master_seed.rs @@ -125,11 +125,17 @@ pub type IdentitySigningFn = Arc [u8; IDENTITY_SIGNATURE_LEN] + /// /// # Custody note /// -/// `bip39::Mnemonic` is a plain `Clone` value over word indices with no -/// `Zeroize`/`Drop` impl (bip39 2.x exposes no `zeroize` feature), so it cannot -/// be wiped in place. Every caller therefore confines it to the smallest -/// possible scope and routes the byte/string buffers *we* own through +/// The returned `Mnemonic`'s word indices are a COMPLETE copy of the account +/// root, and one is built on every derivation. dig-session therefore enables +/// bip39's `zeroize` feature, which makes `Mnemonic` `Zeroize + ZeroizeOnDrop`, +/// so each copy is wiped when it drops. Callers still confine it to the +/// narrowest possible scope and route the byte/string buffers *we* own through /// [`Zeroizing`]. +/// +/// Residual, not fixed by that feature: bip39's own intermediate `to_string()` +/// heap buffer and its transient `[u8; 33]` entropy+checksum stack array are not +/// wiped upstream. Both are short-lived and inside bip39; fixing them requires +/// an upstream change. fn mnemonic_from_entropy(entropy: &[u8; ENTROPY_LEN]) -> Mnemonic { Mnemonic::from_entropy_in(Language::English, entropy) .expect("BIP-39 accepts any 32-byte entropy (24 words); length is a compile-time constant") @@ -241,9 +247,14 @@ impl UnlockedMasterSeed { /// [`Session::enroll_from_recovery_phrase`](crate::Session::enroll_from_recovery_phrase) /// on a new machine — reproduces this exact account. /// + /// # Never log this (the type will NOT stop you) + /// /// The returned `String` is [`Zeroizing`], so its heap buffer is wiped on - /// drop. **Never log it**; `dig-logging`'s BIP-39 redactor is a backstop, - /// not a licence. + /// drop — but `Zeroizing` **derefs to `String`**, so + /// `info!("{phrase}")` compiles without complaint. And `dig-logging`'s + /// BIP-39 redactor runs at **bundle time**, not at write time: a logged + /// phrase sits in plaintext on disk until (and unless) a bundle is built. + /// Treat that redactor as a last-resort scrub, never as protection. pub fn recovery_phrase(&self) -> Zeroizing { Zeroizing::new(mnemonic_from_entropy(&self.entropy).to_string()) } diff --git a/tests/chia_conformance.rs b/tests/chia_conformance.rs index 98d50f9..f80f476 100644 --- a/tests/chia_conformance.rs +++ b/tests/chia_conformance.rs @@ -103,6 +103,15 @@ fn test_entropy() -> [u8; ENTROPY_LEN] { .expect("a 24-word mnemonic carries exactly 32 bytes of entropy") } +/// The 32 bytes of entropy behind [`OTHER_PHRASE`]. +fn other_entropy() -> [u8; ENTROPY_LEN] { + bip39::Mnemonic::parse_in_normalized(bip39::Language::English, OTHER_PHRASE) + .expect("a valid 24-word fixture") + .to_entropy() + .try_into() + .expect("a 24-word mnemonic carries exactly 32 bytes of entropy") +} + fn enrol_from_phrase(phrase: &str) -> dig_session::UnlockedMasterSeed { Session::enroll_from_recovery_phrase( backend(), @@ -200,7 +209,9 @@ fn legacy_raw_seed_blob_fails_closed() { /// an expansion applied to only one of them would pass a wallet-only test. #[test] fn recovery_phrase_round_trips_to_an_identical_account() { - let original = enrol_from_phrase(TEST_PHRASE); + // Deliberately the NON-DEGENERATE account: all-zero entropy cannot distinguish "restored this + // account" from "restored a fixed account". + let original = enrol_from_phrase(OTHER_PHRASE); let phrase = original.recovery_phrase(); assert_eq!( @@ -268,15 +279,19 @@ fn each_accounts_phrase_restores_that_account_and_not_another() { /// choose between displaying the words and staying unlocked. #[test] fn recovery_phrase_does_not_consume_the_handle() { - let handle = enrol_from_phrase(TEST_PHRASE); + // The NON-DEGENERATE account, so the reported phrase must actually be THIS account's. + let handle = enrol_from_phrase(OTHER_PHRASE); let first = handle.recovery_phrase(); let second = handle.recovery_phrase(); assert_eq!(&*first, &*second, "the phrase must be stable"); assert_eq!( first.as_str(), - TEST_PHRASE.split_whitespace().collect::>().join(" "), - "the phrase must be the canonical space-separated 24 words" + OTHER_PHRASE + .split_whitespace() + .collect::>() + .join(" "), + "the phrase must be the canonical space-separated 24 words for THIS account" ); // The handle is still fully usable afterwards. @@ -292,8 +307,10 @@ fn recovery_phrase_does_not_consume_the_handle() { /// rejecting the entropy-derived value is what distinguishes the two placements. #[test] fn identity_and_dek_derive_from_the_expanded_seed() { - let handle = enrol_from_phrase(TEST_PHRASE); - let entropy = test_entropy(); + // The NON-DEGENERATE account: with all-zero entropy an implementation that ignored the live root + // would land on the same values by accident. + let handle = enrol_from_phrase(OTHER_PHRASE); + let entropy = other_entropy(); let from_expanded = public_key_bytes(&derive_identity_sk(&master_secret_key_from_seed( &*handle.master_seed(), @@ -363,8 +380,8 @@ fn invalid_recovery_phrases_are_rejected_without_echoing_them() { /// and irregular whitespace — and still resolves to the same account. #[test] fn recovery_phrase_accepts_user_typed_whitespace_and_case() { - let canonical = enrol_from_phrase(TEST_PHRASE); - let messy = TEST_PHRASE.replace("abandon", "Abandon").replace(' ', " "); + let canonical = enrol_from_phrase(OTHER_PHRASE); + let messy = OTHER_PHRASE.replace("spot", "Spot").replace(' ', " "); let restored = enrol_from_phrase(messy.trim()); assert_eq!( @@ -437,3 +454,63 @@ fn unknown_envelope_version_or_kind_is_refused() { ); } } + +/// CONF-11: `bip39::Mnemonic` is `ZeroizeOnDrop`, i.e. the crate's `zeroize` feature is enabled. +/// +/// This is a COMPILE-TIME assertion, and it is the only thing standing between the feature and a +/// silent regression: `expand_entropy` builds a `Mnemonic` on every derivation and its word indices +/// are a complete copy of the account root, so dropping the feature would leave un-wiped copies of the +/// root accumulating in memory — with every behavioural test still green. +#[test] +fn mnemonic_is_zeroize_on_drop() { + fn requires_zeroize_on_drop() {} + requires_zeroize_on_drop::(); +} + +/// CONF-12: a VALID BIP-39 phrase of a supported-but-shorter length (12, 15, 18 or 21 words) is +/// rejected with an error, never a panic. +/// +/// DIG accounts are 24 words / 32 bytes of entropy. A 12-word phrase is perfectly valid BIP-39 — it +/// parses, its checksum passes — it just carries 16 bytes, so it reaches the length guard in +/// `entropy_from_phrase` rather than failing inside bip39 like a malformed phrase does. CONF-7's cases +/// all die inside bip39 first and never exercise that guard, so without this test deleting the guard +/// leaves the whole suite green while turning a legitimate user input into a `copy_from_slice` panic +/// on the restore path. +#[test] +fn valid_but_shorter_bip39_phrases_are_rejected_not_panicked() { + // Each is a genuine, checksum-valid English BIP-39 mnemonic of all-zero entropy. + let shorter = [ + // 12 words / 16 bytes + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + // 15 words / 20 bytes + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon address", + // 18 words / 24 bytes + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon abandon agent", + // 21 words / 28 bytes + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon abandon abandon abandon abandon admit", + ]; + + for phrase in shorter { + // Fixture check: bip39 itself ACCEPTS these, so the rejection below must come from DIG's + // 24-word requirement and not from a parse failure — otherwise this test proves nothing. + assert!( + bip39::Mnemonic::parse_in_normalized(bip39::Language::English, phrase).is_ok(), + "fixture check: this must be a VALID BIP-39 phrase, or the guard is never reached" + ); + + let err = Session::enroll_from_recovery_phrase( + backend(), + BackendKey::new("acct"), + Password::from(PASSWORD), + phrase, + ) + .expect_err("a phrase shorter than 24 words must be rejected"); + assert!( + matches!(err, SessionError::InvalidRecoveryPhrase), + "expected InvalidRecoveryPhrase, got {err:?}" + ); + } +} diff --git a/tests/master_seed.rs b/tests/master_seed.rs index a55e3de..d0b5ba0 100644 --- a/tests/master_seed.rs +++ b/tests/master_seed.rs @@ -28,6 +28,17 @@ const PASSWORD: &str = "correct horse battery staple"; /// `keystore/secrets.rs`) — mirrored from `dig_constants::PROFILE_DEK_LABEL`. const DIG_APP_DEK_LABEL: &[u8] = b"dig-app:profile-dek:v2"; +/// The profile-0 and profile-1 DEKs for [`ENTROPY`] + [`DIG_APP_DEK_LABEL`], as FROZEN HEX LITERALS. +/// +/// Hardcoded on purpose. `dig_app_reference_dek` reconstructs the HKDF independently, but its INPUT +/// scalar still comes from `bip39` + `dig-identity` — so a bump in either would move the reference and +/// the production value together and mask the drift. Only a literal pins the actual bytes, and these +/// bytes ARE the at-rest contract: a change makes every already-sealed profile blob unreadable (§5.1). +const GOLDEN_DEK_PROFILE_0: &str = + "7bf5db71c6a53f4d52b95ff4408cd2a542ad1f335324a8a26fdb243b846fcf37"; +const GOLDEN_DEK_PROFILE_1: &str = + "eddb892484e73c6046ecdbdd87610f4aba74d4b728addaa7ff20d8c598c729c9"; + /// Independently expand [`ENTROPY`] into the 64-byte master HD seed the BIP-39 / /// Chia way. Deliberately re-derived here from `bip39` rather than calling /// dig-session's own expansion, so a drift on either side is caught rather than @@ -176,10 +187,20 @@ fn master_seed_dek_is_byte_identical_to_identity_scalar_path() { #[test] fn master_seed_dek_golden_vector() { - // MS-3 (frozen): a FIXED seed + FIXED label -> the EXACT DEK bytes. Any KDF - // parameter drift fails this literal comparison and flags a §5.1 break. + // MS-3 (frozen): a FIXED entropy + FIXED label -> the EXACT DEK bytes, pinned as a HEX LITERAL. + // Any KDF-parameter or root drift fails this comparison and flags a §5.1 break. + // + // The independently-reconstructed reference is ALSO asserted, so the test catches both a drift in + // the frozen bytes (via the literal) and a divergence between dig-session's HKDF and dig-app's + // (via the reference) — the literal alone could not distinguish those. let scalar = derive_identity_sk(&master_secret_key_from_seed(&expanded())).to_bytes(); - let expected = dig_app_reference_dek(&scalar, DIG_APP_DEK_LABEL); + let reference = dig_app_reference_dek(&scalar, DIG_APP_DEK_LABEL); + let expected = hex::decode(GOLDEN_DEK_PROFILE_0).expect("a valid hex literal"); + assert_eq!( + hex::encode(reference), + GOLDEN_DEK_PROFILE_0, + "dig-app's reference DEK drifted from the frozen literal (§5.1 break)" + ); let handle = Session::enroll_master_seed( backend(), @@ -191,8 +212,9 @@ fn master_seed_dek_golden_vector() { let dek = handle.derive_symmetric_key(DIG_APP_DEK_LABEL); assert_eq!( - &*dek, &expected, - "master-seed DEK must match the reference construction" + &dek[..], + &expected[..], + "master-seed DEK must match the frozen literal" ); } @@ -429,10 +451,21 @@ fn profile_sign_verifies_against_profile_public_key() { #[test] fn profile_dek_golden_vector() { - // PROF-7 (frozen): FIXED seed + profile 1 + FIXED label -> the EXACT DEK bytes - // from dig-app's reference construction over derive_identity_sk_at(master, 1). + // PROF-7 (frozen): FIXED entropy + profile 1 + FIXED label -> the EXACT DEK bytes, pinned as a + // HEX LITERAL, plus dig-app's independently-reconstructed reference over + // derive_identity_sk_at(master, 1). let scalar = derive_identity_sk_at(&master_secret_key_from_seed(&expanded()), 1).to_bytes(); - let expected = dig_app_reference_dek(&scalar, DIG_APP_DEK_LABEL); + let reference = dig_app_reference_dek(&scalar, DIG_APP_DEK_LABEL); + let expected = hex::decode(GOLDEN_DEK_PROFILE_1).expect("a valid hex literal"); + assert_eq!( + hex::encode(reference), + GOLDEN_DEK_PROFILE_1, + "dig-app's reference profile-1 DEK drifted from the frozen literal (§5.1 break)" + ); + assert_ne!( + GOLDEN_DEK_PROFILE_1, GOLDEN_DEK_PROFILE_0, + "fixture check: profile 1 must derive a DIFFERENT DEK from profile 0" + ); let handle = Session::enroll_master_seed( backend(), @@ -442,9 +475,9 @@ fn profile_dek_golden_vector() { ) .unwrap(); assert_eq!( - &*handle.profile_derive_symmetric_key(1, DIG_APP_DEK_LABEL), - &expected, - "profile 1 DEK must match the reference construction over derive_identity_sk_at" + &handle.profile_derive_symmetric_key(1, DIG_APP_DEK_LABEL)[..], + &expected[..], + "profile 1 DEK must match the frozen literal" ); }