From f4838d5d54ddbf7901a29ba2ecece28b24a09a4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Tue, 4 Aug 2026 11:44:48 -0400 Subject: [PATCH 01/28] feat(validator): automate Golden DKG over Iroh --- .config/cspell.yaml | 1 + CHANGELOG.archived.md | 1 + Cargo.lock | 1799 +++++++++++++++++++++- Cargo.toml | 4 + bin/validator/Cargo.toml | 7 +- bin/validator/src/commands/dkg.rs | 10 + bin/validator/src/commands/dkg/board.rs | 1189 ++++++++++++++ bin/validator/src/commands/dkg/runner.rs | 726 +++++++++ bin/validator/src/commands/dkg/tests.rs | 101 ++ 9 files changed, 3797 insertions(+), 41 deletions(-) create mode 100644 bin/validator/src/commands/dkg/board.rs create mode 100644 bin/validator/src/commands/dkg/runner.rs diff --git a/.config/cspell.yaml b/.config/cspell.yaml index 6acc3c6963..47f208294e 100644 --- a/.config/cspell.yaml +++ b/.config/cspell.yaml @@ -16,6 +16,7 @@ words: - ciphertext - Devnet - grpcurl + - Iroh - Merkle - Miden - Midenscan diff --git a/CHANGELOG.archived.md b/CHANGELOG.archived.md index b41d72839a..5000aadde4 100644 --- a/CHANGELOG.archived.md +++ b/CHANGELOG.archived.md @@ -4,6 +4,7 @@ This changelog has been archived. Changes are now documented in pull request des ## Unreleased +- Added a genesis-bound Golden DKG ceremony and Iroh bulletin board for validator storage keys ([#2426](https://github.com/0xMiden/node/issues/2426)). - [BREAKING] Updated `miden-protocol` dependencies to use the `next` branch (v0.16). Block and transaction account updates now use the absolute `AccountPatch` representation instead of the relative `AccountDelta`, and the `miden-tx-batch-prover` crate was renamed to `miden-tx-batch` ([#2282](https://github.com/0xMiden/node/pull/2282)). ## v0.15.0 (2026-06-10) diff --git a/Cargo.lock b/Cargo.lock index e1afcc096a..915a813b85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,16 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + [[package]] name = "aead" version = "0.6.1" @@ -12,6 +22,31 @@ dependencies = [ "inout 0.2.2", ] +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead 0.5.2", + "aes", + "cipher 0.4.4", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -475,6 +510,18 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-trait" version = "0.1.92" @@ -486,6 +533,26 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "pharos", + "rustc_version 0.4.1", +] + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -998,6 +1065,25 @@ dependencies = [ "tokio", ] +[[package]] +name = "bao-tree" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06384416b1825e6e04fde63262fda2dc408f5b64c02d04e0d8b70ae72c17a52b" +dependencies = [ + "blake3", + "bytes", + "futures-lite", + "genawaiter", + "iroh-io", + "positioned-io", + "range-collections", + "self_cell", + "serde", + "smallvec", + "tokio", +] + [[package]] name = "base16ct" version = "1.0.0" @@ -1051,6 +1137,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "binary-merge" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597bb81c80a54b6a4381b23faba8d7774b144c94cbd1d6fe3f1329bd776554ab" + [[package]] name = "bindgen" version = "0.72.1" @@ -1124,6 +1216,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "bon" version = "3.10.0" @@ -1205,6 +1306,9 @@ name = "bytes" version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] [[package]] name = "bytes-utils" @@ -1244,6 +1348,12 @@ dependencies = [ "shlex 2.0.1", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cexpr" version = "0.6.0" @@ -1294,7 +1404,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" dependencies = [ - "aead", + "aead 0.6.1", "chacha20 0.10.2", "cipher 0.5.2", "poly1305", @@ -1428,6 +1538,15 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.19", +] + [[package]] name = "codegen" version = "0.3.0" @@ -1453,6 +1572,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "const-hex" version = "1.19.1" @@ -1507,6 +1635,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cordyceps" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b9ab7e0ca1d179628fa0172b2b97203c7fa0cd81be2448bd446fb9559ca9261" +dependencies = [ + "loom", + "tracing", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1598,6 +1736,15 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.7" @@ -1665,6 +1812,15 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "ctutils" version = "0.4.2" @@ -1686,7 +1842,9 @@ dependencies = [ "curve25519-dalek-derive", "digest 0.11.3", "fiat-crypto", + "rand_core 0.10.1", "rustc_version 0.4.1", + "serde", "subtle", "zeroize", ] @@ -1785,6 +1943,32 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "data-encoding-macro" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6a127ecbb3c4632e1525380e04c0c3fcf8dcb44d32a79ea290d8a36906edcd8" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c54e03a951783e8b327515db3f2a2fd0e3bed362a96b066f341ce66ed49b4ead" +dependencies = [ + "data-encoding", + "syn 3.0.3", +] + [[package]] name = "deadpool" version = "0.12.3" @@ -1864,6 +2048,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" dependencies = [ "const-oid", + "pem-rfc7468", "zeroize", ] @@ -1910,6 +2095,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "diatomic-waker" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" + [[package]] name = "diesel" version = "2.3.12" @@ -1987,6 +2178,18 @@ dependencies = [ "ctutils", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.7" @@ -2004,6 +2207,17 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aeda16ab4059c5fd2a83f2b9c9e9c981327b18aa8e3b313f7e6563799d4f093e" +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "libc", + "once_cell", + "winapi", +] + [[package]] name = "downcast-rs" version = "2.0.2" @@ -2058,6 +2272,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ "pkcs8", + "serdect", "signature", ] @@ -2069,6 +2284,7 @@ checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" dependencies = [ "curve25519-dalek", "ed25519", + "rand_core 0.10.1", "serde", "sha2 0.11.0", "signature", @@ -2115,6 +2331,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -2124,6 +2352,17 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "enum-assoc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed8956bd5c1f0415200516e78ff07ec9e16415ade83c056c230d7b7ea0d55b7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "enum-ordinalize" version = "4.4.2" @@ -2194,6 +2433,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -2366,6 +2625,19 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-buffered" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4421cb78ee172b6b06080093479d3c50f058e7c81b7d577bbb8d118d551d4cd5" +dependencies = [ + "cordyceps", + "diatomic-waker", + "futures-core", + "pin-project-lite", + "spin 0.10.1", +] + [[package]] name = "futures-channel" version = "0.3.34" @@ -2376,6 +2648,19 @@ dependencies = [ "futures-sink", ] +[[package]] +name = "futures-concurrency" +version = "7.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" +dependencies = [ + "fixedbitset", + "futures-core", + "futures-lite", + "pin-project", + "smallvec", +] + [[package]] name = "futures-core" version = "0.3.34" @@ -2399,6 +2684,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -2445,6 +2743,37 @@ dependencies = [ "slab", ] +[[package]] +name = "genawaiter" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86bd0361bcbde39b13475e6e36cb24c329964aa2611be285289d1e4b751c1a0" +dependencies = [ + "futures-core", + "genawaiter-macro", + "genawaiter-proc-macro", + "proc-macro-hack", +] + +[[package]] +name = "genawaiter-macro" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b32dfe1fdfc0bbde1f22a5da25355514b5e450c33a6af6770884c8750aedfbc" + +[[package]] +name = "genawaiter-proc-macro" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784f84eebc366e15251c4a8c3acee82a6a6f427949776ecb88377362a9621738" +dependencies = [ + "proc-macro-error", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "generator" version = "0.8.9" @@ -2511,6 +2840,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "glob" version = "0.3.4" @@ -2704,6 +3043,15 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2742,6 +3090,8 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.2.0", ] @@ -2754,6 +3104,20 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version 0.4.1", + "serde", + "spin 0.9.9", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -2773,19 +3137,96 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] -name = "hkdf" -version = "0.12.4" +name = "hickory-net" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" dependencies = [ - "hmac 0.12.1", -] - -[[package]] -name = "hkdf" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" + "async-trait", + "bytes", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "h2 0.4.15", + "hickory-proto", + "http 1.5.0", + "idna", + "ipnet", + "jni 0.22.4", + "rand 0.10.2", + "rustls 0.23.43", + "thiserror 2.0.19", + "tinyvec", + "tokio", + "tokio-rustls 0.26.4", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni 0.22.4", + "once_cell", + "prefix-trie", + "rand 0.10.2", + "ring", + "thiserror 2.0.19", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni 0.22.4", + "moka", + "ndk-context", + "once_cell", + "parking_lot", + "rand 0.10.2", + "resolv-conf", + "rustls 0.23.43", + "smallvec", + "system-configuration", + "thiserror 2.0.19", + "tokio", + "tokio-rustls 0.26.4", + "tracing", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ "hmac 0.13.0", ] @@ -3081,6 +3522,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "identity-hash" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfdd7caa900436d8f13b2346fe10257e0c05c1f1f9e351f4f5d57c03bd5f45da" + [[package]] name = "idna" version = "1.1.0" @@ -3169,11 +3616,376 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "inplace-vec-builder" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf64c2edc8226891a71f127587a2861b132d2b942310843814d5001d99a1d307" +dependencies = [ + "smallvec", +] + +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2 0.6.5", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", +] + [[package]] name = "ipnet" version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +dependencies = [ + "serde", +] + +[[package]] +name = "iroh" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460de6bc52163b41b1646931f2897e5ab986f0966ade444467fec25024751a72" +dependencies = [ + "backon", + "blake3", + "bytes", + "cfg_aliases", + "ctutils", + "data-encoding", + "derive_more", + "ed25519-dalek", + "futures-util", + "getrandom 0.4.3", + "hickory-resolver", + "http 1.5.0", + "ipnet", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "iroh-relay", + "n0-error", + "n0-future", + "n0-watcher", + "netwatch", + "noq", + "noq-proto", + "noq-udp", + "papaya", + "pin-project", + "portable-atomic", + "rand 0.10.2", + "reqwest", + "rustc-hash", + "rustls 0.23.43", + "rustls-pki-types", + "serde", + "smallvec", + "strum", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "wasm-bindgen-futures", +] + +[[package]] +name = "iroh-base" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6be73e16ee21c923aca9b3121aaa0db936f7c7ecc156ff47b8dac944c68d59a8" +dependencies = [ + "curve25519-dalek", + "data-encoding", + "data-encoding-macro", + "derive_more", + "ed25519-dalek", + "getrandom 0.4.3", + "n0-error", + "rand 0.10.2", + "serde", + "url", + "zeroize", +] + +[[package]] +name = "iroh-blobs" +version = "0.103.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be50b0e2d0a9ba65cee4e0dfb708b3704e02ad12bd4c14c6307e94245943126" +dependencies = [ + "arrayvec", + "bao-tree", + "bytes", + "cfg_aliases", + "chrono", + "constant_time_eq", + "data-encoding", + "derive_more", + "genawaiter", + "getrandom 0.4.3", + "hex", + "iroh", + "iroh-base", + "iroh-io", + "iroh-metrics", + "iroh-tickets", + "iroh-util", + "irpc", + "n0-error", + "n0-future", + "nested_enum_utils", + "postcard", + "rand 0.10.2", + "range-collections", + "redb", + "ref-cast", + "reflink-copy", + "self_cell", + "serde", + "smallvec", + "tokio", + "tracing", +] + +[[package]] +name = "iroh-dns" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46f6a9b39d18e6345f5c151afd299f2488e2cb5c520fe41b107b6bd3dc4c3349" +dependencies = [ + "arc-swap", + "cfg_aliases", + "derive_more", + "hickory-resolver", + "iroh-base", + "n0-error", + "n0-future", + "ndk-context", + "portable-atomic", + "rand 0.10.2", + "rustls 0.23.43", + "simple-dns", + "strum", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "iroh-docs" +version = "0.101.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd1bd5e39d0321a3c4a2bcef9650476c076e2df41a0e84577eca23d6de6c8ab" +dependencies = [ + "anyhow", + "async-channel", + "blake3", + "bytes", + "cfg_aliases", + "derive_more", + "futures-buffered", + "hex", + "iroh", + "iroh-blobs", + "iroh-gossip", + "iroh-metrics", + "iroh-tickets", + "irpc", + "n0-error", + "n0-future", + "num_enum", + "postcard", + "rand 0.10.2", + "redb", + "self_cell", + "serde", + "serde-error", + "strum", + "tempfile", + "thiserror 2.0.19", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", +] + +[[package]] +name = "iroh-gossip" +version = "0.101.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e1dc4b05f73e7a1b9e83b531eb63c3fd671b0af3aeb13b59c546dd7ca747515" +dependencies = [ + "blake3", + "bytes", + "data-encoding", + "derive_more", + "futures-concurrency", + "hex", + "indexmap 2.14.0", + "iroh", + "iroh-base", + "iroh-metrics", + "irpc", + "n0-error", + "n0-future", + "postcard", + "rand 0.10.2", + "serde", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "iroh-io" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a5feb781017b983ff1b155cd1faf8174da2acafd807aa482876da2d7e6577a" +dependencies = [ + "bytes", + "futures-lite", + "pin-project", + "smallvec", + "tokio", +] + +[[package]] +name = "iroh-metrics" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291065721ad7c477b972e581bbc528df031dc8eb5e39fe1ff3300ae5dfb157ef" +dependencies = [ + "iroh-metrics-derive", + "itoa", + "n0-error", + "portable-atomic", + "ryu", + "serde", + "tracing", +] + +[[package]] +name = "iroh-metrics-derive" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae5f0c4405d1fbc9fb16ff422ca40620e93dc36c30ecaba0c2aee3992b7bd48" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "iroh-relay" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24bd586cf927f7b700f56ec3639b53cb5fa901ce284784051ff71092bfbf8193" +dependencies = [ + "blake3", + "bytes", + "cfg_aliases", + "data-encoding", + "derive_more", + "getrandom 0.4.3", + "hickory-resolver", + "http 1.5.0", + "http-body-util", + "hyper 1.11.0", + "hyper-util", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "lru 0.18.2", + "n0-error", + "n0-future", + "noq", + "noq-proto", + "num_enum", + "pin-project", + "postcard", + "rand 0.10.2", + "reqwest", + "rustls 0.23.43", + "rustls-pki-types", + "serde", + "serde_bytes", + "strum", + "tokio", + "tokio-rustls 0.26.4", + "tokio-util", + "tokio-websockets", + "tracing", + "url", + "webpki-roots", + "ws_stream_wasm", +] + +[[package]] +name = "iroh-tickets" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da53233419ca36bf521ed45683b7748366f9b233032891eefc2d70567a84ac54" +dependencies = [ + "data-encoding", + "derive_more", + "iroh-base", + "n0-error", + "postcard", + "serde", +] + +[[package]] +name = "iroh-util" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20e41eb982f15230c55f0a70a74a514360e1f565b07861924fd0e8db172b3d00" +dependencies = [ + "derive_more", + "iroh", + "n0-error", + "n0-future", + "tokio", + "tracing", +] + +[[package]] +name = "irpc" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3623d6ff582b415904b29bbe6ebcb4a4f9a262ccdee05a45fdd003ef0950c386" +dependencies = [ + "futures-util", + "irpc-derive", + "n0-error", + "n0-future", + "postcard", + "serde", + "smallvec", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "irpc-derive" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35c254013736de16472140d26904e6ac98e8f3887284dcf4af40f88c77411b56" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "is_terminal_polyfill" @@ -3276,6 +4088,22 @@ dependencies = [ "jiff-tzdb", ] +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + [[package]] name = "jni" version = "0.22.4" @@ -3285,7 +4113,7 @@ dependencies = [ "cfg-if", "combine", "jni-macros", - "jni-sys", + "jni-sys 0.4.1", "log", "simd_cesu8", "thiserror 2.0.20", @@ -3306,6 +4134,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + [[package]] name = "jni-sys" version = "0.4.1" @@ -3574,6 +4411,15 @@ version = "0.18.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25" +[[package]] +name = "lru" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -3590,6 +4436,12 @@ dependencies = [ "libc", ] +[[package]] +name = "mac-addr" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" + [[package]] name = "macro-string" version = "0.2.0" @@ -4372,7 +5224,7 @@ dependencies = [ "http 1.5.0", "humantime", "itertools 0.14.0", - "lru", + "lru 0.16.4", "miden-crypto", "miden-node-tracing", "miden-processor", @@ -4848,11 +5700,16 @@ dependencies = [ "chacha20poly1305", "clap", "fs-err", + "futures", "golden-core", "golden-ehtdh1", "golden-evrf", "golden-halo2curves", "hex", + "iroh", + "iroh-blobs", + "iroh-docs", + "iroh-gossip", "miden-node-db", "miden-node-proto", "miden-node-proto-build", @@ -4933,30 +5790,232 @@ dependencies = [ name = "mime" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "n0-error" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c37e81176a83a77d2514528b91bdafc70ef88aab428f0e1b91aebb8d99888895" +dependencies = [ + "anyhow", + "n0-error-macros", + "spez", +] + +[[package]] +name = "n0-error-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2acd8b070213b0299282f884b4beba4e7b52d624fdcd504a3ad3665390c11e1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "n0-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" +dependencies = [ + "cfg_aliases", + "derive_more", + "futures-buffered", + "futures-lite", + "futures-util", + "js-sys", + "pin-project", + "send_wrapper", + "tokio", + "tokio-util", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "n0-watcher" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc618745ad0b7414b149d0517ad8b5573b2fb4d4e2717add3d2446ce1fdd826" +dependencies = [ + "derive_more", + "n0-error", + "n0-future", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "nested_enum_utils" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d5475271bdd36a4a2769eac1ef88df0f99428ea43e52dfd8b0ee5cb674695f" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "netdev" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "569dfbdd2efd771b24ec9bb57f956e04d4fbfc72f62b2f11961723f9b3f4b020" +dependencies = [ + "block2", + "dispatch2", + "dlopen2", + "ipnet", + "jni 0.21.1", + "libc", + "mac-addr", + "ndk-context", + "netlink-packet-core", + "netlink-packet-route", + "netlink-sys", + "objc2", + "objc2-core-foundation", + "objc2-core-wlan", + "objc2-foundation", + "objc2-system-configuration", + "once_cell", + "plist", + "windows-sys 0.61.2", +] + +[[package]] +name = "netlink-packet-core" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b897d7bd4f0af82e68d40d0344cf37e97f9c97ddf74a098de3e4da05e96ca395" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-route" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007" +dependencies = [ + "bitflags 2.13.1", + "libc", + "log", + "netlink-packet-core", +] [[package]] -name = "minimal-lexical" -version = "0.2.1" +name = "netlink-proto" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +checksum = "e6f7398dddf5f152d2a91a2921a134c6097056e292c0d4b9906007855e7cece6" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "log", + "netlink-packet-core", + "netlink-sys", + "thiserror 2.0.19", +] [[package]] -name = "mio" -version = "1.2.3" +name = "netlink-sys" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" dependencies = [ + "bytes", + "futures-util", "libc", - "wasi", - "windows-sys 0.61.2", + "log", + "tokio", ] [[package]] -name = "multimap" -version = "0.10.1" +name = "netwatch" +version = "0.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +checksum = "4d9cbe01741347ef750d743d6690603f5eed8341e679fb51c8e629337aa11976" +dependencies = [ + "atomic-waker", + "bytes", + "cfg_aliases", + "derive_more", + "ipnet", + "js-sys", + "libc", + "n0-error", + "n0-future", + "n0-watcher", + "netdev", + "netlink-packet-core", + "netlink-packet-route", + "netlink-proto", + "netlink-sys", + "noq-udp", + "objc2-core-foundation", + "objc2-system-configuration", + "pin-project-lite", + "serde", + "socket2 0.6.5", + "time", + "tokio", + "tokio-util", + "tracing", + "web-sys", + "windows", + "windows-result", + "wmi", +] [[package]] name = "nom" @@ -4980,6 +6039,68 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" +[[package]] +name = "noq" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e4bb6601fa543c110d8957813267d5a8d775a0f8fbaccf1f615d06ba9b10da" +dependencies = [ + "bytes", + "cfg_aliases", + "derive_more", + "noq-proto", + "noq-udp", + "pin-project-lite", + "rustc-hash", + "rustls 0.23.43", + "socket2 0.6.5", + "thiserror 2.0.19", + "tokio", + "tokio-stream", + "tracing", + "web-time", +] + +[[package]] +name = "noq-proto" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa7b5ccd819a9c68a0d955e67a881032d09b1a17219b1f90b0997a0888e1a15" +dependencies = [ + "aes-gcm", + "bytes", + "derive_more", + "enum-assoc", + "getrandom 0.4.3", + "identity-hash", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls 0.23.43", + "rustls-pki-types", + "slab", + "sorted-index-buffer", + "thiserror 2.0.19", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "noq-udp" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bba20e097a5a16cd0ad14ec882fae1e80a092a124e9422fc4dddd92e96a647" +dependencies = [ + "cfg_aliases", + "libc", + "socket2 0.6.5", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -5088,6 +6209,118 @@ dependencies = [ "libc", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "block2", + "dispatch2", + "libc", + "objc2", +] + +[[package]] +name = "objc2-core-wlan" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-security", + "objc2-security-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-security-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef76382e9cedd18123099f17638715cc3d81dba3637d4c0d39ab69df2ef345a5" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "libc", + "objc2", + "objc2-core-foundation", + "objc2-security", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -5110,6 +6343,12 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl-probe" version = "0.2.1" @@ -5408,6 +6647,16 @@ dependencies = [ "group 0.13.0", ] +[[package]] +name = "papaya" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "997ee03cd38c01469a7046643714f0ad28880bcb9e6679ff0666e24817ca19b7" +dependencies = [ + "equivalent", + "seize", +] + [[package]] name = "parity-scale-codec" version = "3.7.5" @@ -5436,6 +6685,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -5471,6 +6726,15 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -5498,6 +6762,16 @@ dependencies = [ "indexmap 2.14.1", ] +[[package]] +name = "pharos" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" +dependencies = [ + "futures", + "rustc_version 0.4.1", +] + [[package]] name = "pin-project" version = "1.1.13" @@ -5546,6 +6820,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "plist" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +dependencies = [ + "base64", + "indexmap 2.14.0", + "quick-xml", + "serde", + "time", +] + [[package]] name = "plonky2_maybe_rayon" version = "1.0.0" @@ -5590,7 +6877,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" dependencies = [ "cpufeatures 0.3.1", - "universal-hash", + "universal-hash 0.6.1", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash 0.5.1", ] [[package]] @@ -5598,6 +6897,9 @@ name = "portable-atomic" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" +dependencies = [ + "serde", +] [[package]] name = "portable-atomic-util" @@ -5608,6 +6910,41 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "positioned-io" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ec4b80060f033312b99b6874025d9503d2af87aef2dd4c516e253fbfcdada7" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "postcard-derive", + "serde", +] + +[[package]] +name = "postcard-derive" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -5632,6 +6969,17 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + [[package]] name = "pretty_assertions" version = "1.4.1" @@ -5724,9 +7072,35 @@ dependencies = [ name = "proc-macro-crate" version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-error" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18f33027081eba0a6d8aba6d1b1c3a3be58cbb12106341c2d5759fcd9b5277e7" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a5b4b77fdb63c1eca72173d68d24501c54ab1269409f6b672c85deb18af69de" dependencies = [ - "toml_edit", + "proc-macro2", + "quote", + "syn 1.0.109", + "syn-mid", + "version_check", ] [[package]] @@ -5751,6 +7125,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -5939,6 +7319,21 @@ dependencies = [ "winapi", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.11" @@ -6136,6 +7531,19 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "range-collections" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "861706ea9c4aded7584c5cd1d241cec2ea7f5f50999f236c22b65409a1f1a0d0" +dependencies = [ + "binary-merge", + "inplace-vec-builder", + "ref-cast", + "serde", + "smallvec", +] + [[package]] name = "raw-cpuid" version = "11.6.0" @@ -6165,6 +7573,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "redb" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e925444704b5f17d32bf42f5b6e2df050bceebc3dcd6e71cc73dafe8092e839" +dependencies = [ + "libc", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -6194,6 +7611,18 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "reflink-copy" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9dd7ab4af0363d5ccfd2838d782a28196cf32a5cc2e4fe3c5dc83f2be588b8b" +dependencies = [ + "cfg-if", + "libc", + "rustix", + "windows", +] + [[package]] name = "regex" version = "1.13.1" @@ -6245,7 +7674,8 @@ dependencies = [ "bytes", "encoding_rs", "futures-core", - "h2", + "futures-util", + "h2 0.4.15", "http 1.5.0", "http-body 1.1.0", "http-body-util", @@ -6266,16 +7696,24 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", ] +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + [[package]] name = "rfc6979" version = "0.6.0" @@ -6506,7 +7944,7 @@ checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", - "jni", + "jni 0.22.4", "log", "once_cell", "rustls", @@ -6640,6 +8078,22 @@ dependencies = [ "libc", ] +[[package]] +name = "seize" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + [[package]] name = "semver" version = "0.9.0" @@ -6683,6 +8137,12 @@ dependencies = [ "pest", ] +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.229" @@ -6693,6 +8153,15 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-error" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "342110fb7a5d801060c885da03bf91bfa7c7ca936deafcc64bb6706375605d47" +dependencies = [ + "serde", +] + [[package]] name = "serde-untagged" version = "0.1.9" @@ -6716,6 +8185,16 @@ dependencies = [ "wincode", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -6848,6 +8327,12 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -6958,6 +8443,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simple-dns" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "slab" version = "0.4.12" @@ -6989,6 +8483,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "sorted-index-buffer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea06cc588e43c632923a55450401b8f25e628131571d4e1baea1bdfdb2b5ed06" + +[[package]] +name = "spez" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "spin" version = "0.9.9" @@ -6998,6 +8509,12 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" + [[package]] name = "spin" version = "0.12.3" @@ -7077,6 +8594,27 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subtle" version = "2.6.1" @@ -7116,6 +8654,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn-mid" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea305d57546cc8cd04feb14b62ec84bf17f50e3f7b12560d7bfa9265f39d9ed" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "syn-solidity" version = "1.7.2" @@ -7169,6 +8718,12 @@ dependencies = [ "libc", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tap" version = "1.0.1" @@ -7284,6 +8839,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", + "js-sys", "num-conv", "powerfmt", "serde_core", @@ -7416,6 +8972,29 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-websockets" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52efb639344a7c6adb8e62c6f3d2c19c001ff1b79a5041ba1c6ed42e19c6aa5" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-sink", + "getrandom 0.4.3", + "http 1.5.0", + "httparse", + "rand 0.10.2", + "ring", + "rustls-pki-types", + "sha1_smol", + "simdutf8", + "tokio", + "tokio-rustls 0.26.4", + "tokio-util", +] + [[package]] name = "toml" version = "1.1.5+spec-1.1.0" @@ -7896,6 +9475,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "universal-hash" version = "0.6.1" @@ -7949,6 +9538,7 @@ version = "1.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" dependencies = [ + "getrandom 0.4.3", "js-sys", "wasm-bindgen", ] @@ -8084,6 +9674,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" version = "0.3.104" @@ -8101,6 +9704,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", + "serde", "wasm-bindgen", ] @@ -8113,6 +9717,21 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -8268,13 +9887,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -8286,20 +9914,35 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -8311,18 +9954,36 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -8335,24 +9996,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -8374,6 +10059,21 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wmi" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c81b85c57a57500e56669586496bf2abd5cf082b9d32995251185d105208b64" +dependencies = [ + "chrono", + "futures", + "log", + "serde", + "thiserror 2.0.19", + "windows", + "windows-core", +] + [[package]] name = "wnaf" version = "0.14.1" @@ -8392,6 +10092,25 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "ws_stream_wasm" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" +dependencies = [ + "async_io_stream", + "futures", + "js-sys", + "log", + "pharos", + "rustc_version 0.4.1", + "send_wrapper", + "thiserror 2.0.19", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wyz" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index 5b9931f698..62c08d1a81 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -94,6 +94,10 @@ hex = { version = "0.4" } http = { version = "1.3" } humantime = { version = "2.2" } indexmap = { version = "2.12" } +iroh = { default-features = false, features = ["tls-ring"], version = "=1.0.3" } +iroh-blobs = { default-features = false, features = ["fs-store"], version = "=0.103.0" } +iroh-docs = { default-features = false, features = ["fs-store"], version = "=0.101.0" } +iroh-gossip = { default-features = false, features = ["net"], version = "=0.101.0" } itertools = { version = "0.14" } libsqlite3-sys = { features = ["bundled"], version = "0.35" } lru = { default-features = false, version = "0.18" } diff --git a/bin/validator/Cargo.toml b/bin/validator/Cargo.toml index a7b0d67156..a079a0bae8 100644 --- a/bin/validator/Cargo.toml +++ b/bin/validator/Cargo.toml @@ -29,11 +29,16 @@ base64 = { version = "0.22" } chacha20poly1305 = { workspace = true } clap = { features = ["env", "string"], workspace = true } fs-err = { workspace = true } +futures = { workspace = true } golden-core = { workspace = true } golden-ehtdh1 = { workspace = true } golden-evrf = { workspace = true } golden-halo2curves = { workspace = true } hex = { workspace = true } +iroh = { workspace = true } +iroh-blobs = { workspace = true } +iroh-docs = { workspace = true } +iroh-gossip = { workspace = true } miden-node-db = { workspace = true } miden-node-proto = { workspace = true } miden-node-proto-build = { features = ["internal"], workspace = true } @@ -47,7 +52,7 @@ serde = { workspace = true } sha2 = { workspace = true } tempfile = { workspace = true } thiserror = { workspace = true } -tokio = { features = ["macros", "net", "rt-multi-thread"], workspace = true } +tokio = { features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"], workspace = true } tokio-stream = { features = ["net"], workspace = true } toml = { workspace = true } tonic = { default-features = true, features = ["transport"], workspace = true } diff --git a/bin/validator/src/commands/dkg.rs b/bin/validator/src/commands/dkg.rs index 3bbe6efd27..674908d312 100644 --- a/bin/validator/src/commands/dkg.rs +++ b/bin/validator/src/commands/dkg.rs @@ -49,6 +49,8 @@ use super::ValidatorSigningKey; #[cfg(test)] mod tests; +mod board; +mod runner; type StorageGroup = Secp256k1GoldenGroup; type StorageScalar = ::Scalar; @@ -92,6 +94,12 @@ pub struct DkgOptions { /// DKG ceremony commands. #[derive(clap::Subcommand)] enum DkgCommand { + /// Runs the shared Iroh bulletin board for a ceremony. + Board(runner::GoldenDkgBoardServeOptions), + + /// Runs every ceremony stage for one validator through an Iroh board. + Run(runner::GoldenDkgRunOptions), + /// Generates this validator's DKG identity and public registration. Identity { /// Trusted genesis block for the network. @@ -339,6 +347,8 @@ struct DealingSet { /// Runs one DKG ceremony command. pub async fn run(options: DkgOptions) -> anyhow::Result<()> { match options.command { + DkgCommand::Board(options) => runner::serve_board(options).await, + DkgCommand::Run(options) => runner::run_validator(options).await, DkgCommand::Identity { genesis, epoch, diff --git a/bin/validator/src/commands/dkg/board.rs b/bin/validator/src/commands/dkg/board.rs new file mode 100644 index 0000000000..618073ceb4 --- /dev/null +++ b/bin/validator/src/commands/dkg/board.rs @@ -0,0 +1,1189 @@ +use std::collections::BTreeMap; +use std::fmt; +use std::path::Path; +use std::str::FromStr; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, ensure}; +use futures::StreamExt; +use iroh::endpoint::{Connection, presets}; +use iroh::protocol::{AcceptError, ProtocolHandler, Router}; +use iroh::{Endpoint, EndpointAddr, EndpointId, SecretKey}; +use iroh_blobs::api::downloader::{DownloadProgressItem, Downloader}; +use iroh_blobs::store::fs::FsStore; +use iroh_blobs::{BlobsProtocol, Hash}; +use iroh_docs::DocTicket; +use iroh_docs::api::Doc; +use iroh_docs::api::protocol::{AddrInfoOptions, ShareMode}; +use iroh_docs::engine::LiveEvent; +use iroh_docs::protocol::Docs; +use iroh_docs::store::{DownloadPolicy, Query}; +use iroh_gossip::net::Gossip; + +use super::{decode_fixed_hex, write_new_file}; + +const ENDPOINT_SECRET_FILE: &str = "endpoint-secret.hex"; +const DOCUMENT_ID_FILE: &str = "document-id.hex"; +const BOARD_FORMAT_FILE: &str = "board-format"; +const BOARD_FORMAT: &[u8] = b"bounded-upload-v1\n"; +const UPLOAD_SECRET_FILE: &str = "upload-secret.hex"; +const BOARD_TICKET_PREFIX: &str = "miden-golden-board-v1"; +const UPLOAD_ALPN: &[u8] = b"/miden/golden-dkg-board-upload/1"; +const UPLOAD_HEADER_BYTES: usize = 32 + 1 + 4 + 8; +const UPLOAD_RESPONSE_BYTES: usize = 1 + 32; +const MAX_ARTIFACT_BYTES: u64 = 64 * 1024 * 1024; +const MAX_CONCURRENT_UPLOADS: usize = 3; +const MAX_UPLOAD_ERROR_BYTES: usize = 1024; +const UPLOAD_TIMEOUT: Duration = Duration::from_secs(30); +const PEER_READY_TIMEOUT: Duration = Duration::from_secs(30); +const COMMON_ARTIFACT_COUNT: usize = 3; +const ARTIFACTS_PER_PARTICIPANT: usize = 6; +const MAX_VALUES_PER_SLOT: usize = 2; + +/// A read-only document ticket paired with permission to submit bounded artifacts to its board. +#[derive(Clone, Debug)] +pub(super) struct BoardTicket { + document: DocTicket, + upload_secret: [u8; 32], +} + +impl fmt::Display for BoardTicket { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "{BOARD_TICKET_PREFIX}:{}:{}", + hex::encode(self.upload_secret), + self.document + ) + } +} + +impl FromStr for BoardTicket { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + let mut parts = value.splitn(3, ':'); + ensure!(parts.next() == Some(BOARD_TICKET_PREFIX), "invalid Golden board ticket prefix"); + let secret = parts.next().context("Golden board ticket is missing its upload secret")?; + let document = + parts.next().context("Golden board ticket is missing its document ticket")?; + let upload_secret = decode_fixed_hex::<32>(secret, "Golden board upload secret")?; + let document = DocTicket::from_str(document).context("invalid Iroh document ticket")?; + ensure!( + matches!(document.capability, iroh_docs::Capability::Read(_)), + "Golden board document ticket must be read-only" + ); + Ok(Self { document, upload_secret }) + } +} + +/// One immutable location in a Golden ceremony document. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) enum ArtifactSlot { + Registration(u32), + Manifest, + DecryptionConfig, + ContextConfig, + DecryptionDealing(u32), + ContextDealing(u32), + Transcript(u32), + TranscriptAcceptance(u32), + FinalConfirmation(u32), +} + +impl ArtifactSlot { + fn prefix(&self) -> String { + match self { + Self::Registration(participant) => format!("registration/{participant}/"), + Self::Manifest => "common/manifest/".to_owned(), + Self::DecryptionConfig => "common/decryption-config/".to_owned(), + Self::ContextConfig => "common/context-config/".to_owned(), + Self::DecryptionDealing(participant) => { + format!("dealing/{participant}/decryption/") + }, + Self::ContextDealing(participant) => format!("dealing/{participant}/context/"), + Self::Transcript(participant) => format!("acceptance/{participant}/transcript/"), + Self::TranscriptAcceptance(participant) => { + format!("acceptance/{participant}/signature/") + }, + Self::FinalConfirmation(participant) => { + format!("final/{participant}/confirmation/") + }, + } + } + + fn key(&self, hash: Hash) -> String { + format!("{}{}", self.prefix(), hash.to_hex()) + } + + fn upload_fields(&self) -> anyhow::Result<(u8, u32)> { + let fields = match self { + Self::Registration(participant) => (1, *participant), + Self::DecryptionDealing(participant) => (2, *participant), + Self::ContextDealing(participant) => (3, *participant), + Self::Transcript(participant) => (4, *participant), + Self::TranscriptAcceptance(participant) => (5, *participant), + Self::FinalConfirmation(participant) => (6, *participant), + Self::Manifest | Self::DecryptionConfig | Self::ContextConfig => { + anyhow::bail!("only the Golden board may publish common ceremony artifacts") + }, + }; + Ok(fields) + } + + fn from_upload_fields(kind: u8, participant: u32) -> anyhow::Result { + ensure!(participant > 0, "Golden board participant index must be nonzero"); + match kind { + 1 => Ok(Self::Registration(participant)), + 2 => Ok(Self::DecryptionDealing(participant)), + 3 => Ok(Self::ContextDealing(participant)), + 4 => Ok(Self::Transcript(participant)), + 5 => Ok(Self::TranscriptAcceptance(participant)), + 6 => Ok(Self::FinalConfirmation(participant)), + _ => anyhow::bail!("Golden board upload contains an unknown artifact kind"), + } + } +} + +#[derive(Clone, Debug)] +struct BoardWriter { + author: iroh_docs::AuthorId, + document: Doc, + allowed_prefixes: Arc>, + lock: Arc>, +} + +#[derive(Debug)] +enum Publisher { + Local(BoardWriter), + Remote { + endpoint: Endpoint, + target: EndpointAddr, + upload_secret: [u8; 32], + }, +} + +#[derive(Clone, Debug)] +struct UploadProtocol { + permits: Arc, + upload_secret: [u8; 32], + writer: BoardWriter, +} + +/// A persistent Iroh node joined to one ceremony document. +pub(super) struct BoardNode { + blobs: FsStore, + document: Doc, + downloader: Downloader, + event_error: tokio::sync::watch::Receiver>, + event_task: tokio::task::JoinHandle<()>, + allowed_prefixes: Arc>, + max_document_entries: usize, + peer_ready: tokio::sync::watch::Receiver, + publisher: Publisher, + remote_providers: std::sync::Arc>>>, + router: Router, + sync_generation: tokio::sync::watch::Receiver, + sync_targets: Vec, +} + +struct BoardRuntime { + author: iroh_docs::AuthorId, + blobs: FsStore, + docs: Docs, + downloader: Downloader, + endpoint: Endpoint, + gossip: Gossip, +} + +struct BoardEvents { + error: tokio::sync::watch::Receiver>, + peer_ready: tokio::sync::watch::Receiver, + remote_providers: Arc>>>, + sync_generation: tokio::sync::watch::Receiver, + task: tokio::task::JoinHandle<()>, +} + +impl Drop for BoardNode { + fn drop(&mut self) { + self.event_task.abort(); + } +} + +impl BoardNode { + /// Creates a new ceremony document and returns its read and upload ticket. + pub(super) async fn create( + data_directory: &Path, + participant_count: usize, + ) -> anyhow::Result<(Self, BoardTicket)> { + Self::create_with_network(data_directory, participant_count, true).await + } + + pub(super) async fn create_with_network( + data_directory: &Path, + participant_count: usize, + use_network_services: bool, + ) -> anyhow::Result<(Self, BoardTicket)> { + let runtime = BoardRuntime::start(data_directory, use_network_services).await?; + let document_id_path = data_directory.join(DOCUMENT_ID_FILE); + let existing_document = document_id_path.exists(); + let document = if existing_document { + require_current_board_format(data_directory)?; + let id = fs_err::read_to_string(&document_id_path).with_context(|| { + format!("failed to read Iroh document ID {}", document_id_path.display()) + })?; + let id = decode_fixed_hex::<32>(id.trim(), "Iroh document ID")?; + runtime + .docs + .open(iroh_docs::NamespaceId::from(&id)) + .await + .context("failed to open Iroh document")? + .context("persisted Iroh document is missing")? + } else { + let document = runtime.docs.create().await.context("failed to create Iroh document")?; + write_new_file( + &document_id_path, + hex::encode(document.id().to_bytes()).as_bytes(), + true, + )?; + write_new_file(&data_directory.join(BOARD_FORMAT_FILE), BOARD_FORMAT, true)?; + document + }; + let upload_secret = load_or_create_upload_secret(data_directory, !existing_document)?; + document + .set_download_policy(DownloadPolicy::NothingExcept(Vec::new())) + .await + .context("failed to restrict Golden board downloads")?; + let mut document_ticket = document + .share( + ShareMode::Read, + if use_network_services { + AddrInfoOptions::RelayAndAddresses + } else { + AddrInfoOptions::Id + }, + ) + .await + .context("failed to create Iroh document ticket")?; + if !use_network_services { + let mut socket = runtime + .endpoint + .bound_sockets() + .into_iter() + .find(std::net::SocketAddr::is_ipv4) + .context("Iroh test endpoint has no IPv4 socket")?; + socket.set_ip(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)); + document_ticket.nodes = vec![iroh::EndpointAddr::from_parts( + runtime.endpoint.id(), + [iroh::TransportAddr::Ip(socket)], + )]; + } + let ticket = BoardTicket { document: document_ticket, upload_secret }; + let board = runtime + .attach(document, participant_count, Vec::new(), Some(upload_secret), None) + .await?; + board + .document + .start_sync(Vec::new()) + .await + .context("failed to start Golden board synchronization")?; + Ok((board, ticket)) + } + + /// Joins an existing ceremony document through its read and upload ticket. + pub(super) async fn join( + data_directory: &Path, + ticket: &str, + participant_count: usize, + ) -> anyhow::Result { + Self::join_with_network(data_directory, ticket, participant_count, true).await + } + + pub(super) async fn join_with_network( + data_directory: &Path, + ticket: &str, + participant_count: usize, + use_network_services: bool, + ) -> anyhow::Result { + let ticket = BoardTicket::from_str(ticket)?; + let runtime = BoardRuntime::start(data_directory, use_network_services).await?; + let BoardTicket { document, upload_secret } = ticket; + let DocTicket { capability, nodes } = document; + let target = nodes.first().cloned().context("Golden board ticket has no endpoint")?; + let document = runtime + .docs + .import_namespace(capability) + .await + .context("failed to join Iroh ceremony document")?; + document + .set_download_policy(DownloadPolicy::NothingExcept(Vec::new())) + .await + .context("failed to restrict Golden board downloads")?; + let mut board = runtime + .attach(document, participant_count, nodes.clone(), None, Some((target, upload_secret))) + .await?; + board + .document + .start_sync(nodes) + .await + .context("failed to start Golden board synchronization")?; + board.wait_for_peer().await?; + Ok(board) + } + + /// Publishes one artifact without replacing another value in the same slot. + pub(super) async fn publish(&self, slot: &ArtifactSlot, value: &[u8]) -> anyhow::Result { + self.ensure_admitted()?; + validate_artifact_length(value.len())?; + let expected_hash = Hash::new(value); + let sync_generation = *self.sync_generation.borrow(); + let stored_hash = match &self.publisher { + Publisher::Local(writer) => writer.store(slot, value).await?, + Publisher::Remote { endpoint, target, upload_secret } => { + upload_artifact(endpoint, target, upload_secret, slot, value).await? + }, + }; + ensure!(stored_hash == expected_hash, "Iroh stored artifact under an unexpected hash"); + self.document + .start_sync(self.sync_targets.clone()) + .await + .context("failed to synchronize Golden board artifact")?; + if !self.sync_targets.is_empty() || *self.peer_ready.borrow() { + let mut completed = self.sync_generation.clone(); + tokio::time::timeout( + PEER_READY_TIMEOUT, + completed.wait_for(|generation| *generation > sync_generation), + ) + .await + .context("timed out synchronizing Golden board artifact")? + .context("Golden board synchronization monitor stopped")?; + } + Ok(stored_hash) + } + + /// Reads the unique content value published for one artifact slot. + pub(super) async fn read_unique(&self, slot: &ArtifactSlot) -> anyhow::Result>> { + self.validate_document_metadata().await?; + let prefix = slot.prefix(); + let entries = self + .document + .get_many(Query::key_prefix(prefix.as_bytes())) + .await + .context("failed to query Golden board artifacts")?; + futures::pin_mut!(entries); + let mut values = BTreeMap::new(); + while let Some(entry) = entries.next().await { + let entry = entry.context("failed to read Golden board entry")?; + ensure!( + entry.content_len() > 0 && entry.content_len() <= MAX_ARTIFACT_BYTES, + "Golden board artifact exceeds {MAX_ARTIFACT_BYTES} bytes", + ); + let expected_key = slot.key(entry.content_hash()); + ensure!( + entry.key() == expected_key.as_bytes(), + "Golden board key does not match its content hash" + ); + let hash = entry.content_hash(); + if self.blobs.blobs().get_bytes(hash).await.is_err() { + let mut providers = + self.remote_providers.read().await.get(&hash).cloned().unwrap_or_default(); + let sync_peers = self + .document + .get_sync_peers() + .await + .context("failed to list Golden board peers")? + .unwrap_or_default() + .into_iter() + .map(|id| EndpointId::from_bytes(&id).context("invalid Golden board peer ID")) + .collect::>>()?; + for peer in sync_peers { + if !providers.contains(&peer) { + providers.push(peer); + } + } + if providers.is_empty() { + return Ok(None); + } + let Ok(mut progress) = self.downloader.download(hash, providers).stream().await + else { + return Ok(None); + }; + while let Some(item) = progress.next().await { + match item { + DownloadProgressItem::Progress(downloaded) => ensure!( + downloaded <= MAX_ARTIFACT_BYTES, + "Golden board artifact exceeds {MAX_ARTIFACT_BYTES} bytes", + ), + DownloadProgressItem::Error(_) | DownloadProgressItem::DownloadError => { + return Ok(None); + }, + DownloadProgressItem::TryProvider { .. } + | DownloadProgressItem::ProviderFailed { .. } + | DownloadProgressItem::PartComplete { .. } => {}, + } + } + } + let bytes = self + .blobs + .blobs() + .get_bytes(hash) + .await + .context("downloaded Golden board artifact is missing")?; + ensure!( + u64::try_from(bytes.len()).context("artifact length does not fit u64")? + == entry.content_len(), + "Golden board artifact length does not match its entry" + ); + values.entry(hash).or_insert_with(|| bytes.to_vec()); + } + ensure!(values.len() <= 1, "Golden board contains conflicting artifacts for {prefix}"); + Ok(values.into_values().next()) + } + + async fn validate_document_metadata(&self) -> anyhow::Result<()> { + self.ensure_admitted()?; + inspect_document_metadata(&self.document, &self.allowed_prefixes, self.max_document_entries) + .await + } + + fn ensure_admitted(&self) -> anyhow::Result<()> { + if let Some(error) = self.event_error.borrow().as_ref() { + anyhow::bail!("Golden board synchronization stopped: {error}"); + } + Ok(()) + } + + async fn wait_for_peer(&mut self) -> anyhow::Result<()> { + if *self.peer_ready.borrow() { + return Ok(()); + } + tokio::time::timeout(PEER_READY_TIMEOUT, self.peer_ready.wait_for(|ready| *ready)) + .await + .context("timed out waiting for the Golden board peer")? + .context("Golden board peer monitor stopped")?; + Ok(()) + } + + /// Waits until one unique artifact has synchronized locally. + pub(super) async fn wait_unique( + &self, + slot: &ArtifactSlot, + timeout: Duration, + ) -> anyhow::Result> { + let mut events = self + .document + .subscribe() + .await + .context("failed to subscribe to Golden board updates")?; + tokio::time::timeout(timeout, async { + loop { + if let Some(value) = self.read_unique(slot).await? { + return Ok(value); + } + tokio::select! { + event = events.next() => { + event.transpose()?.context("Golden board update stream ended")?; + }, + () = tokio::time::sleep(Duration::from_millis(250)) => {}, + } + } + }) + .await + .with_context(|| format!("timed out waiting for Golden board slot {}", slot.prefix()))? + } + + /// Stops the board node and flushes its persistent stores. + pub(super) async fn shutdown(self) -> anyhow::Result<()> { + self.event_task.abort(); + self.router.shutdown().await.context("failed to stop Iroh board node")?; + Ok(()) + } +} + +fn validate_artifact_length(length: usize) -> anyhow::Result<()> { + ensure!(length > 0, "Golden board artifact must not be empty"); + ensure!( + u64::try_from(length).context("artifact length does not fit u64")? <= MAX_ARTIFACT_BYTES, + "Golden board artifact exceeds {MAX_ARTIFACT_BYTES} bytes", + ); + Ok(()) +} + +impl BoardWriter { + fn validate_slot(&self, slot: &ArtifactSlot) -> anyhow::Result<()> { + ensure!( + self.allowed_prefixes.contains(&slot.prefix()), + "Golden board upload targets an unknown participant or artifact slot" + ); + Ok(()) + } + + async fn store(&self, slot: &ArtifactSlot, value: &[u8]) -> anyhow::Result { + validate_artifact_length(value.len())?; + self.validate_slot(slot)?; + let prefix = slot.prefix(); + let expected_hash = Hash::new(value); + let _guard = self.lock.lock().await; + let entries = self + .document + .get_many(Query::key_prefix(prefix.as_bytes())) + .await + .context("failed to inspect Golden board artifact slot")?; + futures::pin_mut!(entries); + let mut hashes = Vec::new(); + while let Some(entry) = entries.next().await { + let entry = entry.context("failed to read Golden board artifact slot")?; + if entry.content_hash() == expected_hash { + return Ok(expected_hash); + } + hashes.push(entry.content_hash()); + ensure!( + hashes.len() < MAX_VALUES_PER_SLOT, + "Golden board artifact slot already contains conflicting values" + ); + } + + let stored_hash = self + .document + .set_bytes(self.author, slot.key(expected_hash), value.to_vec()) + .await + .context("failed to publish Golden board artifact")?; + ensure!(stored_hash == expected_hash, "Iroh stored artifact under an unexpected hash"); + Ok(stored_hash) + } +} + +impl UploadProtocol { + async fn receive(&self, recv: &mut iroh::endpoint::RecvStream) -> anyhow::Result { + let mut header = [0u8; UPLOAD_HEADER_BYTES]; + recv.read_exact(&mut header) + .await + .context("failed to read Golden board upload header")?; + ensure!( + secrets_match(&header[..32], &self.upload_secret), + "invalid Golden board upload secret" + ); + let kind = header[32]; + let participant = u32::from_be_bytes(header[33..37].try_into().expect("fixed slice")); + let length = u64::from_be_bytes(header[37..45].try_into().expect("fixed slice")); + ensure!( + length > 0 && length <= MAX_ARTIFACT_BYTES, + "Golden board artifact exceeds {MAX_ARTIFACT_BYTES} bytes" + ); + let slot = ArtifactSlot::from_upload_fields(kind, participant)?; + self.writer.validate_slot(&slot)?; + let length = + usize::try_from(length).context("Golden board artifact length is too large")?; + let mut value = vec![0u8; length]; + recv.read_exact(&mut value) + .await + .context("failed to read Golden board upload body")?; + recv.read_to_end(0).await.context("Golden board upload has trailing bytes")?; + self.writer.store(&slot, &value).await + } +} + +impl ProtocolHandler for UploadProtocol { + async fn accept(&self, connection: Connection) -> Result<(), AcceptError> { + if let Ok(result) = + tokio::time::timeout(UPLOAD_TIMEOUT, self.serve_connection(&connection)).await + { + result + } else { + connection.close(1u32.into(), b"Golden board upload timed out"); + Ok(()) + } + } +} + +impl UploadProtocol { + async fn serve_connection(&self, connection: &Connection) -> Result<(), AcceptError> { + let _permit = self.permits.acquire().await.map_err(AcceptError::from_err)?; + let (mut send, mut recv) = connection.accept_bi().await?; + let response = match self.receive(&mut recv).await { + Ok(hash) => { + let mut response = Vec::with_capacity(UPLOAD_RESPONSE_BYTES); + response.push(0); + response.extend_from_slice(hash.as_bytes()); + response + }, + Err(error) => upload_error_response(&error), + }; + send.write_all(&response).await.map_err(AcceptError::from_err)?; + send.finish()?; + connection.closed().await; + Ok(()) + } +} + +async fn upload_artifact( + endpoint: &Endpoint, + target: &EndpointAddr, + upload_secret: &[u8; 32], + slot: &ArtifactSlot, + value: &[u8], +) -> anyhow::Result { + validate_artifact_length(value.len())?; + let (kind, participant) = slot.upload_fields()?; + upload_artifact_request( + endpoint, + target, + upload_secret, + kind, + participant, + u64::try_from(value.len()).context("artifact length does not fit u64")?, + value, + ) + .await +} + +async fn upload_artifact_request( + endpoint: &Endpoint, + target: &EndpointAddr, + upload_secret: &[u8; 32], + kind: u8, + participant: u32, + declared_length: u64, + value: &[u8], +) -> anyhow::Result { + let connection = endpoint + .connect(target.clone(), UPLOAD_ALPN) + .await + .context("failed to connect to the Golden board upload service")?; + let (mut send, mut recv) = connection + .open_bi() + .await + .context("failed to open a Golden board upload stream")?; + let mut header = [0u8; UPLOAD_HEADER_BYTES]; + header[..32].copy_from_slice(upload_secret); + header[32] = kind; + header[33..37].copy_from_slice(&participant.to_be_bytes()); + header[37..45].copy_from_slice(&declared_length.to_be_bytes()); + send.write_all(&header) + .await + .context("failed to write Golden board upload header")?; + send.write_all(value) + .await + .context("failed to write Golden board upload body")?; + send.finish().context("failed to finish Golden board upload")?; + let response = tokio::time::timeout( + UPLOAD_TIMEOUT, + recv.read_to_end(UPLOAD_RESPONSE_BYTES + MAX_UPLOAD_ERROR_BYTES), + ) + .await + .context("timed out waiting for the Golden board upload response")? + .context("failed to read Golden board upload response")?; + connection.close(0u32.into(), b"upload complete"); + ensure!(!response.is_empty(), "Golden board returned an empty upload response"); + if response[0] != 0 { + let message = std::str::from_utf8(&response[1..]) + .context("Golden board returned a non-UTF-8 upload error")?; + anyhow::bail!("Golden board rejected the artifact: {message}"); + } + ensure!( + response.len() == UPLOAD_RESPONSE_BYTES, + "Golden board returned an invalid upload response" + ); + Ok(Hash::from_bytes(response[1..].try_into().expect("validated response length"))) +} + +fn upload_error_response(error: &anyhow::Error) -> Vec { + let mut message = format!("{error:#}"); + if message.len() > MAX_UPLOAD_ERROR_BYTES { + let mut end = MAX_UPLOAD_ERROR_BYTES; + while !message.is_char_boundary(end) { + end -= 1; + } + message.truncate(end); + } + let mut response = Vec::with_capacity(1 + message.len()); + response.push(1); + response.extend_from_slice(message.as_bytes()); + response +} + +fn secrets_match(candidate: &[u8], expected: &[u8; 32]) -> bool { + candidate + .iter() + .zip(expected) + .fold(0u8, |difference, (left, right)| difference | (left ^ right)) + == 0 +} + +impl BoardRuntime { + async fn start(data_directory: &Path, use_network_services: bool) -> anyhow::Result { + fs_err::create_dir_all(data_directory).with_context(|| { + format!("failed to create Iroh data directory {}", data_directory.display()) + })?; + let secret = load_or_create_endpoint_secret(data_directory)?; + let builder = if use_network_services { + Endpoint::builder(presets::N0) + } else { + Endpoint::builder(presets::Minimal) + }; + let endpoint = builder + .secret_key(secret) + .bind() + .await + .context("failed to bind Iroh endpoint")?; + let blobs_directory = data_directory.join("blobs"); + let docs_directory = data_directory.join("docs"); + fs_err::create_dir_all(&blobs_directory).context("failed to create Iroh blob directory")?; + fs_err::create_dir_all(&docs_directory) + .context("failed to create Iroh document directory")?; + let blobs = + FsStore::load(blobs_directory).await.context("failed to load Iroh blob store")?; + let downloader = blobs.downloader(&endpoint); + let gossip = Gossip::builder().spawn(endpoint.clone()); + let docs = Docs::persistent(docs_directory) + .spawn(endpoint.clone(), blobs.as_ref().clone(), gossip.clone()) + .await + .context("failed to load Iroh document store")?; + let author = docs.author_default().await.context("failed to load Iroh author")?; + Ok(Self { + author, + blobs, + docs, + downloader, + endpoint, + gossip, + }) + } + + async fn attach( + self, + document: Doc, + participant_count: usize, + sync_targets: Vec, + served_upload_secret: Option<[u8; 32]>, + remote_upload: Option<(EndpointAddr, [u8; 32])>, + ) -> anyhow::Result { + ensure!(participant_count > 0, "Golden board requires at least one participant"); + ensure!( + served_upload_secret.is_some() ^ remote_upload.is_some(), + "Golden board must either serve or submit uploads" + ); + let artifact_slot_count = participant_count + .checked_mul(ARTIFACTS_PER_PARTICIPANT) + .and_then(|count| count.checked_add(COMMON_ARTIFACT_COUNT)) + .context("Golden board participant count is too large")?; + let max_document_entries = artifact_slot_count + .checked_mul(MAX_VALUES_PER_SLOT) + .context("Golden board participant count is too large")?; + let allowed_prefixes = Arc::new(allowed_slot_prefixes(participant_count)?); + inspect_document_metadata(&document, &allowed_prefixes, max_document_entries).await?; + let writer = BoardWriter { + author: self.author, + document: document.clone(), + allowed_prefixes: allowed_prefixes.clone(), + lock: Arc::new(tokio::sync::Mutex::new(())), + }; + let publisher = match remote_upload { + Some((target, upload_secret)) => Publisher::Remote { + endpoint: self.endpoint.clone(), + target, + upload_secret, + }, + None => Publisher::Local(writer.clone()), + }; + let mut router = Router::builder(self.endpoint) + .accept(iroh_blobs::ALPN, BlobsProtocol::new(self.blobs.as_ref(), None)) + .accept(iroh_gossip::ALPN, self.gossip) + .accept(iroh_docs::ALPN, self.docs.clone()); + if let Some(upload_secret) = served_upload_secret { + router = router.accept( + UPLOAD_ALPN, + UploadProtocol { + permits: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_UPLOADS)), + upload_secret, + writer, + }, + ); + } + let router = router.spawn(); + let events = BoardEvents::start(&document).await?; + Ok(BoardNode { + blobs: self.blobs, + document, + downloader: self.downloader, + event_error: events.error, + event_task: events.task, + allowed_prefixes, + max_document_entries, + peer_ready: events.peer_ready, + publisher, + remote_providers: events.remote_providers, + router, + sync_generation: events.sync_generation, + sync_targets, + }) + } +} + +impl BoardEvents { + async fn start(document: &Doc) -> anyhow::Result { + let mut events = document + .subscribe() + .await + .context("failed to start Golden board event monitor")?; + let (event_tx, error) = tokio::sync::watch::channel(None); + let (peer_ready_tx, peer_ready) = tokio::sync::watch::channel(false); + let (sync_generation_tx, sync_generation) = tokio::sync::watch::channel(0u64); + let remote_providers = + Arc::new(tokio::sync::RwLock::>>::default()); + let monitored_providers = remote_providers.clone(); + let task = tokio::spawn(async move { + let mut neighbor_ready = false; + let mut sync_ready = false; + while let Some(event) = events.next().await { + let event = match event { + Ok(event) => event, + Err(error) => { + event_tx.send_replace(Some(error.to_string())); + break; + }, + }; + match &event { + LiveEvent::NeighborUp(_) => { + neighbor_ready = true; + if sync_ready { + peer_ready_tx.send_replace(true); + } + }, + LiveEvent::NeighborDown(_) => { + neighbor_ready = false; + sync_ready = false; + peer_ready_tx.send_replace(false); + }, + LiveEvent::SyncFinished(sync) if sync.result.is_ok() => { + sync_ready = true; + sync_generation_tx.send_modify(|generation| *generation += 1); + if neighbor_ready { + peer_ready_tx.send_replace(true); + } + }, + _ => {}, + } + if let LiveEvent::InsertRemote { from, entry, .. } = &event { + let mut providers = monitored_providers.write().await; + let providers = providers.entry(entry.content_hash()).or_default(); + if !providers.contains(from) { + providers.push(*from); + } + } + } + }); + Ok(Self { + error, + peer_ready, + remote_providers, + sync_generation, + task, + }) + } +} + +fn allowed_slot_prefixes(participant_count: usize) -> anyhow::Result> { + let mut prefixes = vec![ + ArtifactSlot::Manifest.prefix(), + ArtifactSlot::DecryptionConfig.prefix(), + ArtifactSlot::ContextConfig.prefix(), + ]; + for position in 0..participant_count { + let participant = u32::try_from(position + 1).context("too many Golden participants")?; + prefixes.extend([ + ArtifactSlot::Registration(participant).prefix(), + ArtifactSlot::DecryptionDealing(participant).prefix(), + ArtifactSlot::ContextDealing(participant).prefix(), + ArtifactSlot::Transcript(participant).prefix(), + ArtifactSlot::TranscriptAcceptance(participant).prefix(), + ArtifactSlot::FinalConfirmation(participant).prefix(), + ]); + } + Ok(prefixes) +} + +async fn inspect_document_metadata( + document: &Doc, + allowed_prefixes: &[String], + max_document_entries: usize, +) -> anyhow::Result<()> { + let entries = document + .get_many(Query::all()) + .await + .context("failed to inspect Golden board")?; + futures::pin_mut!(entries); + let mut slots = BTreeMap::new(); + let mut count = 0usize; + while let Some(entry) = entries.next().await { + let entry = entry.context("failed to read Golden board entry")?; + count += 1; + ensure!(count <= max_document_entries, "Golden board contains too many entries"); + ensure!( + entry.content_len() > 0 && entry.content_len() <= MAX_ARTIFACT_BYTES, + "Golden board artifact exceeds {MAX_ARTIFACT_BYTES} bytes", + ); + let key = std::str::from_utf8(entry.key()).context("Golden board key is not UTF-8")?; + let (prefix, hash) = allowed_prefixes + .iter() + .find_map(|prefix| key.strip_prefix(prefix).map(|hash| (prefix, hash))) + .context("Golden board contains an unrecognized artifact slot")?; + ensure!( + hash.len() == 64 && hash.bytes().all(|byte| byte.is_ascii_hexdigit()), + "Golden board key has an invalid content hash" + ); + if let Some(previous) = slots.insert(prefix.clone(), hash.to_owned()) { + ensure!(previous == hash, "Golden board contains conflicting artifacts for {prefix}"); + } + } + Ok(()) +} + +fn load_or_create_endpoint_secret(data_directory: &Path) -> anyhow::Result { + let path = data_directory.join(ENDPOINT_SECRET_FILE); + if path.exists() { + let bytes = fs_err::read_to_string(&path) + .with_context(|| format!("failed to read Iroh endpoint secret {}", path.display()))?; + let bytes = decode_fixed_hex::<32>(bytes.trim(), "Iroh endpoint secret")?; + return Ok(SecretKey::from_bytes(&bytes)); + } + + let secret = SecretKey::generate(); + write_new_file(&path, hex::encode(secret.to_bytes()).as_bytes(), true)?; + Ok(secret) +} + +fn load_or_create_upload_secret( + data_directory: &Path, + allow_create: bool, +) -> anyhow::Result<[u8; 32]> { + let path = data_directory.join(UPLOAD_SECRET_FILE); + if path.exists() { + let bytes = fs_err::read_to_string(&path).with_context(|| { + format!("failed to read Golden board upload secret {}", path.display()) + })?; + return decode_fixed_hex::<32>(bytes.trim(), "Golden board upload secret"); + } + ensure!( + allow_create, + "this Golden board predates bounded uploads; start a new ceremony in a new data directory" + ); + + let secret = SecretKey::generate().to_bytes(); + write_new_file(&path, hex::encode(secret).as_bytes(), true)?; + Ok(secret) +} + +fn require_current_board_format(data_directory: &Path) -> anyhow::Result<()> { + let path = data_directory.join(BOARD_FORMAT_FILE); + let format = fs_err::read(&path).with_context(|| { + format!( + "this Golden board predates bounded uploads; start a new ceremony in a new data directory ({})", + path.display() + ) + })?; + ensure!( + format == BOARD_FORMAT, + "unsupported Golden board format; start a new ceremony in a new data directory" + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + impl BoardNode { + async fn create_for_test(data_directory: &Path) -> anyhow::Result<(Self, BoardTicket)> { + Self::create_with_network(data_directory, 3, false).await + } + + async fn join_for_test(data_directory: &Path, ticket: BoardTicket) -> anyhow::Result { + Self::join_with_network(data_directory, &ticket.to_string(), 3, false).await + } + + fn local_writer_for_test(&self) -> &BoardWriter { + match &self.publisher { + Publisher::Local(writer) => writer, + Publisher::Remote { .. } => panic!("expected local Golden board writer"), + } + } + + async fn upload_raw_for_test( + &self, + kind: u8, + participant: u32, + declared_length: u64, + value: &[u8], + ) -> anyhow::Result { + match &self.publisher { + Publisher::Remote { endpoint, target, upload_secret } => { + upload_artifact_request( + endpoint, + target, + upload_secret, + kind, + participant, + declared_length, + value, + ) + .await + }, + Publisher::Local(_) => anyhow::bail!("expected remote Golden board publisher"), + } + } + + async fn publish_hash_for_test( + &self, + slot: &ArtifactSlot, + hash: Hash, + size: u64, + ) -> anyhow::Result<()> { + let writer = self.local_writer_for_test(); + self.document + .set_hash(writer.author, slot.key(hash), hash, size) + .await + .context("failed to publish raw test hash") + } + } + + #[tokio::test] + async fn artifact_syncs_between_board_nodes() -> anyhow::Result<()> { + let root = tempfile::tempdir()?; + let (host, ticket) = BoardNode::create_for_test(&root.path().join("host")).await?; + assert!(matches!(ticket.document.capability, iroh_docs::Capability::Read(_))); + let client = BoardNode::join_for_test(&root.path().join("client"), ticket).await?; + let slot = ArtifactSlot::Registration(1); + let value = b"signed registration"; + + client.publish(&slot, value).await?; + assert_eq!(host.wait_unique(&slot, Duration::from_secs(10)).await?, value,); + + client.shutdown().await?; + host.shutdown().await?; + Ok(()) + } + + #[tokio::test] + async fn conflicting_artifacts_are_rejected() -> anyhow::Result<()> { + let root = tempfile::tempdir()?; + let (host, _) = BoardNode::create_for_test(&root.path().join("host")).await?; + let slot = ArtifactSlot::Manifest; + + host.publish(&slot, b"first").await?; + host.publish(&slot, b"second").await?; + let error = host.read_unique(&slot).await.unwrap_err(); + assert!(error.to_string().contains("conflicting artifacts")); + + host.shutdown().await?; + Ok(()) + } + + #[tokio::test] + async fn board_reopens_the_same_document_after_restart() -> anyhow::Result<()> { + let root = tempfile::tempdir()?; + let data_directory = root.path().join("host"); + let (host, first_ticket) = BoardNode::create_for_test(&data_directory).await?; + host.publish(&ArtifactSlot::Manifest, b"manifest").await?; + host.shutdown().await?; + + let (host, second_ticket) = BoardNode::create_for_test(&data_directory).await?; + assert_eq!(first_ticket.document.capability.id(), second_ticket.document.capability.id()); + assert_eq!(first_ticket.upload_secret, second_ticket.upload_secret); + assert_eq!(host.read_unique(&ArtifactSlot::Manifest).await?, Some(b"manifest".to_vec())); + + host.shutdown().await?; + Ok(()) + } + + #[tokio::test] + async fn unmarked_board_is_not_reopened_even_with_an_upload_secret() -> anyhow::Result<()> { + let root = tempfile::tempdir()?; + let data_directory = root.path().join("host"); + let (host, _) = BoardNode::create_for_test(&data_directory).await?; + host.shutdown().await?; + fs_err::remove_file(data_directory.join(BOARD_FORMAT_FILE))?; + + let error = BoardNode::create_for_test(&data_directory) + .await + .err() + .context("legacy board unexpectedly reopened")?; + assert!(error.to_string().contains("predates bounded uploads")); + Ok(()) + } + + #[tokio::test] + async fn unknown_participants_and_artifact_kinds_are_rejected_before_body_allocation() + -> anyhow::Result<()> { + let root = tempfile::tempdir()?; + let (host, ticket) = BoardNode::create_for_test(&root.path().join("host")).await?; + let client = BoardNode::join_for_test(&root.path().join("client"), ticket).await?; + + let error = client.upload_raw_for_test(1, 99, MAX_ARTIFACT_BYTES, &[]).await.unwrap_err(); + assert!(error.to_string().contains("unknown participant or artifact slot")); + let error = client.upload_raw_for_test(255, 1, 16, b"private artifact").await.unwrap_err(); + assert!(error.to_string().contains("unknown artifact kind")); + assert!(host.read_unique(&ArtifactSlot::Registration(1)).await?.is_none()); + + client.shutdown().await?; + host.shutdown().await?; + Ok(()) + } + + #[test] + fn oversized_artifacts_are_rejected_before_allocation() { + let oversized = usize::try_from(MAX_ARTIFACT_BYTES).unwrap() + 1; + assert!(validate_artifact_length(oversized).is_err()); + } + + #[tokio::test] + async fn oversized_upload_is_rejected_before_body_allocation() -> anyhow::Result<()> { + let root = tempfile::tempdir()?; + let (host, ticket) = BoardNode::create_for_test(&root.path().join("host")).await?; + let client = BoardNode::join_for_test(&root.path().join("client"), ticket).await?; + let error = + client.upload_raw_for_test(1, 1, MAX_ARTIFACT_BYTES + 1, &[]).await.unwrap_err(); + assert!(error.to_string().contains("exceeds")); + assert!(host.read_unique(&ArtifactSlot::Registration(1)).await?.is_none()); + + client.shutdown().await?; + host.shutdown().await?; + Ok(()) + } + + #[tokio::test] + async fn invalid_upload_secret_is_rejected_before_storage() -> anyhow::Result<()> { + let root = tempfile::tempdir()?; + let (host, mut ticket) = BoardNode::create_for_test(&root.path().join("host")).await?; + ticket.upload_secret[0] ^= 1; + let client = BoardNode::join_for_test(&root.path().join("client"), ticket).await?; + + let error = client + .publish(&ArtifactSlot::Registration(1), b"signed registration") + .await + .unwrap_err(); + assert!(error.to_string().contains("invalid Golden board upload secret")); + assert!(host.read_unique(&ArtifactSlot::Registration(1)).await?.is_none()); + + client.shutdown().await?; + host.shutdown().await?; + Ok(()) + } + + #[tokio::test] + async fn invalid_download_metadata_is_rejected() -> anyhow::Result<()> { + let root = tempfile::tempdir()?; + let (host, _) = BoardNode::create_for_test(&root.path().join("host")).await?; + let slot = ArtifactSlot::Manifest; + let value = b"manifest"; + let hash = host.publish(&slot, value).await?; + + host.publish_hash_for_test(&slot, hash, 1).await?; + let error = host.read_unique(&slot).await.unwrap_err(); + assert!(error.to_string().contains("length does not match")); + + host.shutdown().await?; + Ok(()) + } +} diff --git a/bin/validator/src/commands/dkg/runner.rs b/bin/validator/src/commands/dkg/runner.rs new file mode 100644 index 0000000000..5f9daf766e --- /dev/null +++ b/bin/validator/src/commands/dkg/runner.rs @@ -0,0 +1,726 @@ +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{Context, ensure}; +use golden_core::{EvrfProofBackend, ParticipantIndex}; +use miden_protocol::crypto::dsa::ecdsa_k256_keccak::PublicKey; +use miden_validator::ValidatorSigner; + +use super::board::{ArtifactSlot, BoardNode}; +use super::{ + CONTEXT_CONFIG_FILE, + CONTEXT_DEALING_FILE, + DECRYPTION_CONFIG_FILE, + DECRYPTION_DEALING_FILE, + Deserialize, + Digest, + EPOCH_FILE, + GoldenGroup, + IDENTITY_SECRET_FILE, + MANIFEST_FILE, + OsRng, + PRIVATE_STATE_FILE, + PUBLIC_KEY_SET_FILE, + REGISTRATION_FILE, + Registration, + Rpo256, + SETUP_CONTEXT_FILE, + SecpSecqBackend, + Serializable, + Serialize, + Sha256, + StorageGroup, + TRANSCRIPT_ACCEPTANCE_FILE, + TRANSCRIPT_FILE, + ValidatorSigningKey, + WireMessage, + Word, + Zeroizing, + accept_transcript, + deal, + decode_fixed_hex, + decode_identity_secret, + decode_validator_public_key, + decode_validator_signature, + finalize, + generate_identity, + prepare, + publish_directory, + read_ceremony, + read_registration, + read_trusted_genesis, + read_validated_registrations, + validate_bundle, + write_new_file, +}; + +const IDENTITY_DIRECTORY: &str = "identity"; +const CEREMONY_DIRECTORY: &str = "ceremony"; +const DEALINGS_DIRECTORY: &str = "dealings"; +const PUBLIC_DEALINGS_DIRECTORY: &str = "public-dealings"; +const ACCEPTANCE_DIRECTORY: &str = "acceptance"; +const PUBLIC_ACCEPTANCES_DIRECTORY: &str = "public-acceptances"; +const BOARD_DIRECTORY: &str = "board"; +const CEREMONY_WAIT_TIMEOUT: Duration = Duration::from_hours(24); +const PUBLIC_OUTPUT_DIGEST_DOMAIN: &[u8] = b"miden-golden-dkg-public-output-v1"; +const FINAL_CONFIRMATION_VERSION: &str = "miden-golden-dkg-final-confirmation-v1"; +const FINAL_CONFIRMATION_SIGNATURE_DOMAIN: &[u8] = + b"miden-golden-dkg-final-confirmation-signature-v1"; + +#[derive(Deserialize, Serialize)] +struct FinalConfirmation { + version: String, + validator_public_key: String, + public_output_sha256: String, + validator_signature: String, +} + +/// Inputs for the shared Golden DKG board. +#[derive(clap::Args)] +pub(super) struct GoldenDkgBoardServeOptions { + /// Durable directory for the Iroh endpoint, document, and common ceremony files. + #[arg(long, value_name = "DIR")] + data_directory: PathBuf, + + /// Trusted genesis block for the network. + #[arg(long, value_name = "FILE")] + genesis: PathBuf, + + /// Number of shares needed to decrypt a private record. + #[arg(long, value_name = "NUM")] + threshold: usize, + + /// Hex-encoded 32-byte storage-key epoch. + #[arg(long, value_name = "HEX")] + epoch: String, + + /// New private file that receives the board ticket for automation. + #[arg(long, value_name = "FILE")] + ticket_output: Option, +} + +/// Inputs for one validator's automatic Golden DKG ceremony runner. +#[derive(clap::Args)] +pub(super) struct GoldenDkgRunOptions { + /// Read and upload ticket printed by `golden-dkg board`. + #[arg(long, value_name = "BOARD_TICKET", required_unless_present = "board_file")] + board: Option, + + /// Private file containing the read and upload board ticket. + #[arg(long, value_name = "FILE", conflicts_with = "board")] + board_file: Option, + + /// Trusted genesis block for the network. + #[arg(long, value_name = "FILE")] + genesis: PathBuf, + + /// Validator signing key committed by genesis. + #[command(flatten)] + signing_key: ValidatorSigningKey, + + /// Durable directory for private ceremony state and the local Iroh endpoint. + #[arg(long, value_name = "DIR")] + work_directory: PathBuf, + + /// New directory that receives the final storage-key bundle. + #[arg(long, value_name = "DIR")] + output_directory: PathBuf, +} + +/// Runs one validator through every DKG phase. +pub(super) async fn run_validator(options: GoldenDkgRunOptions) -> anyhow::Result<()> { + let board = if let Some(ticket) = options.board { + ticket + } else { + let path = options.board_file.context("a board ticket or board ticket file is required")?; + fs_err::read_to_string(&path) + .with_context(|| format!("failed to read Golden DKG board ticket {}", path.display()))? + .trim() + .to_owned() + }; + ensure!(!board.is_empty(), "Golden DKG board ticket must not be empty"); + let signer = options.signing_key.into_signer().await?; + run_validator_with_network::( + &board, + &options.genesis, + &signer, + &options.work_directory, + &options.output_directory, + true, + CEREMONY_WAIT_TIMEOUT, + ) + .await +} + +pub(super) async fn serve_board(options: GoldenDkgBoardServeOptions) -> anyhow::Result<()> { + let genesis = read_trusted_genesis(&options.genesis)?; + let participant_count = genesis.inner().header().validator_keys().as_keys().len(); + let (board, ticket) = BoardNode::create(&options.data_directory, participant_count).await?; + if let Some(path) = &options.ticket_output { + write_new_file(path, ticket.to_string().as_bytes(), true)?; + println!("Golden DKG board ticket written to {}", path.display()); + } else { + println!("Golden DKG board ticket:\n{ticket}"); + } + + let result = async { + coordinate_common_files( + &board, + &options.data_directory, + &options.genesis, + options.threshold, + &options.epoch, + CEREMONY_WAIT_TIMEOUT, + ) + .await?; + println!("Golden DKG board is ready. Press Ctrl-C to stop it."); + tokio::signal::ctrl_c().await.context("failed to wait for Ctrl-C") + } + .await; + let shutdown = board.shutdown().await; + result.and(shutdown) +} + +/// Waits for signed registrations, prepares the ceremony, and publishes its common files. +pub(super) async fn coordinate_common_files( + board: &BoardNode, + data_directory: &Path, + genesis_path: &Path, + threshold: usize, + epoch: &str, + timeout: Duration, +) -> anyhow::Result<()> { + let genesis = read_trusted_genesis(genesis_path)?; + let validator_keys = genesis.inner().header().validator_keys().as_keys(); + ensure!( + threshold > 0 && threshold <= validator_keys.len(), + "threshold must be between 1 and {}", + validator_keys.len(), + ); + decode_fixed_hex::<32>(epoch, "storage-key epoch")?; + + let ceremony_directory = data_directory.join(CEREMONY_DIRECTORY); + if !ceremony_directory.exists() { + let registrations = wait_for_registrations(board, validator_keys, timeout).await?; + let registration_directory = data_directory.join("registrations"); + materialize_or_compare(®istration_directory, ®istrations)?; + let paths = registrations + .iter() + .map(|(name, _)| registration_directory.join(name)) + .collect::>(); + prepare(genesis_path, threshold, epoch, &paths, &ceremony_directory)?; + } + + let ceremony = read_ceremony(genesis_path, &ceremony_directory)?; + ensure!( + ceremony.manifest.threshold == threshold, + "board threshold changed after creation" + ); + ensure!(ceremony.manifest.epoch == epoch, "board epoch changed after creation"); + publish_named_file(board, &ArtifactSlot::Manifest, &ceremony_directory.join(MANIFEST_FILE)) + .await?; + publish_named_file( + board, + &ArtifactSlot::DecryptionConfig, + &ceremony_directory.join(DECRYPTION_CONFIG_FILE), + ) + .await?; + publish_named_file( + board, + &ArtifactSlot::ContextConfig, + &ceremony_directory.join(CONTEXT_CONFIG_FILE), + ) + .await?; + Ok(()) +} + +async fn wait_for_registrations( + board: &BoardNode, + validator_keys: &[PublicKey], + timeout: Duration, +) -> anyhow::Result)>> { + let mut files = Vec::with_capacity(validator_keys.len()); + for (position, validator_key) in validator_keys.iter().enumerate() { + let participant = participant_at(position)?; + let bytes = board + .wait_unique(&ArtifactSlot::Registration(participant.get()), timeout) + .await?; + let registration: Registration = toml::from_slice(&bytes).with_context(|| { + format!("invalid registration for participant {}", participant.get()) + })?; + ensure!( + registration.validator_public_key == hex::encode(validator_key.to_bytes()), + "registration slot {} belongs to another genesis validator", + participant.get(), + ); + files.push((format!("registration-{}.toml", participant.get()), bytes)); + } + Ok(files) +} + +/// Runs the restartable validator state machine over one board. +pub(super) async fn run_validator_with_network( + ticket: &str, + genesis_path: &Path, + signer: &ValidatorSigner, + work_directory: &Path, + output_directory: &Path, + use_network_services: bool, + timeout: Duration, +) -> anyhow::Result<()> +where + B: EvrfProofBackend, + B::Proof: WireMessage, +{ + fs_err::create_dir_all(work_directory).with_context(|| { + format!("failed to create DKG work directory {}", work_directory.display()) + })?; + let genesis = read_trusted_genesis(genesis_path)?; + let participant_count = genesis.inner().header().validator_keys().as_keys().len(); + let participant = prepare_local_identity(genesis_path, signer, work_directory).await?; + let board_directory = work_directory.join(BOARD_DIRECTORY); + let board = if use_network_services { + BoardNode::join(&board_directory, ticket, participant_count).await? + } else { + BoardNode::join_with_network(&board_directory, ticket, participant_count, false).await? + }; + let result = run_validator_on_board::( + &board, + genesis_path, + signer, + participant, + work_directory, + output_directory, + timeout, + ) + .await; + let shutdown = board.shutdown().await; + result.and(shutdown) +} + +#[expect( + clippy::too_many_lines, + reason = "the linear body mirrors the ceremony phase order" +)] +async fn run_validator_on_board( + board: &BoardNode, + genesis_path: &Path, + signer: &ValidatorSigner, + participant: ParticipantIndex, + work_directory: &Path, + output_directory: &Path, + timeout: Duration, +) -> anyhow::Result<()> +where + B: EvrfProofBackend, + B::Proof: WireMessage, +{ + let identity_directory = work_directory.join(IDENTITY_DIRECTORY); + publish_named_file( + board, + &ArtifactSlot::Registration(participant.get()), + &identity_directory.join(REGISTRATION_FILE), + ) + .await?; + + let ceremony_directory = work_directory.join(CEREMONY_DIRECTORY); + let common = vec![ + ( + MANIFEST_FILE.to_owned(), + board.wait_unique(&ArtifactSlot::Manifest, timeout).await?, + ), + ( + DECRYPTION_CONFIG_FILE.to_owned(), + board.wait_unique(&ArtifactSlot::DecryptionConfig, timeout).await?, + ), + ( + CONTEXT_CONFIG_FILE.to_owned(), + board.wait_unique(&ArtifactSlot::ContextConfig, timeout).await?, + ), + ]; + materialize_or_compare(&ceremony_directory, &common)?; + let ceremony = read_ceremony(genesis_path, &ceremony_directory)?; + ensure!( + ceremony.manifest.participants[participant.get() as usize - 1].validator_public_key + == hex::encode(signer.public_key().to_bytes()), + "validator signing key has the wrong ceremony participant index", + ); + + let dealings_directory = work_directory.join(DEALINGS_DIRECTORY); + if !dealings_directory.exists() { + deal::( + genesis_path, + &ceremony_directory, + &identity_directory.join(IDENTITY_SECRET_FILE), + &dealings_directory, + &mut OsRng, + )?; + } + publish_named_file( + board, + &ArtifactSlot::DecryptionDealing(participant.get()), + &dealings_directory.join(DECRYPTION_DEALING_FILE), + ) + .await?; + publish_named_file( + board, + &ArtifactSlot::ContextDealing(participant.get()), + &dealings_directory.join(CONTEXT_DEALING_FILE), + ) + .await?; + + let participant_count = ceremony.manifest.participants.len(); + let public_dealings_directory = work_directory.join(PUBLIC_DEALINGS_DIRECTORY); + let public_dealings = wait_for_dealings(board, participant_count, timeout).await?; + materialize_or_compare(&public_dealings_directory, &public_dealings)?; + let decryption_dealings = participant_files( + &public_dealings_directory, + "decryption-dealing", + "wire", + participant_count, + )?; + let context_dealings = participant_files( + &public_dealings_directory, + "context-dealing", + "wire", + participant_count, + )?; + + let acceptance_directory = work_directory.join(ACCEPTANCE_DIRECTORY); + if !acceptance_directory.exists() { + accept_transcript::( + genesis_path, + &ceremony_directory, + signer, + &decryption_dealings, + &context_dealings, + &acceptance_directory, + ) + .await?; + } + publish_named_file( + board, + &ArtifactSlot::Transcript(participant.get()), + &acceptance_directory.join(TRANSCRIPT_FILE), + ) + .await?; + publish_named_file( + board, + &ArtifactSlot::TranscriptAcceptance(participant.get()), + &acceptance_directory.join(TRANSCRIPT_ACCEPTANCE_FILE), + ) + .await?; + + let (transcript, acceptances) = wait_for_acceptances(board, participant_count, timeout).await?; + let public_acceptances_directory = work_directory.join(PUBLIC_ACCEPTANCES_DIRECTORY); + let mut acceptance_files = vec![(TRANSCRIPT_FILE.to_owned(), transcript)]; + acceptance_files.extend(acceptances); + materialize_or_compare(&public_acceptances_directory, &acceptance_files)?; + let transcript_path = public_acceptances_directory.join(TRANSCRIPT_FILE); + let transcript_acceptances = participant_files( + &public_acceptances_directory, + "transcript-acceptance", + "toml", + participant_count, + )?; + + if !output_directory.exists() { + finalize::( + genesis_path, + &ceremony_directory, + &identity_directory.join(IDENTITY_SECRET_FILE), + &dealings_directory.join(PRIVATE_STATE_FILE), + &decryption_dealings, + &context_dealings, + &transcript_path, + &transcript_acceptances, + output_directory, + )?; + } + validate_bundle( + genesis_path, + &ceremony_directory, + &hex::encode(signer.public_key().to_bytes()), + output_directory, + )?; + + let digest = public_output_digest(output_directory)?; + let confirmation = sign_final_confirmation(signer, ceremony.genesis_commitment, digest).await?; + board + .publish(&ArtifactSlot::FinalConfirmation(participant.get()), &confirmation) + .await?; + for position in 0..participant_count { + let other = participant_at(position)?; + let other_confirmation = board + .wait_unique(&ArtifactSlot::FinalConfirmation(other.get()), timeout) + .await?; + validate_final_confirmation( + &other_confirmation, + &ceremony.manifest.participants[position].validator_public_key, + ceremony.genesis_commitment, + digest, + )?; + } + println!("Golden DKG completed for participant {}.", participant.get()); + Ok(()) +} + +pub(super) async fn prepare_local_identity( + genesis_path: &Path, + signer: &ValidatorSigner, + work_directory: &Path, +) -> anyhow::Result { + let validator_key = signer.public_key(); + let participant = participant_for_validator(genesis_path, &validator_key)?; + let identity_directory = work_directory.join(IDENTITY_DIRECTORY); + if !identity_directory.exists() { + generate_identity(genesis_path, signer, &identity_directory).await?; + } + validate_local_identity(genesis_path, &validator_key, &identity_directory)?; + Ok(participant) +} + +fn participant_for_validator( + genesis_path: &Path, + validator_key: &PublicKey, +) -> anyhow::Result { + let genesis = read_trusted_genesis(genesis_path)?; + let position = genesis + .inner() + .header() + .validator_keys() + .as_keys() + .iter() + .position(|key| key == validator_key) + .context("validator signing key is not committed by genesis")?; + participant_at(position) +} + +fn participant_at(position: usize) -> anyhow::Result { + ParticipantIndex::new(u32::try_from(position + 1).context("too many Golden participants")?) + .map_err(Into::into) +} + +fn validate_local_identity( + genesis_path: &Path, + validator_key: &PublicKey, + identity_directory: &Path, +) -> anyhow::Result<()> { + let registration_path = identity_directory.join(REGISTRATION_FILE); + let registration = read_registration(®istration_path)?; + ensure!( + registration.validator_public_key == hex::encode(validator_key.to_bytes()), + "stored DKG identity belongs to another validator", + ); + let genesis = read_trusted_genesis(genesis_path)?; + read_validated_registrations(&[registration_path], genesis.inner().header().commitment())?; + let secret = Zeroizing::new(fs_err::read(identity_directory.join(IDENTITY_SECRET_FILE))?); + let secret = decode_identity_secret(&secret)?; + ensure!( + hex::encode(StorageGroup::encode_element(&StorageGroup::mul_generator(&secret))) + == registration.dkg_identity_public_key, + "stored DKG identity secret does not match its registration", + ); + Ok(()) +} + +async fn wait_for_dealings( + board: &BoardNode, + participant_count: usize, + timeout: Duration, +) -> anyhow::Result)>> { + let mut files = Vec::with_capacity(participant_count * 2); + for position in 0..participant_count { + let participant = participant_at(position)?; + files.push(( + format!("decryption-dealing-{}.wire", participant.get()), + board + .wait_unique(&ArtifactSlot::DecryptionDealing(participant.get()), timeout) + .await?, + )); + files.push(( + format!("context-dealing-{}.wire", participant.get()), + board + .wait_unique(&ArtifactSlot::ContextDealing(participant.get()), timeout) + .await?, + )); + } + Ok(files) +} + +async fn wait_for_acceptances( + board: &BoardNode, + participant_count: usize, + timeout: Duration, +) -> anyhow::Result<(Vec, Vec<(String, Vec)>)> { + let mut transcript = None; + let mut acceptances = Vec::with_capacity(participant_count); + for position in 0..participant_count { + let participant = participant_at(position)?; + let candidate = + board.wait_unique(&ArtifactSlot::Transcript(participant.get()), timeout).await?; + if let Some(expected) = &transcript { + ensure!(candidate == *expected, "validators accepted different DKG transcripts"); + } else { + transcript = Some(candidate); + } + acceptances.push(( + format!("transcript-acceptance-{}.toml", participant.get()), + board + .wait_unique(&ArtifactSlot::TranscriptAcceptance(participant.get()), timeout) + .await?, + )); + } + Ok((transcript.context("ceremony has no participants")?, acceptances)) +} + +async fn publish_named_file( + board: &BoardNode, + slot: &ArtifactSlot, + path: &Path, +) -> anyhow::Result<()> { + let bytes = fs_err::read(path) + .with_context(|| format!("failed to read ceremony artifact {}", path.display()))?; + board.publish(slot, &bytes).await?; + Ok(()) +} + +fn materialize_or_compare(directory: &Path, files: &[(String, Vec)]) -> anyhow::Result<()> { + if directory.exists() { + for (name, expected) in files { + let path = directory.join(name); + let actual = fs_err::read(&path).with_context(|| { + format!("failed to read cached board artifact {}", path.display()) + })?; + ensure!(actual == *expected, "cached board artifact {} changed", path.display()); + } + return Ok(()); + } + publish_directory(directory, |temporary| { + for (name, bytes) in files { + write_new_file(&temporary.join(name), bytes, false)?; + } + Ok(()) + }) +} + +fn participant_files( + directory: &Path, + stem: &str, + extension: &str, + participant_count: usize, +) -> anyhow::Result> { + (0..participant_count) + .map(|position| { + Ok(directory.join(format!("{stem}-{}.{}", participant_at(position)?.get(), extension))) + }) + .collect() +} + +fn public_output_digest(bundle_directory: &Path) -> anyhow::Result<[u8; 32]> { + let mut digest = Sha256::new(); + digest.update(PUBLIC_OUTPUT_DIGEST_DOMAIN); + for name in [EPOCH_FILE, SETUP_CONTEXT_FILE, PUBLIC_KEY_SET_FILE] { + let bytes = fs_err::read(bundle_directory.join(name))?; + digest.update(u64::try_from(bytes.len())?.to_be_bytes()); + digest.update(bytes); + } + Ok(digest.finalize().into()) +} + +async fn sign_final_confirmation( + signer: &ValidatorSigner, + genesis_commitment: Word, + public_output_sha256: [u8; 32], +) -> anyhow::Result> { + let validator_public_key = signer.public_key(); + let signature = signer + .sign_commitment(final_confirmation_commitment(genesis_commitment, public_output_sha256)) + .await + .context("failed to sign Golden final confirmation")?; + let confirmation = FinalConfirmation { + version: FINAL_CONFIRMATION_VERSION.to_owned(), + validator_public_key: hex::encode(validator_public_key.to_bytes()), + public_output_sha256: hex::encode(public_output_sha256), + validator_signature: hex::encode(signature.to_bytes()), + }; + toml::to_string_pretty(&confirmation) + .context("failed to encode Golden final confirmation") + .map(String::into_bytes) +} + +fn validate_final_confirmation( + bytes: &[u8], + expected_validator_public_key: &str, + genesis_commitment: Word, + expected_public_output_sha256: [u8; 32], +) -> anyhow::Result<()> { + let confirmation: FinalConfirmation = + toml::from_slice(bytes).context("invalid Golden final confirmation")?; + ensure!( + confirmation.version == FINAL_CONFIRMATION_VERSION, + "unsupported Golden final confirmation version" + ); + ensure!( + confirmation.validator_public_key == expected_validator_public_key, + "Golden final confirmation belongs to another validator" + ); + ensure!( + confirmation.public_output_sha256 == hex::encode(expected_public_output_sha256), + "validators produced different Golden public outputs" + ); + let validator_public_key = decode_validator_public_key(&confirmation.validator_public_key)?; + let signature = decode_validator_signature(&confirmation.validator_signature)?; + ensure!( + signature.verify( + final_confirmation_commitment(genesis_commitment, expected_public_output_sha256), + &validator_public_key, + ), + "invalid Golden final confirmation signature" + ); + Ok(()) +} + +fn final_confirmation_commitment(genesis_commitment: Word, public_output_sha256: [u8; 32]) -> Word { + let mut bytes = Vec::with_capacity( + FINAL_CONFIRMATION_SIGNATURE_DOMAIN.len() + + Word::SERIALIZED_SIZE + + public_output_sha256.len(), + ); + bytes.extend_from_slice(FINAL_CONFIRMATION_SIGNATURE_DOMAIN); + bytes.extend_from_slice(&genesis_commitment.to_bytes()); + bytes.extend_from_slice(&public_output_sha256); + Rpo256::hash(&bytes) +} + +#[cfg(test)] +mod tests { + use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey; + + use super::*; + + #[tokio::test] + async fn final_confirmation_cannot_be_copied_between_validator_slots() -> anyhow::Result<()> { + let first = ValidatorSigner::new_local(SigningKey::new()); + let second = ValidatorSigner::new_local(SigningKey::new()); + let genesis_commitment = Word::default(); + let digest = [7; 32]; + let confirmation = sign_final_confirmation(&first, genesis_commitment, digest).await?; + + validate_final_confirmation( + &confirmation, + &hex::encode(first.public_key().to_bytes()), + genesis_commitment, + digest, + )?; + let error = validate_final_confirmation( + &confirmation, + &hex::encode(second.public_key().to_bytes()), + genesis_commitment, + digest, + ) + .unwrap_err(); + assert!(error.to_string().contains("another validator")); + Ok(()) + } +} diff --git a/bin/validator/src/commands/dkg/tests.rs b/bin/validator/src/commands/dkg/tests.rs index 85ddc5873c..848531470a 100644 --- a/bin/validator/src/commands/dkg/tests.rs +++ b/bin/validator/src/commands/dkg/tests.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use golden_core::wire::from_wire_bytes; use golden_ehtdh1::wire::from_wire_bytes as from_ehtdh1_wire_bytes; use golden_ehtdh1::{ @@ -897,3 +899,102 @@ async fn deal_rejects_unknown_identity_and_existing_output() -> TestResult { ); Ok(()) } +#[tokio::test] +async fn iroh_board_restarts_a_runner_and_completes_three_validator_ceremony() -> TestResult { + let root = tempfile::tempdir()?; + let genesis = write_genesis(root.path())?; + let board_directory = root.path().join("board"); + let (board, ticket) = board::BoardNode::create_with_network(&board_directory, 3, false).await?; + let ticket = ticket.to_string(); + let timeout = Duration::from_mins(2); + let restart_checkpoint_timeout = Duration::from_secs(10); + let first_work = root.path().join("work-1"); + fs_err::create_dir(&first_work)?; + let first_signer = ValidatorSigner::new_local(genesis.signing_keys[0].clone()); + + let interrupted = tokio::spawn({ + let genesis_path = genesis.path.clone(); + let first_work = first_work.clone(); + async move { + runner::prepare_local_identity(&genesis_path, &first_signer, &first_work).await?; + std::future::pending::>().await + } + }); + let registration = first_work.join("identity").join(REGISTRATION_FILE); + tokio::time::timeout(restart_checkpoint_timeout, async { + while !registration.exists() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await?; + interrupted.abort(); + assert!(interrupted.await.unwrap_err().is_cancelled()); + tokio::time::sleep(Duration::from_millis(100)).await; + + let signers = genesis + .signing_keys + .iter() + .cloned() + .map(ValidatorSigner::new_local) + .collect::>(); + let work_directories = (1..=3) + .map(|participant| root.path().join(format!("work-{participant}"))) + .collect::>(); + let bundle_directories = (1..=3) + .map(|participant| root.path().join(format!("bundle-{participant}"))) + .collect::>(); + let epoch = "66".repeat(32); + let coordinate = runner::coordinate_common_files( + &board, + &board_directory, + &genesis.path, + 2, + &epoch, + timeout, + ); + let first = runner::run_validator_with_network::( + &ticket, + &genesis.path, + &signers[0], + &work_directories[0], + &bundle_directories[0], + false, + timeout, + ); + let second = runner::run_validator_with_network::( + &ticket, + &genesis.path, + &signers[1], + &work_directories[1], + &bundle_directories[1], + false, + timeout, + ); + let third = runner::run_validator_with_network::( + &ticket, + &genesis.path, + &signers[2], + &work_directories[2], + &bundle_directories[2], + false, + timeout, + ); + tokio::try_join!(coordinate, first, second, third)?; + + let shared_setup = fs_err::read(bundle_directories[0].join(SETUP_CONTEXT_FILE))?; + let shared_public_keys = fs_err::read(bundle_directories[0].join(PUBLIC_KEY_SET_FILE))?; + let secret_shares = bundle_directories + .iter() + .map(|bundle| fs_err::read(bundle.join(SECRET_SHARE_FILE))) + .collect::, _>>()?; + assert!(bundle_directories.iter().all(|bundle| { + fs_err::read(bundle.join(SETUP_CONTEXT_FILE)).unwrap() == shared_setup + && fs_err::read(bundle.join(PUBLIC_KEY_SET_FILE)).unwrap() == shared_public_keys + })); + assert_ne!(secret_shares[0], secret_shares[1]); + assert_ne!(secret_shares[1], secret_shares[2]); + assert_ne!(secret_shares[0], secret_shares[2]); + + board.shutdown().await?; + Ok(()) +} From d1f91bedaf957d17667925dd90b416b64ce73778 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Tue, 4 Aug 2026 11:44:54 -0400 Subject: [PATCH 02/28] docs(validator): explain Iroh DKG exchange --- .../network-operator/bootstrap-and-genesis.md | 10 +++-- .../src/network-operator/validator.md | 39 +++++++++++++++++-- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/docs/external/src/network-operator/bootstrap-and-genesis.md b/docs/external/src/network-operator/bootstrap-and-genesis.md index 1a8b12417c..471486ea3d 100644 --- a/docs/external/src/network-operator/bootstrap-and-genesis.md +++ b/docs/external/src/network-operator/bootstrap-and-genesis.md @@ -152,10 +152,12 @@ The key each validator operator starts their validator with must match the publi After genesis is built, every listed validator must join one offline DKG ceremony. The ceremony creates the shared public storage key and one distinct secret share per validator. No coordinator can derive those shares. -Each operator first registers a fresh DKG identity with the validator signing key committed in genesis. One coordinator -uses every signed registration to prepare the common ceremony. Every operator then creates two public dealings, checks -and signs the same full transcript, and completes both rounds locally. The DKG and database bootstrap may run in either -order, but both must finish before the validator starts. +For the normal flow, one operator starts the durable Iroh board and sends its private ticket to each validator through +the authenticated bootstrap channel. Each validator runs the full ceremony with the signing key committed in genesis. +The board carries only public ceremony artifacts. Validator signatures authenticate registrations and transcript +checkpoints. Each validator keeps its identity, private DKG state, and final secret share local. The manual file +commands remain available for recovery. The DKG and database bootstrap may run in either order, but both must finish +before the validator starts. All listed validators must contribute to the ceremony even when the recovery threshold is lower. If any participant drops out or any transcript differs, discard the incomplete ceremony and start a new one with fresh identities and diff --git a/docs/external/src/network-operator/validator.md b/docs/external/src/network-operator/validator.md index fdf0edc99d..aac1baa88d 100644 --- a/docs/external/src/network-operator/validator.md +++ b/docs/external/src/network-operator/validator.md @@ -40,9 +40,42 @@ This flow supports initial storage-key bootstrap only. The validator loads one s shares, and validator-set changes are not yet supported. Keep each operator bundle available for as long as records from its epoch may need to be decrypted. -First, each operator creates a DKG identity for the agreed storage-key epoch and sends `registration.toml` to the -coordinator. The registration proves ownership of the DKG identity secret. The signing key must match one key in -genesis. Use `--signing-key.hex` instead of KMS only for local or private deployments. +For the normal ceremony, one operator starts the durable Iroh bulletin board: + +```bash +miden-validator golden-dkg board \ + --data-directory golden-board \ + --genesis genesis.dat \ + --threshold 2 \ + --epoch <32-byte-hex-epoch> \ + --ticket-output golden-board-ticket +``` + +The command writes one board ticket. Send the file to each genesis validator through the authenticated bootstrap +channel. The ticket grants read access and permission to upload bounded ceremony artifacts. Do not publish it. Keep the +board running until every validator reports ceremony completion, then stop it with Ctrl-C. + +Each validator then runs the full ceremony with its own signing key and private work directory: + +```bash +miden-validator golden-dkg run \ + --board-file \ + --genesis genesis.dat \ + --signing-key.kms-id \ + --work-directory golden-work \ + --output-directory storage-key +``` + +Both commands can restart with the same data and work directories. Give `--ticket-output` a new path when restarting the +board because it will not overwrite a ticket file. The board prepares the common files after all signed registrations +arrive. Each validator checks every artifact, writes its own storage key bundle, and confirms that all validators +produced the same public output. A board directory from an older format cannot be reopened; start that ceremony again in +a new directory. + +The commands below provide a manual recovery path. First, each operator creates a DKG identity for the agreed storage-key +epoch and sends `registration.toml` to the coordinator. The registration proves ownership of the DKG identity secret. +The signing key must match one key in genesis. Use `--signing-key.hex` instead of KMS only for local or private +deployments. ```bash miden-validator dkg identity \ From e8fb686b6f5c7884fa8d8d33c8d09b9cbb3064c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Tue, 4 Aug 2026 11:44:59 -0400 Subject: [PATCH 03/28] test(validator): exercise Golden DKG over Iroh in Compose --- compose/validator.yml | 115 ++++++++++++++++++ .../external/src/local-network-development.md | 10 ++ 2 files changed, 125 insertions(+) diff --git a/compose/validator.yml b/compose/validator.yml index 966f48f6b3..86b91226f6 100644 --- a/compose/validator.yml +++ b/compose/validator.yml @@ -15,6 +15,121 @@ x-validator: &validator - --admin.listen=0.0.0.0:50102 services: + golden-dkg-check: + profiles: ["golden-dkg"] + image: ${MIDEN_VALIDATOR_IMAGE:-miden-validator} + pull_policy: missing + volumes: + - node-data:/data + environment: + MIDEN_VALIDATOR_STORAGE_KEY_EPOCH: ${MIDEN_VALIDATOR_STORAGE_KEY_EPOCH:-0909090909090909090909090909090909090909090909090909090909090909} + depends_on: + bootstrap-validator: + condition: service_completed_successfully + entrypoint: ["/bin/sh", "-c"] + command: + - | + set -eu + ROOT=/data/golden-dkg-check + TICKET="$${ROOT}/board-ticket" + GENESIS=/data/genesis/genesis.dat + EPOCH="$${MIDEN_VALIDATOR_STORAGE_KEY_EPOCH}" + rm -rf "$${ROOT}" + mkdir -p "$${ROOT}" + + miden-validator golden-dkg board \ + --data-directory "$${ROOT}/board" \ + --genesis "$${GENESIS}" \ + --threshold 2 \ + --epoch "$${EPOCH}" \ + --ticket-output "$${TICKET}" & + BOARD_PID=$$! + RUNNER_PIDS="" + cleanup() { + for PID in $${RUNNER_PIDS}; do + kill -TERM "$${PID}" 2>/dev/null || true + done + kill -INT "$${BOARD_PID}" 2>/dev/null || true + for PID in $${RUNNER_PIDS}; do + wait "$${PID}" 2>/dev/null || true + done + wait "$${BOARD_PID}" 2>/dev/null || true + } + trap cleanup EXIT INT TERM + + while [ ! -s "$${TICKET}" ]; do + kill -0 "$${BOARD_PID}" 2>/dev/null + sleep 1 + done + run_validator() { + PARTICIPANT="$$1" + SIGNING_KEY="$$2" + exec miden-validator golden-dkg run \ + --board-file "$${TICKET}" \ + --genesis "$${GENESIS}" \ + --signing-key.hex "$${SIGNING_KEY}" \ + --work-directory "$${ROOT}/validator-$${PARTICIPANT}/work" \ + --output-directory "$${ROOT}/validator-$${PARTICIPANT}/bundle" + } + + run_checked() { + PARTICIPANT="$$1" + SIGNING_KEY="$$2" + run_validator "$${PARTICIPANT}" "$${SIGNING_KEY}" & + VALIDATOR_PID=$$! + terminate_validator() { + kill -TERM "$${VALIDATOR_PID}" 2>/dev/null || true + wait "$${VALIDATOR_PID}" 2>/dev/null || true + echo 143 > "$${ROOT}/status-$${PARTICIPANT}" + exit 143 + } + trap terminate_validator INT TERM + set +e + wait "$${VALIDATOR_PID}" + STATUS=$$? + set -e + trap - INT TERM + echo "$${STATUS}" > "$${ROOT}/status-$${PARTICIPANT}" + exit "$${STATUS}" + } + + run_checked 1 0101010101010101010101010101010101010101010101010101010101010101 & + FIRST_PID=$$! + run_checked 2 0303030303030303030303030303030303030303030303030303030303030303 & + SECOND_PID=$$! + run_checked 3 0404040404040404040404040404040404040404040404040404040404040404 & + THIRD_PID=$$! + RUNNER_PIDS="$${FIRST_PID} $${SECOND_PID} $${THIRD_PID}" + + while :; do + COMPLETE=0 + for PARTICIPANT in 1 2 3; do + STATUS_FILE="$${ROOT}/status-$${PARTICIPANT}" + if [ -f "$${STATUS_FILE}" ]; then + STATUS="$$(cat "$${STATUS_FILE}")" + [ "$${STATUS}" -eq 0 ] || exit "$${STATUS}" + COMPLETE=$$((COMPLETE + 1)) + fi + done + [ "$${COMPLETE}" -eq 3 ] && break + sleep 1 + done + wait "$${FIRST_PID}" + wait "$${SECOND_PID}" + wait "$${THIRD_PID}" + + for PARTICIPANT in 2 3; do + cmp "$${ROOT}/validator-1/bundle/epoch.hex" \ + "$${ROOT}/validator-$${PARTICIPANT}/bundle/epoch.hex" + cmp "$${ROOT}/validator-1/bundle/setup-context.wire" \ + "$${ROOT}/validator-$${PARTICIPANT}/bundle/setup-context.wire" + cmp "$${ROOT}/validator-1/bundle/public-key-set.wire" \ + "$${ROOT}/validator-$${PARTICIPANT}/bundle/public-key-set.wire" + ! cmp -s "$${ROOT}/validator-1/bundle/secret-share.wire" \ + "$${ROOT}/validator-$${PARTICIPANT}/bundle/secret-share.wire" + done + echo "Golden DKG completed over the Iroh document." + validator-1: <<: *validator environment: diff --git a/docs/external/src/local-network-development.md b/docs/external/src/local-network-development.md index 84d92b8f0d..8c798e9542 100644 --- a/docs/external/src/local-network-development.md +++ b/docs/external/src/local-network-development.md @@ -238,6 +238,16 @@ starting the network. This can take several minutes. For a faster local start, s `MIDEN_VALIDATOR_USE_STORAGE_KEY_FIXTURE=true` to use the committed insecure fixture instead. The fixture is public test data and must never be used outside local development. +To exercise the production ceremony and Iroh document locally, build the validator image and run the opt-in check: + +```bash +make docker-build-validator +docker compose --profile golden-dkg run --rm golden-dkg-check +``` + +This runs the production proof backend and can take several minutes. It does not replace the fixture used by normal +local startup. + ## Check the RPC API The RPC server exposes gRPC reflection. With `grpcurl` installed, a basic status check looks like: From 22921c31b83280e53dcdca47354739d9e32472af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Tue, 4 Aug 2026 13:34:08 -0400 Subject: [PATCH 04/28] refactor(validator): split Iroh DKG tests --- bin/validator/src/commands/dkg/board.rs | 197 +----------------- bin/validator/src/commands/dkg/runner.rs | 31 +-- .../src/commands/golden_dkg/board/tests.rs | 193 +++++++++++++++++ .../src/commands/golden_dkg/runner/tests.rs | 28 +++ 4 files changed, 223 insertions(+), 226 deletions(-) create mode 100644 bin/validator/src/commands/golden_dkg/board/tests.rs create mode 100644 bin/validator/src/commands/golden_dkg/runner/tests.rs diff --git a/bin/validator/src/commands/dkg/board.rs b/bin/validator/src/commands/dkg/board.rs index 618073ceb4..e501c9fe13 100644 --- a/bin/validator/src/commands/dkg/board.rs +++ b/bin/validator/src/commands/dkg/board.rs @@ -991,199 +991,4 @@ fn require_current_board_format(data_directory: &Path) -> anyhow::Result<()> { } #[cfg(test)] -mod tests { - use super::*; - - impl BoardNode { - async fn create_for_test(data_directory: &Path) -> anyhow::Result<(Self, BoardTicket)> { - Self::create_with_network(data_directory, 3, false).await - } - - async fn join_for_test(data_directory: &Path, ticket: BoardTicket) -> anyhow::Result { - Self::join_with_network(data_directory, &ticket.to_string(), 3, false).await - } - - fn local_writer_for_test(&self) -> &BoardWriter { - match &self.publisher { - Publisher::Local(writer) => writer, - Publisher::Remote { .. } => panic!("expected local Golden board writer"), - } - } - - async fn upload_raw_for_test( - &self, - kind: u8, - participant: u32, - declared_length: u64, - value: &[u8], - ) -> anyhow::Result { - match &self.publisher { - Publisher::Remote { endpoint, target, upload_secret } => { - upload_artifact_request( - endpoint, - target, - upload_secret, - kind, - participant, - declared_length, - value, - ) - .await - }, - Publisher::Local(_) => anyhow::bail!("expected remote Golden board publisher"), - } - } - - async fn publish_hash_for_test( - &self, - slot: &ArtifactSlot, - hash: Hash, - size: u64, - ) -> anyhow::Result<()> { - let writer = self.local_writer_for_test(); - self.document - .set_hash(writer.author, slot.key(hash), hash, size) - .await - .context("failed to publish raw test hash") - } - } - - #[tokio::test] - async fn artifact_syncs_between_board_nodes() -> anyhow::Result<()> { - let root = tempfile::tempdir()?; - let (host, ticket) = BoardNode::create_for_test(&root.path().join("host")).await?; - assert!(matches!(ticket.document.capability, iroh_docs::Capability::Read(_))); - let client = BoardNode::join_for_test(&root.path().join("client"), ticket).await?; - let slot = ArtifactSlot::Registration(1); - let value = b"signed registration"; - - client.publish(&slot, value).await?; - assert_eq!(host.wait_unique(&slot, Duration::from_secs(10)).await?, value,); - - client.shutdown().await?; - host.shutdown().await?; - Ok(()) - } - - #[tokio::test] - async fn conflicting_artifacts_are_rejected() -> anyhow::Result<()> { - let root = tempfile::tempdir()?; - let (host, _) = BoardNode::create_for_test(&root.path().join("host")).await?; - let slot = ArtifactSlot::Manifest; - - host.publish(&slot, b"first").await?; - host.publish(&slot, b"second").await?; - let error = host.read_unique(&slot).await.unwrap_err(); - assert!(error.to_string().contains("conflicting artifacts")); - - host.shutdown().await?; - Ok(()) - } - - #[tokio::test] - async fn board_reopens_the_same_document_after_restart() -> anyhow::Result<()> { - let root = tempfile::tempdir()?; - let data_directory = root.path().join("host"); - let (host, first_ticket) = BoardNode::create_for_test(&data_directory).await?; - host.publish(&ArtifactSlot::Manifest, b"manifest").await?; - host.shutdown().await?; - - let (host, second_ticket) = BoardNode::create_for_test(&data_directory).await?; - assert_eq!(first_ticket.document.capability.id(), second_ticket.document.capability.id()); - assert_eq!(first_ticket.upload_secret, second_ticket.upload_secret); - assert_eq!(host.read_unique(&ArtifactSlot::Manifest).await?, Some(b"manifest".to_vec())); - - host.shutdown().await?; - Ok(()) - } - - #[tokio::test] - async fn unmarked_board_is_not_reopened_even_with_an_upload_secret() -> anyhow::Result<()> { - let root = tempfile::tempdir()?; - let data_directory = root.path().join("host"); - let (host, _) = BoardNode::create_for_test(&data_directory).await?; - host.shutdown().await?; - fs_err::remove_file(data_directory.join(BOARD_FORMAT_FILE))?; - - let error = BoardNode::create_for_test(&data_directory) - .await - .err() - .context("legacy board unexpectedly reopened")?; - assert!(error.to_string().contains("predates bounded uploads")); - Ok(()) - } - - #[tokio::test] - async fn unknown_participants_and_artifact_kinds_are_rejected_before_body_allocation() - -> anyhow::Result<()> { - let root = tempfile::tempdir()?; - let (host, ticket) = BoardNode::create_for_test(&root.path().join("host")).await?; - let client = BoardNode::join_for_test(&root.path().join("client"), ticket).await?; - - let error = client.upload_raw_for_test(1, 99, MAX_ARTIFACT_BYTES, &[]).await.unwrap_err(); - assert!(error.to_string().contains("unknown participant or artifact slot")); - let error = client.upload_raw_for_test(255, 1, 16, b"private artifact").await.unwrap_err(); - assert!(error.to_string().contains("unknown artifact kind")); - assert!(host.read_unique(&ArtifactSlot::Registration(1)).await?.is_none()); - - client.shutdown().await?; - host.shutdown().await?; - Ok(()) - } - - #[test] - fn oversized_artifacts_are_rejected_before_allocation() { - let oversized = usize::try_from(MAX_ARTIFACT_BYTES).unwrap() + 1; - assert!(validate_artifact_length(oversized).is_err()); - } - - #[tokio::test] - async fn oversized_upload_is_rejected_before_body_allocation() -> anyhow::Result<()> { - let root = tempfile::tempdir()?; - let (host, ticket) = BoardNode::create_for_test(&root.path().join("host")).await?; - let client = BoardNode::join_for_test(&root.path().join("client"), ticket).await?; - let error = - client.upload_raw_for_test(1, 1, MAX_ARTIFACT_BYTES + 1, &[]).await.unwrap_err(); - assert!(error.to_string().contains("exceeds")); - assert!(host.read_unique(&ArtifactSlot::Registration(1)).await?.is_none()); - - client.shutdown().await?; - host.shutdown().await?; - Ok(()) - } - - #[tokio::test] - async fn invalid_upload_secret_is_rejected_before_storage() -> anyhow::Result<()> { - let root = tempfile::tempdir()?; - let (host, mut ticket) = BoardNode::create_for_test(&root.path().join("host")).await?; - ticket.upload_secret[0] ^= 1; - let client = BoardNode::join_for_test(&root.path().join("client"), ticket).await?; - - let error = client - .publish(&ArtifactSlot::Registration(1), b"signed registration") - .await - .unwrap_err(); - assert!(error.to_string().contains("invalid Golden board upload secret")); - assert!(host.read_unique(&ArtifactSlot::Registration(1)).await?.is_none()); - - client.shutdown().await?; - host.shutdown().await?; - Ok(()) - } - - #[tokio::test] - async fn invalid_download_metadata_is_rejected() -> anyhow::Result<()> { - let root = tempfile::tempdir()?; - let (host, _) = BoardNode::create_for_test(&root.path().join("host")).await?; - let slot = ArtifactSlot::Manifest; - let value = b"manifest"; - let hash = host.publish(&slot, value).await?; - - host.publish_hash_for_test(&slot, hash, 1).await?; - let error = host.read_unique(&slot).await.unwrap_err(); - assert!(error.to_string().contains("length does not match")); - - host.shutdown().await?; - Ok(()) - } -} +mod tests; diff --git a/bin/validator/src/commands/dkg/runner.rs b/bin/validator/src/commands/dkg/runner.rs index 5f9daf766e..182ea4daeb 100644 --- a/bin/validator/src/commands/dkg/runner.rs +++ b/bin/validator/src/commands/dkg/runner.rs @@ -694,33 +694,4 @@ fn final_confirmation_commitment(genesis_commitment: Word, public_output_sha256: } #[cfg(test)] -mod tests { - use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey; - - use super::*; - - #[tokio::test] - async fn final_confirmation_cannot_be_copied_between_validator_slots() -> anyhow::Result<()> { - let first = ValidatorSigner::new_local(SigningKey::new()); - let second = ValidatorSigner::new_local(SigningKey::new()); - let genesis_commitment = Word::default(); - let digest = [7; 32]; - let confirmation = sign_final_confirmation(&first, genesis_commitment, digest).await?; - - validate_final_confirmation( - &confirmation, - &hex::encode(first.public_key().to_bytes()), - genesis_commitment, - digest, - )?; - let error = validate_final_confirmation( - &confirmation, - &hex::encode(second.public_key().to_bytes()), - genesis_commitment, - digest, - ) - .unwrap_err(); - assert!(error.to_string().contains("another validator")); - Ok(()) - } -} +mod tests; diff --git a/bin/validator/src/commands/golden_dkg/board/tests.rs b/bin/validator/src/commands/golden_dkg/board/tests.rs new file mode 100644 index 0000000000..87f784e31c --- /dev/null +++ b/bin/validator/src/commands/golden_dkg/board/tests.rs @@ -0,0 +1,193 @@ +use super::*; + +impl BoardNode { + async fn create_for_test(data_directory: &Path) -> anyhow::Result<(Self, BoardTicket)> { + Self::create_with_network(data_directory, 3, false).await + } + + async fn join_for_test(data_directory: &Path, ticket: BoardTicket) -> anyhow::Result { + Self::join_with_network(data_directory, &ticket.to_string(), 3, false).await + } + + fn local_writer_for_test(&self) -> &BoardWriter { + match &self.publisher { + Publisher::Local(writer) => writer, + Publisher::Remote { .. } => panic!("expected local Golden board writer"), + } + } + + async fn upload_raw_for_test( + &self, + kind: u8, + participant: u32, + declared_length: u64, + value: &[u8], + ) -> anyhow::Result { + match &self.publisher { + Publisher::Remote { endpoint, target, upload_secret } => { + upload_artifact_request( + endpoint, + target, + upload_secret, + kind, + participant, + declared_length, + value, + ) + .await + }, + Publisher::Local(_) => anyhow::bail!("expected remote Golden board publisher"), + } + } + + async fn publish_hash_for_test( + &self, + slot: &ArtifactSlot, + hash: Hash, + size: u64, + ) -> anyhow::Result<()> { + let writer = self.local_writer_for_test(); + self.document + .set_hash(writer.author, slot.key(hash), hash, size) + .await + .context("failed to publish raw test hash") + } +} + +#[tokio::test] +async fn artifact_syncs_between_board_nodes() -> anyhow::Result<()> { + let root = tempfile::tempdir()?; + let (host, ticket) = BoardNode::create_for_test(&root.path().join("host")).await?; + assert!(matches!(ticket.document.capability, iroh_docs::Capability::Read(_))); + let client = BoardNode::join_for_test(&root.path().join("client"), ticket).await?; + let slot = ArtifactSlot::Registration(1); + let value = b"signed registration"; + + client.publish(&slot, value).await?; + assert_eq!(host.wait_unique(&slot, Duration::from_secs(10)).await?, value,); + + client.shutdown().await?; + host.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn conflicting_artifacts_are_rejected() -> anyhow::Result<()> { + let root = tempfile::tempdir()?; + let (host, _) = BoardNode::create_for_test(&root.path().join("host")).await?; + let slot = ArtifactSlot::Manifest; + + host.publish(&slot, b"first").await?; + host.publish(&slot, b"second").await?; + let error = host.read_unique(&slot).await.unwrap_err(); + assert!(error.to_string().contains("conflicting artifacts")); + + host.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn board_reopens_the_same_document_after_restart() -> anyhow::Result<()> { + let root = tempfile::tempdir()?; + let data_directory = root.path().join("host"); + let (host, first_ticket) = BoardNode::create_for_test(&data_directory).await?; + host.publish(&ArtifactSlot::Manifest, b"manifest").await?; + host.shutdown().await?; + + let (host, second_ticket) = BoardNode::create_for_test(&data_directory).await?; + assert_eq!(first_ticket.document.capability.id(), second_ticket.document.capability.id()); + assert_eq!(first_ticket.upload_secret, second_ticket.upload_secret); + assert_eq!(host.read_unique(&ArtifactSlot::Manifest).await?, Some(b"manifest".to_vec())); + + host.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn unmarked_board_is_not_reopened_even_with_an_upload_secret() -> anyhow::Result<()> { + let root = tempfile::tempdir()?; + let data_directory = root.path().join("host"); + let (host, _) = BoardNode::create_for_test(&data_directory).await?; + host.shutdown().await?; + fs_err::remove_file(data_directory.join(BOARD_FORMAT_FILE))?; + + let error = BoardNode::create_for_test(&data_directory) + .await + .err() + .context("legacy board unexpectedly reopened")?; + assert!(error.to_string().contains("predates bounded uploads")); + Ok(()) +} + +#[tokio::test] +async fn unknown_participants_and_artifact_kinds_are_rejected_before_body_allocation() +-> anyhow::Result<()> { + let root = tempfile::tempdir()?; + let (host, ticket) = BoardNode::create_for_test(&root.path().join("host")).await?; + let client = BoardNode::join_for_test(&root.path().join("client"), ticket).await?; + + let error = client.upload_raw_for_test(1, 99, MAX_ARTIFACT_BYTES, &[]).await.unwrap_err(); + assert!(error.to_string().contains("unknown participant or artifact slot")); + let error = client.upload_raw_for_test(255, 1, 16, b"private artifact").await.unwrap_err(); + assert!(error.to_string().contains("unknown artifact kind")); + assert!(host.read_unique(&ArtifactSlot::Registration(1)).await?.is_none()); + + client.shutdown().await?; + host.shutdown().await?; + Ok(()) +} + +#[test] +fn oversized_artifacts_are_rejected_before_allocation() { + let oversized = usize::try_from(MAX_ARTIFACT_BYTES).unwrap() + 1; + assert!(validate_artifact_length(oversized).is_err()); +} + +#[tokio::test] +async fn oversized_upload_is_rejected_before_body_allocation() -> anyhow::Result<()> { + let root = tempfile::tempdir()?; + let (host, ticket) = BoardNode::create_for_test(&root.path().join("host")).await?; + let client = BoardNode::join_for_test(&root.path().join("client"), ticket).await?; + let error = client.upload_raw_for_test(1, 1, MAX_ARTIFACT_BYTES + 1, &[]).await.unwrap_err(); + assert!(error.to_string().contains("exceeds")); + assert!(host.read_unique(&ArtifactSlot::Registration(1)).await?.is_none()); + + client.shutdown().await?; + host.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn invalid_upload_secret_is_rejected_before_storage() -> anyhow::Result<()> { + let root = tempfile::tempdir()?; + let (host, mut ticket) = BoardNode::create_for_test(&root.path().join("host")).await?; + ticket.upload_secret[0] ^= 1; + let client = BoardNode::join_for_test(&root.path().join("client"), ticket).await?; + + let error = client + .publish(&ArtifactSlot::Registration(1), b"signed registration") + .await + .unwrap_err(); + assert!(error.to_string().contains("invalid Golden board upload secret")); + assert!(host.read_unique(&ArtifactSlot::Registration(1)).await?.is_none()); + + client.shutdown().await?; + host.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn invalid_download_metadata_is_rejected() -> anyhow::Result<()> { + let root = tempfile::tempdir()?; + let (host, _) = BoardNode::create_for_test(&root.path().join("host")).await?; + let slot = ArtifactSlot::Manifest; + let value = b"manifest"; + let hash = host.publish(&slot, value).await?; + + host.publish_hash_for_test(&slot, hash, 1).await?; + let error = host.read_unique(&slot).await.unwrap_err(); + assert!(error.to_string().contains("length does not match")); + + host.shutdown().await?; + Ok(()) +} diff --git a/bin/validator/src/commands/golden_dkg/runner/tests.rs b/bin/validator/src/commands/golden_dkg/runner/tests.rs new file mode 100644 index 0000000000..d83ca692c5 --- /dev/null +++ b/bin/validator/src/commands/golden_dkg/runner/tests.rs @@ -0,0 +1,28 @@ +use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey; + +use super::*; + +#[tokio::test] +async fn final_confirmation_cannot_be_copied_between_validator_slots() -> anyhow::Result<()> { + let first = ValidatorSigner::new_local(SigningKey::new()); + let second = ValidatorSigner::new_local(SigningKey::new()); + let genesis_commitment = Word::default(); + let digest = [7; 32]; + let confirmation = sign_final_confirmation(&first, genesis_commitment, digest).await?; + + validate_final_confirmation( + &confirmation, + &hex::encode(first.public_key().to_bytes()), + genesis_commitment, + digest, + )?; + let error = validate_final_confirmation( + &confirmation, + &hex::encode(second.public_key().to_bytes()), + genesis_commitment, + digest, + ) + .unwrap_err(); + assert!(error.to_string().contains("another validator")); + Ok(()) +} From e875cfa2c69edf8f8c2375d34d93201b1c91f9f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Tue, 4 Aug 2026 13:47:58 -0400 Subject: [PATCH 05/28] docs: move Iroh DKG changelog to PR metadata --- CHANGELOG.archived.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.archived.md b/CHANGELOG.archived.md index 5000aadde4..b41d72839a 100644 --- a/CHANGELOG.archived.md +++ b/CHANGELOG.archived.md @@ -4,7 +4,6 @@ This changelog has been archived. Changes are now documented in pull request des ## Unreleased -- Added a genesis-bound Golden DKG ceremony and Iroh bulletin board for validator storage keys ([#2426](https://github.com/0xMiden/node/issues/2426)). - [BREAKING] Updated `miden-protocol` dependencies to use the `next` branch (v0.16). Block and transaction account updates now use the absolute `AccountPatch` representation instead of the relative `AccountDelta`, and the `miden-tx-batch-prover` crate was renamed to `miden-tx-batch` ([#2282](https://github.com/0xMiden/node/pull/2282)). ## v0.15.0 (2026-06-10) From 004ed49eb5d92cd53cee057c5520813585fe4d36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Tue, 4 Aug 2026 13:59:16 -0400 Subject: [PATCH 06/28] fix(validator): box shutdown future --- bin/validator/src/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/validator/src/main.rs b/bin/validator/src/main.rs index f4fbe9496a..b5f98ecdc1 100644 --- a/bin/validator/src/main.rs +++ b/bin/validator/src/main.rs @@ -10,8 +10,8 @@ async fn main() -> anyhow::Result<()> { let _otel_guard = miden_node_tracing::setup_tracing(command.open_telemetry())?; - miden_node_utils::shutdown::run_with_shutdown("miden-validator", |shutdown| { + Box::pin(miden_node_utils::shutdown::run_with_shutdown("miden-validator", |shutdown| { command.handle(shutdown) - }) + })) .await } From 71b3af74b94b332b8128fcbce976fb2a4846f874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Wed, 5 Aug 2026 09:37:04 -0400 Subject: [PATCH 07/28] refactor(validator): use storage DKG names for Iroh --- bin/validator/src/commands/dkg.rs | 8 +- bin/validator/src/commands/dkg/board.rs | 173 ++++++++---------- .../{golden_dkg => dkg}/board/tests.rs | 6 +- bin/validator/src/commands/dkg/runner.rs | 50 ++--- .../{golden_dkg => dkg}/runner/tests.rs | 0 compose/validator.yml | 12 +- .../external/src/local-network-development.md | 9 +- .../src/network-operator/validator.md | 10 +- 8 files changed, 130 insertions(+), 138 deletions(-) rename bin/validator/src/commands/{golden_dkg => dkg}/board/tests.rs (97%) rename bin/validator/src/commands/{golden_dkg => dkg}/runner/tests.rs (100%) diff --git a/bin/validator/src/commands/dkg.rs b/bin/validator/src/commands/dkg.rs index 674908d312..7194c00115 100644 --- a/bin/validator/src/commands/dkg.rs +++ b/bin/validator/src/commands/dkg.rs @@ -47,10 +47,10 @@ use zeroize::Zeroizing; use super::ValidatorSigningKey; -#[cfg(test)] -mod tests; mod board; mod runner; +#[cfg(test)] +mod tests; type StorageGroup = Secp256k1GoldenGroup; type StorageScalar = ::Scalar; @@ -95,10 +95,10 @@ pub struct DkgOptions { #[derive(clap::Subcommand)] enum DkgCommand { /// Runs the shared Iroh bulletin board for a ceremony. - Board(runner::GoldenDkgBoardServeOptions), + Board(runner::DkgBoardServeOptions), /// Runs every ceremony stage for one validator through an Iroh board. - Run(runner::GoldenDkgRunOptions), + Run(runner::DkgRunOptions), /// Generates this validator's DKG identity and public registration. Identity { diff --git a/bin/validator/src/commands/dkg/board.rs b/bin/validator/src/commands/dkg/board.rs index e501c9fe13..7a025ef9f6 100644 --- a/bin/validator/src/commands/dkg/board.rs +++ b/bin/validator/src/commands/dkg/board.rs @@ -28,8 +28,8 @@ const DOCUMENT_ID_FILE: &str = "document-id.hex"; const BOARD_FORMAT_FILE: &str = "board-format"; const BOARD_FORMAT: &[u8] = b"bounded-upload-v1\n"; const UPLOAD_SECRET_FILE: &str = "upload-secret.hex"; -const BOARD_TICKET_PREFIX: &str = "miden-golden-board-v1"; -const UPLOAD_ALPN: &[u8] = b"/miden/golden-dkg-board-upload/1"; +const BOARD_TICKET_PREFIX: &str = "miden-storage-key-dkg-board-v1"; +const UPLOAD_ALPN: &[u8] = b"/miden/storage-key-dkg-board-upload/1"; const UPLOAD_HEADER_BYTES: usize = 32 + 1 + 4 + 8; const UPLOAD_RESPONSE_BYTES: usize = 1 + 32; const MAX_ARTIFACT_BYTES: u64 = 64 * 1024 * 1024; @@ -64,21 +64,20 @@ impl FromStr for BoardTicket { fn from_str(value: &str) -> Result { let mut parts = value.splitn(3, ':'); - ensure!(parts.next() == Some(BOARD_TICKET_PREFIX), "invalid Golden board ticket prefix"); - let secret = parts.next().context("Golden board ticket is missing its upload secret")?; - let document = - parts.next().context("Golden board ticket is missing its document ticket")?; - let upload_secret = decode_fixed_hex::<32>(secret, "Golden board upload secret")?; + ensure!(parts.next() == Some(BOARD_TICKET_PREFIX), "invalid DKG board ticket prefix"); + let secret = parts.next().context("DKG board ticket is missing its upload secret")?; + let document = parts.next().context("DKG board ticket is missing its document ticket")?; + let upload_secret = decode_fixed_hex::<32>(secret, "DKG board upload secret")?; let document = DocTicket::from_str(document).context("invalid Iroh document ticket")?; ensure!( matches!(document.capability, iroh_docs::Capability::Read(_)), - "Golden board document ticket must be read-only" + "DKG board document ticket must be read-only" ); Ok(Self { document, upload_secret }) } } -/// One immutable location in a Golden ceremony document. +/// One immutable location in a DKG ceremony document. #[derive(Clone, Debug, Eq, PartialEq)] pub(super) enum ArtifactSlot { Registration(u32), @@ -126,14 +125,14 @@ impl ArtifactSlot { Self::TranscriptAcceptance(participant) => (5, *participant), Self::FinalConfirmation(participant) => (6, *participant), Self::Manifest | Self::DecryptionConfig | Self::ContextConfig => { - anyhow::bail!("only the Golden board may publish common ceremony artifacts") + anyhow::bail!("only the DKG board may publish common ceremony artifacts") }, }; Ok(fields) } fn from_upload_fields(kind: u8, participant: u32) -> anyhow::Result { - ensure!(participant > 0, "Golden board participant index must be nonzero"); + ensure!(participant > 0, "DKG board participant index must be nonzero"); match kind { 1 => Ok(Self::Registration(participant)), 2 => Ok(Self::DecryptionDealing(participant)), @@ -141,7 +140,7 @@ impl ArtifactSlot { 4 => Ok(Self::Transcript(participant)), 5 => Ok(Self::TranscriptAcceptance(participant)), 6 => Ok(Self::FinalConfirmation(participant)), - _ => anyhow::bail!("Golden board upload contains an unknown artifact kind"), + _ => anyhow::bail!("DKG board upload contains an unknown artifact kind"), } } } @@ -254,7 +253,7 @@ impl BoardNode { document .set_download_policy(DownloadPolicy::NothingExcept(Vec::new())) .await - .context("failed to restrict Golden board downloads")?; + .context("failed to restrict DKG board downloads")?; let mut document_ticket = document .share( ShareMode::Read, @@ -287,7 +286,7 @@ impl BoardNode { .document .start_sync(Vec::new()) .await - .context("failed to start Golden board synchronization")?; + .context("failed to start DKG board synchronization")?; Ok((board, ticket)) } @@ -310,7 +309,7 @@ impl BoardNode { let runtime = BoardRuntime::start(data_directory, use_network_services).await?; let BoardTicket { document, upload_secret } = ticket; let DocTicket { capability, nodes } = document; - let target = nodes.first().cloned().context("Golden board ticket has no endpoint")?; + let target = nodes.first().cloned().context("DKG board ticket has no endpoint")?; let document = runtime .docs .import_namespace(capability) @@ -319,7 +318,7 @@ impl BoardNode { document .set_download_policy(DownloadPolicy::NothingExcept(Vec::new())) .await - .context("failed to restrict Golden board downloads")?; + .context("failed to restrict DKG board downloads")?; let mut board = runtime .attach(document, participant_count, nodes.clone(), None, Some((target, upload_secret))) .await?; @@ -327,7 +326,7 @@ impl BoardNode { .document .start_sync(nodes) .await - .context("failed to start Golden board synchronization")?; + .context("failed to start DKG board synchronization")?; board.wait_for_peer().await?; Ok(board) } @@ -348,7 +347,7 @@ impl BoardNode { self.document .start_sync(self.sync_targets.clone()) .await - .context("failed to synchronize Golden board artifact")?; + .context("failed to synchronize DKG board artifact")?; if !self.sync_targets.is_empty() || *self.peer_ready.borrow() { let mut completed = self.sync_generation.clone(); tokio::time::timeout( @@ -356,8 +355,8 @@ impl BoardNode { completed.wait_for(|generation| *generation > sync_generation), ) .await - .context("timed out synchronizing Golden board artifact")? - .context("Golden board synchronization monitor stopped")?; + .context("timed out synchronizing DKG board artifact")? + .context("DKG board synchronization monitor stopped")?; } Ok(stored_hash) } @@ -370,19 +369,19 @@ impl BoardNode { .document .get_many(Query::key_prefix(prefix.as_bytes())) .await - .context("failed to query Golden board artifacts")?; + .context("failed to query DKG board artifacts")?; futures::pin_mut!(entries); let mut values = BTreeMap::new(); while let Some(entry) = entries.next().await { - let entry = entry.context("failed to read Golden board entry")?; + let entry = entry.context("failed to read DKG board entry")?; ensure!( entry.content_len() > 0 && entry.content_len() <= MAX_ARTIFACT_BYTES, - "Golden board artifact exceeds {MAX_ARTIFACT_BYTES} bytes", + "DKG board artifact exceeds {MAX_ARTIFACT_BYTES} bytes", ); let expected_key = slot.key(entry.content_hash()); ensure!( entry.key() == expected_key.as_bytes(), - "Golden board key does not match its content hash" + "DKG board key does not match its content hash" ); let hash = entry.content_hash(); if self.blobs.blobs().get_bytes(hash).await.is_err() { @@ -392,10 +391,10 @@ impl BoardNode { .document .get_sync_peers() .await - .context("failed to list Golden board peers")? + .context("failed to list DKG board peers")? .unwrap_or_default() .into_iter() - .map(|id| EndpointId::from_bytes(&id).context("invalid Golden board peer ID")) + .map(|id| EndpointId::from_bytes(&id).context("invalid DKG board peer ID")) .collect::>>()?; for peer in sync_peers { if !providers.contains(&peer) { @@ -413,7 +412,7 @@ impl BoardNode { match item { DownloadProgressItem::Progress(downloaded) => ensure!( downloaded <= MAX_ARTIFACT_BYTES, - "Golden board artifact exceeds {MAX_ARTIFACT_BYTES} bytes", + "DKG board artifact exceeds {MAX_ARTIFACT_BYTES} bytes", ), DownloadProgressItem::Error(_) | DownloadProgressItem::DownloadError => { return Ok(None); @@ -429,15 +428,15 @@ impl BoardNode { .blobs() .get_bytes(hash) .await - .context("downloaded Golden board artifact is missing")?; + .context("downloaded DKG board artifact is missing")?; ensure!( u64::try_from(bytes.len()).context("artifact length does not fit u64")? == entry.content_len(), - "Golden board artifact length does not match its entry" + "DKG board artifact length does not match its entry" ); values.entry(hash).or_insert_with(|| bytes.to_vec()); } - ensure!(values.len() <= 1, "Golden board contains conflicting artifacts for {prefix}"); + ensure!(values.len() <= 1, "DKG board contains conflicting artifacts for {prefix}"); Ok(values.into_values().next()) } @@ -449,7 +448,7 @@ impl BoardNode { fn ensure_admitted(&self) -> anyhow::Result<()> { if let Some(error) = self.event_error.borrow().as_ref() { - anyhow::bail!("Golden board synchronization stopped: {error}"); + anyhow::bail!("DKG board synchronization stopped: {error}"); } Ok(()) } @@ -460,8 +459,8 @@ impl BoardNode { } tokio::time::timeout(PEER_READY_TIMEOUT, self.peer_ready.wait_for(|ready| *ready)) .await - .context("timed out waiting for the Golden board peer")? - .context("Golden board peer monitor stopped")?; + .context("timed out waiting for the DKG board peer")? + .context("DKG board peer monitor stopped")?; Ok(()) } @@ -475,7 +474,7 @@ impl BoardNode { .document .subscribe() .await - .context("failed to subscribe to Golden board updates")?; + .context("failed to subscribe to DKG board updates")?; tokio::time::timeout(timeout, async { loop { if let Some(value) = self.read_unique(slot).await? { @@ -483,14 +482,14 @@ impl BoardNode { } tokio::select! { event = events.next() => { - event.transpose()?.context("Golden board update stream ended")?; + event.transpose()?.context("DKG board update stream ended")?; }, () = tokio::time::sleep(Duration::from_millis(250)) => {}, } } }) .await - .with_context(|| format!("timed out waiting for Golden board slot {}", slot.prefix()))? + .with_context(|| format!("timed out waiting for DKG board slot {}", slot.prefix()))? } /// Stops the board node and flushes its persistent stores. @@ -502,10 +501,10 @@ impl BoardNode { } fn validate_artifact_length(length: usize) -> anyhow::Result<()> { - ensure!(length > 0, "Golden board artifact must not be empty"); + ensure!(length > 0, "DKG board artifact must not be empty"); ensure!( u64::try_from(length).context("artifact length does not fit u64")? <= MAX_ARTIFACT_BYTES, - "Golden board artifact exceeds {MAX_ARTIFACT_BYTES} bytes", + "DKG board artifact exceeds {MAX_ARTIFACT_BYTES} bytes", ); Ok(()) } @@ -514,7 +513,7 @@ impl BoardWriter { fn validate_slot(&self, slot: &ArtifactSlot) -> anyhow::Result<()> { ensure!( self.allowed_prefixes.contains(&slot.prefix()), - "Golden board upload targets an unknown participant or artifact slot" + "DKG board upload targets an unknown participant or artifact slot" ); Ok(()) } @@ -529,18 +528,18 @@ impl BoardWriter { .document .get_many(Query::key_prefix(prefix.as_bytes())) .await - .context("failed to inspect Golden board artifact slot")?; + .context("failed to inspect DKG board artifact slot")?; futures::pin_mut!(entries); let mut hashes = Vec::new(); while let Some(entry) = entries.next().await { - let entry = entry.context("failed to read Golden board artifact slot")?; + let entry = entry.context("failed to read DKG board artifact slot")?; if entry.content_hash() == expected_hash { return Ok(expected_hash); } hashes.push(entry.content_hash()); ensure!( hashes.len() < MAX_VALUES_PER_SLOT, - "Golden board artifact slot already contains conflicting values" + "DKG board artifact slot already contains conflicting values" ); } @@ -548,7 +547,7 @@ impl BoardWriter { .document .set_bytes(self.author, slot.key(expected_hash), value.to_vec()) .await - .context("failed to publish Golden board artifact")?; + .context("failed to publish DKG board artifact")?; ensure!(stored_hash == expected_hash, "Iroh stored artifact under an unexpected hash"); Ok(stored_hash) } @@ -559,27 +558,26 @@ impl UploadProtocol { let mut header = [0u8; UPLOAD_HEADER_BYTES]; recv.read_exact(&mut header) .await - .context("failed to read Golden board upload header")?; + .context("failed to read DKG board upload header")?; ensure!( secrets_match(&header[..32], &self.upload_secret), - "invalid Golden board upload secret" + "invalid DKG board upload secret" ); let kind = header[32]; let participant = u32::from_be_bytes(header[33..37].try_into().expect("fixed slice")); let length = u64::from_be_bytes(header[37..45].try_into().expect("fixed slice")); ensure!( length > 0 && length <= MAX_ARTIFACT_BYTES, - "Golden board artifact exceeds {MAX_ARTIFACT_BYTES} bytes" + "DKG board artifact exceeds {MAX_ARTIFACT_BYTES} bytes" ); let slot = ArtifactSlot::from_upload_fields(kind, participant)?; self.writer.validate_slot(&slot)?; - let length = - usize::try_from(length).context("Golden board artifact length is too large")?; + let length = usize::try_from(length).context("DKG board artifact length is too large")?; let mut value = vec![0u8; length]; recv.read_exact(&mut value) .await - .context("failed to read Golden board upload body")?; - recv.read_to_end(0).await.context("Golden board upload has trailing bytes")?; + .context("failed to read DKG board upload body")?; + recv.read_to_end(0).await.context("DKG board upload has trailing bytes")?; self.writer.store(&slot, &value).await } } @@ -591,7 +589,7 @@ impl ProtocolHandler for UploadProtocol { { result } else { - connection.close(1u32.into(), b"Golden board upload timed out"); + connection.close(1u32.into(), b"DKG board upload timed out"); Ok(()) } } @@ -650,11 +648,9 @@ async fn upload_artifact_request( let connection = endpoint .connect(target.clone(), UPLOAD_ALPN) .await - .context("failed to connect to the Golden board upload service")?; - let (mut send, mut recv) = connection - .open_bi() - .await - .context("failed to open a Golden board upload stream")?; + .context("failed to connect to the DKG board upload service")?; + let (mut send, mut recv) = + connection.open_bi().await.context("failed to open a DKG board upload stream")?; let mut header = [0u8; UPLOAD_HEADER_BYTES]; header[..32].copy_from_slice(upload_secret); header[32] = kind; @@ -662,28 +658,26 @@ async fn upload_artifact_request( header[37..45].copy_from_slice(&declared_length.to_be_bytes()); send.write_all(&header) .await - .context("failed to write Golden board upload header")?; - send.write_all(value) - .await - .context("failed to write Golden board upload body")?; - send.finish().context("failed to finish Golden board upload")?; + .context("failed to write DKG board upload header")?; + send.write_all(value).await.context("failed to write DKG board upload body")?; + send.finish().context("failed to finish DKG board upload")?; let response = tokio::time::timeout( UPLOAD_TIMEOUT, recv.read_to_end(UPLOAD_RESPONSE_BYTES + MAX_UPLOAD_ERROR_BYTES), ) .await - .context("timed out waiting for the Golden board upload response")? - .context("failed to read Golden board upload response")?; + .context("timed out waiting for the DKG board upload response")? + .context("failed to read DKG board upload response")?; connection.close(0u32.into(), b"upload complete"); - ensure!(!response.is_empty(), "Golden board returned an empty upload response"); + ensure!(!response.is_empty(), "DKG board returned an empty upload response"); if response[0] != 0 { let message = std::str::from_utf8(&response[1..]) - .context("Golden board returned a non-UTF-8 upload error")?; - anyhow::bail!("Golden board rejected the artifact: {message}"); + .context("DKG board returned a non-UTF-8 upload error")?; + anyhow::bail!("DKG board rejected the artifact: {message}"); } ensure!( response.len() == UPLOAD_RESPONSE_BYTES, - "Golden board returned an invalid upload response" + "DKG board returned an invalid upload response" ); Ok(Hash::from_bytes(response[1..].try_into().expect("validated response length"))) } @@ -759,18 +753,18 @@ impl BoardRuntime { served_upload_secret: Option<[u8; 32]>, remote_upload: Option<(EndpointAddr, [u8; 32])>, ) -> anyhow::Result { - ensure!(participant_count > 0, "Golden board requires at least one participant"); + ensure!(participant_count > 0, "DKG board requires at least one participant"); ensure!( served_upload_secret.is_some() ^ remote_upload.is_some(), - "Golden board must either serve or submit uploads" + "DKG board must either serve or submit uploads" ); let artifact_slot_count = participant_count .checked_mul(ARTIFACTS_PER_PARTICIPANT) .and_then(|count| count.checked_add(COMMON_ARTIFACT_COUNT)) - .context("Golden board participant count is too large")?; + .context("DKG board participant count is too large")?; let max_document_entries = artifact_slot_count .checked_mul(MAX_VALUES_PER_SLOT) - .context("Golden board participant count is too large")?; + .context("DKG board participant count is too large")?; let allowed_prefixes = Arc::new(allowed_slot_prefixes(participant_count)?); inspect_document_metadata(&document, &allowed_prefixes, max_document_entries).await?; let writer = BoardWriter { @@ -823,10 +817,8 @@ impl BoardRuntime { impl BoardEvents { async fn start(document: &Doc) -> anyhow::Result { - let mut events = document - .subscribe() - .await - .context("failed to start Golden board event monitor")?; + let mut events = + document.subscribe().await.context("failed to start DKG board event monitor")?; let (event_tx, error) = tokio::sync::watch::channel(None); let (peer_ready_tx, peer_ready) = tokio::sync::watch::channel(false); let (sync_generation_tx, sync_generation) = tokio::sync::watch::channel(0u64); @@ -891,7 +883,7 @@ fn allowed_slot_prefixes(participant_count: usize) -> anyhow::Result ArtifactSlot::ContextConfig.prefix(), ]; for position in 0..participant_count { - let participant = u32::try_from(position + 1).context("too many Golden participants")?; + let participant = u32::try_from(position + 1).context("too many DKG participants")?; prefixes.extend([ ArtifactSlot::Registration(participant).prefix(), ArtifactSlot::DecryptionDealing(participant).prefix(), @@ -909,32 +901,29 @@ async fn inspect_document_metadata( allowed_prefixes: &[String], max_document_entries: usize, ) -> anyhow::Result<()> { - let entries = document - .get_many(Query::all()) - .await - .context("failed to inspect Golden board")?; + let entries = document.get_many(Query::all()).await.context("failed to inspect DKG board")?; futures::pin_mut!(entries); let mut slots = BTreeMap::new(); let mut count = 0usize; while let Some(entry) = entries.next().await { - let entry = entry.context("failed to read Golden board entry")?; + let entry = entry.context("failed to read DKG board entry")?; count += 1; - ensure!(count <= max_document_entries, "Golden board contains too many entries"); + ensure!(count <= max_document_entries, "DKG board contains too many entries"); ensure!( entry.content_len() > 0 && entry.content_len() <= MAX_ARTIFACT_BYTES, - "Golden board artifact exceeds {MAX_ARTIFACT_BYTES} bytes", + "DKG board artifact exceeds {MAX_ARTIFACT_BYTES} bytes", ); - let key = std::str::from_utf8(entry.key()).context("Golden board key is not UTF-8")?; + let key = std::str::from_utf8(entry.key()).context("DKG board key is not UTF-8")?; let (prefix, hash) = allowed_prefixes .iter() .find_map(|prefix| key.strip_prefix(prefix).map(|hash| (prefix, hash))) - .context("Golden board contains an unrecognized artifact slot")?; + .context("DKG board contains an unrecognized artifact slot")?; ensure!( hash.len() == 64 && hash.bytes().all(|byte| byte.is_ascii_hexdigit()), - "Golden board key has an invalid content hash" + "DKG board key has an invalid content hash" ); if let Some(previous) = slots.insert(prefix.clone(), hash.to_owned()) { - ensure!(previous == hash, "Golden board contains conflicting artifacts for {prefix}"); + ensure!(previous == hash, "DKG board contains conflicting artifacts for {prefix}"); } } Ok(()) @@ -961,13 +950,13 @@ fn load_or_create_upload_secret( let path = data_directory.join(UPLOAD_SECRET_FILE); if path.exists() { let bytes = fs_err::read_to_string(&path).with_context(|| { - format!("failed to read Golden board upload secret {}", path.display()) + format!("failed to read DKG board upload secret {}", path.display()) })?; - return decode_fixed_hex::<32>(bytes.trim(), "Golden board upload secret"); + return decode_fixed_hex::<32>(bytes.trim(), "DKG board upload secret"); } ensure!( allow_create, - "this Golden board predates bounded uploads; start a new ceremony in a new data directory" + "this DKG board predates bounded uploads; start a new ceremony in a new data directory" ); let secret = SecretKey::generate().to_bytes(); @@ -979,13 +968,13 @@ fn require_current_board_format(data_directory: &Path) -> anyhow::Result<()> { let path = data_directory.join(BOARD_FORMAT_FILE); let format = fs_err::read(&path).with_context(|| { format!( - "this Golden board predates bounded uploads; start a new ceremony in a new data directory ({})", + "this DKG board predates bounded uploads; start a new ceremony in a new data directory ({})", path.display() ) })?; ensure!( format == BOARD_FORMAT, - "unsupported Golden board format; start a new ceremony in a new data directory" + "unsupported DKG board format; start a new ceremony in a new data directory" ); Ok(()) } diff --git a/bin/validator/src/commands/golden_dkg/board/tests.rs b/bin/validator/src/commands/dkg/board/tests.rs similarity index 97% rename from bin/validator/src/commands/golden_dkg/board/tests.rs rename to bin/validator/src/commands/dkg/board/tests.rs index 87f784e31c..5eca069f82 100644 --- a/bin/validator/src/commands/golden_dkg/board/tests.rs +++ b/bin/validator/src/commands/dkg/board/tests.rs @@ -12,7 +12,7 @@ impl BoardNode { fn local_writer_for_test(&self) -> &BoardWriter { match &self.publisher { Publisher::Local(writer) => writer, - Publisher::Remote { .. } => panic!("expected local Golden board writer"), + Publisher::Remote { .. } => panic!("expected local DKG board writer"), } } @@ -36,7 +36,7 @@ impl BoardNode { ) .await }, - Publisher::Local(_) => anyhow::bail!("expected remote Golden board publisher"), + Publisher::Local(_) => anyhow::bail!("expected remote DKG board publisher"), } } @@ -168,7 +168,7 @@ async fn invalid_upload_secret_is_rejected_before_storage() -> anyhow::Result<() .publish(&ArtifactSlot::Registration(1), b"signed registration") .await .unwrap_err(); - assert!(error.to_string().contains("invalid Golden board upload secret")); + assert!(error.to_string().contains("invalid DKG board upload secret")); assert!(host.read_unique(&ArtifactSlot::Registration(1)).await?.is_none()); client.shutdown().await?; diff --git a/bin/validator/src/commands/dkg/runner.rs b/bin/validator/src/commands/dkg/runner.rs index 182ea4daeb..c3be556879 100644 --- a/bin/validator/src/commands/dkg/runner.rs +++ b/bin/validator/src/commands/dkg/runner.rs @@ -62,10 +62,10 @@ const ACCEPTANCE_DIRECTORY: &str = "acceptance"; const PUBLIC_ACCEPTANCES_DIRECTORY: &str = "public-acceptances"; const BOARD_DIRECTORY: &str = "board"; const CEREMONY_WAIT_TIMEOUT: Duration = Duration::from_hours(24); -const PUBLIC_OUTPUT_DIGEST_DOMAIN: &[u8] = b"miden-golden-dkg-public-output-v1"; -const FINAL_CONFIRMATION_VERSION: &str = "miden-golden-dkg-final-confirmation-v1"; +const PUBLIC_OUTPUT_DIGEST_DOMAIN: &[u8] = b"miden-storage-key-dkg-public-output-v1"; +const FINAL_CONFIRMATION_VERSION: &str = "miden-storage-key-dkg-final-confirmation-v1"; const FINAL_CONFIRMATION_SIGNATURE_DOMAIN: &[u8] = - b"miden-golden-dkg-final-confirmation-signature-v1"; + b"miden-storage-key-dkg-final-confirmation-signature-v1"; #[derive(Deserialize, Serialize)] struct FinalConfirmation { @@ -75,9 +75,9 @@ struct FinalConfirmation { validator_signature: String, } -/// Inputs for the shared Golden DKG board. +/// Inputs for the shared storage key DKG board. #[derive(clap::Args)] -pub(super) struct GoldenDkgBoardServeOptions { +pub(super) struct DkgBoardServeOptions { /// Durable directory for the Iroh endpoint, document, and common ceremony files. #[arg(long, value_name = "DIR")] data_directory: PathBuf, @@ -99,10 +99,10 @@ pub(super) struct GoldenDkgBoardServeOptions { ticket_output: Option, } -/// Inputs for one validator's automatic Golden DKG ceremony runner. +/// Inputs for one validator's automatic storage key DKG ceremony runner. #[derive(clap::Args)] -pub(super) struct GoldenDkgRunOptions { - /// Read and upload ticket printed by `golden-dkg board`. +pub(super) struct DkgRunOptions { + /// Read and upload ticket printed by `dkg board`. #[arg(long, value_name = "BOARD_TICKET", required_unless_present = "board_file")] board: Option, @@ -128,17 +128,19 @@ pub(super) struct GoldenDkgRunOptions { } /// Runs one validator through every DKG phase. -pub(super) async fn run_validator(options: GoldenDkgRunOptions) -> anyhow::Result<()> { +pub(super) async fn run_validator(options: DkgRunOptions) -> anyhow::Result<()> { let board = if let Some(ticket) = options.board { ticket } else { let path = options.board_file.context("a board ticket or board ticket file is required")?; fs_err::read_to_string(&path) - .with_context(|| format!("failed to read Golden DKG board ticket {}", path.display()))? + .with_context(|| { + format!("failed to read storage key DKG board ticket {}", path.display()) + })? .trim() .to_owned() }; - ensure!(!board.is_empty(), "Golden DKG board ticket must not be empty"); + ensure!(!board.is_empty(), "storage key DKG board ticket must not be empty"); let signer = options.signing_key.into_signer().await?; run_validator_with_network::( &board, @@ -152,15 +154,15 @@ pub(super) async fn run_validator(options: GoldenDkgRunOptions) -> anyhow::Resul .await } -pub(super) async fn serve_board(options: GoldenDkgBoardServeOptions) -> anyhow::Result<()> { +pub(super) async fn serve_board(options: DkgBoardServeOptions) -> anyhow::Result<()> { let genesis = read_trusted_genesis(&options.genesis)?; let participant_count = genesis.inner().header().validator_keys().as_keys().len(); let (board, ticket) = BoardNode::create(&options.data_directory, participant_count).await?; if let Some(path) = &options.ticket_output { write_new_file(path, ticket.to_string().as_bytes(), true)?; - println!("Golden DKG board ticket written to {}", path.display()); + println!("storage key DKG board ticket written to {}", path.display()); } else { - println!("Golden DKG board ticket:\n{ticket}"); + println!("storage key DKG board ticket:\n{ticket}"); } let result = async { @@ -173,7 +175,7 @@ pub(super) async fn serve_board(options: GoldenDkgBoardServeOptions) -> anyhow:: CEREMONY_WAIT_TIMEOUT, ) .await?; - println!("Golden DKG board is ready. Press Ctrl-C to stop it."); + println!("storage key DKG board is ready. Press Ctrl-C to stop it."); tokio::signal::ctrl_c().await.context("failed to wait for Ctrl-C") } .await; @@ -461,7 +463,7 @@ where digest, )?; } - println!("Golden DKG completed for participant {}.", participant.get()); + println!("storage key DKG completed for participant {}.", participant.get()); Ok(()) } @@ -497,7 +499,7 @@ fn participant_for_validator( } fn participant_at(position: usize) -> anyhow::Result { - ParticipantIndex::new(u32::try_from(position + 1).context("too many Golden participants")?) + ParticipantIndex::new(u32::try_from(position + 1).context("too many DKG participants")?) .map_err(Into::into) } @@ -637,7 +639,7 @@ async fn sign_final_confirmation( let signature = signer .sign_commitment(final_confirmation_commitment(genesis_commitment, public_output_sha256)) .await - .context("failed to sign Golden final confirmation")?; + .context("failed to sign DKG final confirmation")?; let confirmation = FinalConfirmation { version: FINAL_CONFIRMATION_VERSION.to_owned(), validator_public_key: hex::encode(validator_public_key.to_bytes()), @@ -645,7 +647,7 @@ async fn sign_final_confirmation( validator_signature: hex::encode(signature.to_bytes()), }; toml::to_string_pretty(&confirmation) - .context("failed to encode Golden final confirmation") + .context("failed to encode DKG final confirmation") .map(String::into_bytes) } @@ -656,18 +658,18 @@ fn validate_final_confirmation( expected_public_output_sha256: [u8; 32], ) -> anyhow::Result<()> { let confirmation: FinalConfirmation = - toml::from_slice(bytes).context("invalid Golden final confirmation")?; + toml::from_slice(bytes).context("invalid DKG final confirmation")?; ensure!( confirmation.version == FINAL_CONFIRMATION_VERSION, - "unsupported Golden final confirmation version" + "unsupported DKG final confirmation version" ); ensure!( confirmation.validator_public_key == expected_validator_public_key, - "Golden final confirmation belongs to another validator" + "DKG final confirmation belongs to another validator" ); ensure!( confirmation.public_output_sha256 == hex::encode(expected_public_output_sha256), - "validators produced different Golden public outputs" + "validators produced different storage key outputs" ); let validator_public_key = decode_validator_public_key(&confirmation.validator_public_key)?; let signature = decode_validator_signature(&confirmation.validator_signature)?; @@ -676,7 +678,7 @@ fn validate_final_confirmation( final_confirmation_commitment(genesis_commitment, expected_public_output_sha256), &validator_public_key, ), - "invalid Golden final confirmation signature" + "invalid DKG final confirmation signature" ); Ok(()) } diff --git a/bin/validator/src/commands/golden_dkg/runner/tests.rs b/bin/validator/src/commands/dkg/runner/tests.rs similarity index 100% rename from bin/validator/src/commands/golden_dkg/runner/tests.rs rename to bin/validator/src/commands/dkg/runner/tests.rs diff --git a/compose/validator.yml b/compose/validator.yml index 86b91226f6..009347c143 100644 --- a/compose/validator.yml +++ b/compose/validator.yml @@ -15,8 +15,8 @@ x-validator: &validator - --admin.listen=0.0.0.0:50102 services: - golden-dkg-check: - profiles: ["golden-dkg"] + storage-key-dkg-check: + profiles: ["storage-key-dkg"] image: ${MIDEN_VALIDATOR_IMAGE:-miden-validator} pull_policy: missing volumes: @@ -30,14 +30,14 @@ services: command: - | set -eu - ROOT=/data/golden-dkg-check + ROOT=/data/storage-key-dkg-check TICKET="$${ROOT}/board-ticket" GENESIS=/data/genesis/genesis.dat EPOCH="$${MIDEN_VALIDATOR_STORAGE_KEY_EPOCH}" rm -rf "$${ROOT}" mkdir -p "$${ROOT}" - miden-validator golden-dkg board \ + miden-validator dkg board \ --data-directory "$${ROOT}/board" \ --genesis "$${GENESIS}" \ --threshold 2 \ @@ -64,7 +64,7 @@ services: run_validator() { PARTICIPANT="$$1" SIGNING_KEY="$$2" - exec miden-validator golden-dkg run \ + exec miden-validator dkg run \ --board-file "$${TICKET}" \ --genesis "$${GENESIS}" \ --signing-key.hex "$${SIGNING_KEY}" \ @@ -128,7 +128,7 @@ services: ! cmp -s "$${ROOT}/validator-1/bundle/secret-share.wire" \ "$${ROOT}/validator-$${PARTICIPANT}/bundle/secret-share.wire" done - echo "Golden DKG completed over the Iroh document." + echo "storage key DKG completed over the Iroh document." validator-1: <<: *validator diff --git a/docs/external/src/local-network-development.md b/docs/external/src/local-network-development.md index 8c798e9542..cf607ac38d 100644 --- a/docs/external/src/local-network-development.md +++ b/docs/external/src/local-network-development.md @@ -238,15 +238,16 @@ starting the network. This can take several minutes. For a faster local start, s `MIDEN_VALIDATOR_USE_STORAGE_KEY_FIXTURE=true` to use the committed insecure fixture instead. The fixture is public test data and must never be used outside local development. -To exercise the production ceremony and Iroh document locally, build the validator image and run the opt-in check: +To exercise the production ceremony through an Iroh document, build the validator image and run the opt-in check. The +fixture flag skips the separate bootstrap ceremony, so the command runs only the Iroh ceremony under test. ```bash make docker-build-validator -docker compose --profile golden-dkg run --rm golden-dkg-check +MIDEN_VALIDATOR_USE_STORAGE_KEY_FIXTURE=true \ +docker compose --profile storage-key-dkg run --rm storage-key-dkg-check ``` -This runs the production proof backend and can take several minutes. It does not replace the fixture used by normal -local startup. +This runs the production proof backend and can take several minutes. ## Check the RPC API diff --git a/docs/external/src/network-operator/validator.md b/docs/external/src/network-operator/validator.md index aac1baa88d..0e723d0b16 100644 --- a/docs/external/src/network-operator/validator.md +++ b/docs/external/src/network-operator/validator.md @@ -43,12 +43,12 @@ its epoch may need to be decrypted. For the normal ceremony, one operator starts the durable Iroh bulletin board: ```bash -miden-validator golden-dkg board \ - --data-directory golden-board \ +miden-validator dkg board \ + --data-directory storage-key-board \ --genesis genesis.dat \ --threshold 2 \ --epoch <32-byte-hex-epoch> \ - --ticket-output golden-board-ticket + --ticket-output storage-key-board-ticket ``` The command writes one board ticket. Send the file to each genesis validator through the authenticated bootstrap @@ -58,11 +58,11 @@ board running until every validator reports ceremony completion, then stop it wi Each validator then runs the full ceremony with its own signing key and private work directory: ```bash -miden-validator golden-dkg run \ +miden-validator dkg run \ --board-file \ --genesis genesis.dat \ --signing-key.kms-id \ - --work-directory golden-work \ + --work-directory storage-key-work \ --output-directory storage-key ``` From b73578f66aeda02f56baf9cbf2bbe9f03b2af3d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Wed, 5 Aug 2026 10:02:15 -0400 Subject: [PATCH 08/28] fix(validator): clarify Iroh DKG operation --- bin/validator/src/commands/dkg/board.rs | 14 +++++++++++++- bin/validator/src/commands/dkg/runner.rs | 5 +++-- bin/validator/src/commands/dkg/runner/tests.rs | 1 + bin/validator/src/commands/dkg/tests.rs | 3 ++- bin/validator/src/commands/mod.rs | 2 +- bin/validator/src/main.rs | 4 ++-- compose/validator.yml | 1 + docs/external/src/network-operator/validator.md | 7 ++++--- 8 files changed, 27 insertions(+), 10 deletions(-) diff --git a/bin/validator/src/commands/dkg/board.rs b/bin/validator/src/commands/dkg/board.rs index 7a025ef9f6..7596c8793c 100644 --- a/bin/validator/src/commands/dkg/board.rs +++ b/bin/validator/src/commands/dkg/board.rs @@ -1,3 +1,11 @@ +//! A bounded, append-only exchange for storage key DKG artifacts. +//! +//! The board process is the only writer to the Iroh document. Validators receive a read-only +//! document ticket plus a bearer secret for the board's bounded upload protocol. Each +//! [`ArtifactSlot`] starts empty and may hold one content-addressed value; a conflicting value is +//! an error. This module only moves and stores artifacts. The ceremony phases that use those +//! artifacts are ordered in `runner`. + use std::collections::BTreeMap; use std::fmt; use std::path::Path; @@ -41,7 +49,11 @@ const COMMON_ARTIFACT_COUNT: usize = 3; const ARTIFACTS_PER_PARTICIPANT: usize = 6; const MAX_VALUES_PER_SLOT: usize = 2; -/// A read-only document ticket paired with permission to submit bounded artifacts to its board. +/// The board address and read capability, paired with permission to upload bounded artifacts. +/// +/// This bearer credential contains no DKG private material. Anyone who has it can read ceremony +/// artifacts and submit values to empty participant slots, so operators must share it through an +/// authenticated private channel. #[derive(Clone, Debug)] pub(super) struct BoardTicket { document: DocTicket, diff --git a/bin/validator/src/commands/dkg/runner.rs b/bin/validator/src/commands/dkg/runner.rs index c3be556879..55bb904bc5 100644 --- a/bin/validator/src/commands/dkg/runner.rs +++ b/bin/validator/src/commands/dkg/runner.rs @@ -1,3 +1,4 @@ +use std::num::NonZeroUsize; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -88,7 +89,7 @@ pub(super) struct DkgBoardServeOptions { /// Number of shares needed to decrypt a private record. #[arg(long, value_name = "NUM")] - threshold: usize, + threshold: NonZeroUsize, /// Hex-encoded 32-byte storage-key epoch. #[arg(long, value_name = "HEX")] @@ -170,7 +171,7 @@ pub(super) async fn serve_board(options: DkgBoardServeOptions) -> anyhow::Result &board, &options.data_directory, &options.genesis, - options.threshold, + options.threshold.get(), &options.epoch, CEREMONY_WAIT_TIMEOUT, ) diff --git a/bin/validator/src/commands/dkg/runner/tests.rs b/bin/validator/src/commands/dkg/runner/tests.rs index d83ca692c5..4363bc9960 100644 --- a/bin/validator/src/commands/dkg/runner/tests.rs +++ b/bin/validator/src/commands/dkg/runner/tests.rs @@ -3,6 +3,7 @@ use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey; use super::*; #[tokio::test] +/// Guards the final agreement step against copying one validator's signature into another slot. async fn final_confirmation_cannot_be_copied_between_validator_slots() -> anyhow::Result<()> { let first = ValidatorSigner::new_local(SigningKey::new()); let second = ValidatorSigner::new_local(SigningKey::new()); diff --git a/bin/validator/src/commands/dkg/tests.rs b/bin/validator/src/commands/dkg/tests.rs index 848531470a..1a2c953e82 100644 --- a/bin/validator/src/commands/dkg/tests.rs +++ b/bin/validator/src/commands/dkg/tests.rs @@ -900,7 +900,8 @@ async fn deal_rejects_unknown_identity_and_existing_output() -> TestResult { Ok(()) } #[tokio::test] -async fn iroh_board_restarts_a_runner_and_completes_three_validator_ceremony() -> TestResult { +/// Proves a validator can resume from its saved identity after its ceremony process stops. +async fn iroh_ceremony_resumes_after_validator_restart() -> TestResult { let root = tempfile::tempdir()?; let genesis = write_genesis(root.path())?; let board_directory = root.path().join("board"); diff --git a/bin/validator/src/commands/mod.rs b/bin/validator/src/commands/mod.rs index e93c2cd12b..4713bf6e8f 100644 --- a/bin/validator/src/commands/mod.rs +++ b/bin/validator/src/commands/mod.rs @@ -275,7 +275,7 @@ impl ValidatorCommand { .context("failed to apply validator database migrations")?; Ok(()) }, - Self::Dkg(options) => dkg::run(options).await, + Self::Dkg(options) => Box::pin(dkg::run(options)).await, Self::IssuePrivateRecordShare(options) => { issue_private_record_share::issue_from_options(options) }, diff --git a/bin/validator/src/main.rs b/bin/validator/src/main.rs index b5f98ecdc1..f4fbe9496a 100644 --- a/bin/validator/src/main.rs +++ b/bin/validator/src/main.rs @@ -10,8 +10,8 @@ async fn main() -> anyhow::Result<()> { let _otel_guard = miden_node_tracing::setup_tracing(command.open_telemetry())?; - Box::pin(miden_node_utils::shutdown::run_with_shutdown("miden-validator", |shutdown| { + miden_node_utils::shutdown::run_with_shutdown("miden-validator", |shutdown| { command.handle(shutdown) - })) + }) .await } diff --git a/compose/validator.yml b/compose/validator.yml index 009347c143..50f7e5d3eb 100644 --- a/compose/validator.yml +++ b/compose/validator.yml @@ -15,6 +15,7 @@ x-validator: &validator - --admin.listen=0.0.0.0:50102 services: + # Opt-in end-to-end check for the Iroh exchange. Validator bootstrap is defined in node.yml. storage-key-dkg-check: profiles: ["storage-key-dkg"] image: ${MIDEN_VALIDATOR_IMAGE:-miden-validator} diff --git a/docs/external/src/network-operator/validator.md b/docs/external/src/network-operator/validator.md index 0e723d0b16..04d10686da 100644 --- a/docs/external/src/network-operator/validator.md +++ b/docs/external/src/network-operator/validator.md @@ -51,9 +51,10 @@ miden-validator dkg board \ --ticket-output storage-key-board-ticket ``` -The command writes one board ticket. Send the file to each genesis validator through the authenticated bootstrap -channel. The ticket grants read access and permission to upload bounded ceremony artifacts. Do not publish it. Keep the -board running until every validator reports ceremony completion, then stop it with Ctrl-C. +The command writes one board ticket. It contains the board's Iroh address, a read-only document capability, and a bearer +secret for bounded uploads. It contains no private DKG share. Send the file to each genesis validator through the +authenticated bootstrap channel. Anyone with the ticket can read ceremony artifacts and fill empty upload slots, so do +not publish it. Keep the board running until every validator reports ceremony completion, then stop it with Ctrl-C. Each validator then runs the full ceremony with its own signing key and private work directory: From b0b86912fdb7189cdbc868ac94575d7bd1096361 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Wed, 5 Aug 2026 10:06:34 -0400 Subject: [PATCH 09/28] docs(validator): state board ticket trust --- bin/validator/src/commands/dkg/board.rs | 8 ++++---- docs/external/src/network-operator/validator.md | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/bin/validator/src/commands/dkg/board.rs b/bin/validator/src/commands/dkg/board.rs index 7596c8793c..3e297a2346 100644 --- a/bin/validator/src/commands/dkg/board.rs +++ b/bin/validator/src/commands/dkg/board.rs @@ -2,9 +2,9 @@ //! //! The board process is the only writer to the Iroh document. Validators receive a read-only //! document ticket plus a bearer secret for the board's bounded upload protocol. Each -//! [`ArtifactSlot`] starts empty and may hold one content-addressed value; a conflicting value is -//! an error. This module only moves and stores artifacts. The ceremony phases that use those -//! artifacts are ordered in `runner`. +//! [`ArtifactSlot`] is valid only while it holds at most one content-addressed value. A second +//! distinct value poisons that slot and stops the ceremony. This module only moves and stores +//! artifacts. The ceremony phases that use those artifacts are ordered in `runner`. use std::collections::BTreeMap; use std::fmt; @@ -52,7 +52,7 @@ const MAX_VALUES_PER_SLOT: usize = 2; /// The board address and read capability, paired with permission to upload bounded artifacts. /// /// This bearer credential contains no DKG private material. Anyone who has it can read ceremony -/// artifacts and submit values to empty participant slots, so operators must share it through an +/// artifacts and poison any slot with a conflicting value, so operators must share it through an /// authenticated private channel. #[derive(Clone, Debug)] pub(super) struct BoardTicket { diff --git a/docs/external/src/network-operator/validator.md b/docs/external/src/network-operator/validator.md index 04d10686da..e5e1f71c33 100644 --- a/docs/external/src/network-operator/validator.md +++ b/docs/external/src/network-operator/validator.md @@ -53,8 +53,9 @@ miden-validator dkg board \ The command writes one board ticket. It contains the board's Iroh address, a read-only document capability, and a bearer secret for bounded uploads. It contains no private DKG share. Send the file to each genesis validator through the -authenticated bootstrap channel. Anyone with the ticket can read ceremony artifacts and fill empty upload slots, so do -not publish it. Keep the board running until every validator reports ceremony completion, then stop it with Ctrl-C. +authenticated bootstrap channel. Anyone with the ticket can read ceremony artifacts or stop the ceremony by uploading a +conflicting value, so do not publish it. Keep the board running until every validator reports ceremony completion, then +stop it with Ctrl-C. Each validator then runs the full ceremony with its own signing key and private work directory: From 1f05b70a240f3654e1bb2c344ba08cb04b47dc2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Wed, 5 Aug 2026 22:38:53 -0400 Subject: [PATCH 10/28] feat(validator): verify Iroh DKG setup locally --- bin/validator/src/commands/dkg/runner.rs | 70 +++++++++++++++++-- bin/validator/src/commands/dkg/tests.rs | 23 +++++- compose/validator.yml | 2 + .../src/network-operator/validator.md | 20 +++--- 4 files changed, 97 insertions(+), 18 deletions(-) diff --git a/bin/validator/src/commands/dkg/runner.rs b/bin/validator/src/commands/dkg/runner.rs index 55bb904bc5..9eadd90100 100644 --- a/bin/validator/src/commands/dkg/runner.rs +++ b/bin/validator/src/commands/dkg/runner.rs @@ -56,6 +56,7 @@ use super::{ }; const IDENTITY_DIRECTORY: &str = "identity"; +const REGISTRATIONS_DIRECTORY: &str = "registrations"; const CEREMONY_DIRECTORY: &str = "ceremony"; const DEALINGS_DIRECTORY: &str = "dealings"; const PUBLIC_DEALINGS_DIRECTORY: &str = "public-dealings"; @@ -115,6 +116,14 @@ pub(super) struct DkgRunOptions { #[arg(long, value_name = "FILE")] genesis: PathBuf, + /// Expected number of shares needed to decrypt a private record. + #[arg(long, value_name = "NUM")] + threshold: NonZeroUsize, + + /// Expected hex-encoded 32-byte storage-key epoch. + #[arg(long, value_name = "HEX")] + epoch: String, + /// Validator signing key committed by genesis. #[command(flatten)] signing_key: ValidatorSigningKey, @@ -147,6 +156,8 @@ pub(super) async fn run_validator(options: DkgRunOptions) -> anyhow::Result<()> &board, &options.genesis, &signer, + options.threshold.get(), + &options.epoch, &options.work_directory, &options.output_directory, true, @@ -205,7 +216,7 @@ pub(super) async fn coordinate_common_files( let ceremony_directory = data_directory.join(CEREMONY_DIRECTORY); if !ceremony_directory.exists() { let registrations = wait_for_registrations(board, validator_keys, timeout).await?; - let registration_directory = data_directory.join("registrations"); + let registration_directory = data_directory.join(REGISTRATIONS_DIRECTORY); materialize_or_compare(®istration_directory, ®istrations)?; let paths = registrations .iter() @@ -262,10 +273,16 @@ async fn wait_for_registrations( } /// Runs the restartable validator state machine over one board. +#[expect( + clippy::too_many_arguments, + reason = "the inputs separate ceremony policy, durable paths, and test networking" +)] pub(super) async fn run_validator_with_network( ticket: &str, genesis_path: &Path, signer: &ValidatorSigner, + threshold: usize, + epoch: &str, work_directory: &Path, output_directory: &Path, use_network_services: bool, @@ -279,8 +296,14 @@ where format!("failed to create DKG work directory {}", work_directory.display()) })?; let genesis = read_trusted_genesis(genesis_path)?; - let participant_count = genesis.inner().header().validator_keys().as_keys().len(); - let participant = prepare_local_identity(genesis_path, signer, work_directory).await?; + let validator_keys = genesis.inner().header().validator_keys().as_keys(); + let participant_count = validator_keys.len(); + ensure!( + threshold > 0 && threshold <= participant_count, + "threshold must be between 1 and {participant_count}", + ); + decode_fixed_hex::<32>(epoch, "storage-key epoch")?; + let participant = prepare_local_identity(genesis_path, epoch, signer, work_directory).await?; let board_directory = work_directory.join(BOARD_DIRECTORY); let board = if use_network_services { BoardNode::join(&board_directory, ticket, participant_count).await? @@ -292,6 +315,8 @@ where genesis_path, signer, participant, + threshold, + epoch, work_directory, output_directory, timeout, @@ -302,14 +327,17 @@ where } #[expect( + clippy::too_many_arguments, clippy::too_many_lines, - reason = "the linear body mirrors the ceremony phase order" + reason = "the inputs and linear body mirror the ceremony policy and phase order" )] async fn run_validator_on_board( board: &BoardNode, genesis_path: &Path, signer: &ValidatorSigner, participant: ParticipantIndex, + threshold: usize, + epoch: &str, work_directory: &Path, output_directory: &Path, timeout: Duration, @@ -326,7 +354,20 @@ where ) .await?; + let genesis = read_trusted_genesis(genesis_path)?; + let registrations = + wait_for_registrations(board, genesis.inner().header().validator_keys().as_keys(), timeout) + .await?; + let registration_directory = work_directory.join(REGISTRATIONS_DIRECTORY); + materialize_or_compare(®istration_directory, ®istrations)?; + let registration_paths = registrations + .iter() + .map(|(name, _)| registration_directory.join(name)) + .collect::>(); let ceremony_directory = work_directory.join(CEREMONY_DIRECTORY); + if !ceremony_directory.exists() { + prepare(genesis_path, threshold, epoch, ®istration_paths, &ceremony_directory)?; + } let common = vec![ ( MANIFEST_FILE.to_owned(), @@ -343,6 +384,14 @@ where ]; materialize_or_compare(&ceremony_directory, &common)?; let ceremony = read_ceremony(genesis_path, &ceremony_directory)?; + ensure!( + ceremony.manifest.threshold == threshold, + "board threshold does not match the validator's expected threshold" + ); + ensure!( + ceremony.manifest.epoch == epoch, + "board epoch does not match the validator's expected epoch" + ); ensure!( ceremony.manifest.participants[participant.get() as usize - 1].validator_public_key == hex::encode(signer.public_key().to_bytes()), @@ -470,6 +519,7 @@ where pub(super) async fn prepare_local_identity( genesis_path: &Path, + epoch: &str, signer: &ValidatorSigner, work_directory: &Path, ) -> anyhow::Result { @@ -477,9 +527,9 @@ pub(super) async fn prepare_local_identity( let participant = participant_for_validator(genesis_path, &validator_key)?; let identity_directory = work_directory.join(IDENTITY_DIRECTORY); if !identity_directory.exists() { - generate_identity(genesis_path, signer, &identity_directory).await?; + generate_identity(genesis_path, epoch, signer, &identity_directory).await?; } - validate_local_identity(genesis_path, &validator_key, &identity_directory)?; + validate_local_identity(genesis_path, epoch, &validator_key, &identity_directory)?; Ok(participant) } @@ -506,6 +556,7 @@ fn participant_at(position: usize) -> anyhow::Result { fn validate_local_identity( genesis_path: &Path, + epoch: &str, validator_key: &PublicKey, identity_directory: &Path, ) -> anyhow::Result<()> { @@ -516,7 +567,12 @@ fn validate_local_identity( "stored DKG identity belongs to another validator", ); let genesis = read_trusted_genesis(genesis_path)?; - read_validated_registrations(&[registration_path], genesis.inner().header().commitment())?; + let expected_epoch = decode_fixed_hex::<32>(epoch, "storage-key epoch")?; + read_validated_registrations( + &[registration_path], + genesis.inner().header().commitment(), + &expected_epoch, + )?; let secret = Zeroizing::new(fs_err::read(identity_directory.join(IDENTITY_SECRET_FILE))?); let secret = decode_identity_secret(&secret)?; ensure!( diff --git a/bin/validator/src/commands/dkg/tests.rs b/bin/validator/src/commands/dkg/tests.rs index 1a2c953e82..bf46846bfa 100644 --- a/bin/validator/src/commands/dkg/tests.rs +++ b/bin/validator/src/commands/dkg/tests.rs @@ -901,6 +901,10 @@ async fn deal_rejects_unknown_identity_and_existing_output() -> TestResult { } #[tokio::test] /// Proves a validator can resume from its saved identity after its ceremony process stops. +#[expect( + clippy::too_many_lines, + reason = "the test runs every ceremony phase for three validators" +)] async fn iroh_ceremony_resumes_after_validator_restart() -> TestResult { let root = tempfile::tempdir()?; let genesis = write_genesis(root.path())?; @@ -909,6 +913,7 @@ async fn iroh_ceremony_resumes_after_validator_restart() -> TestResult { let ticket = ticket.to_string(); let timeout = Duration::from_mins(2); let restart_checkpoint_timeout = Duration::from_secs(10); + let epoch = "66".repeat(32); let first_work = root.path().join("work-1"); fs_err::create_dir(&first_work)?; let first_signer = ValidatorSigner::new_local(genesis.signing_keys[0].clone()); @@ -916,8 +921,10 @@ async fn iroh_ceremony_resumes_after_validator_restart() -> TestResult { let interrupted = tokio::spawn({ let genesis_path = genesis.path.clone(); let first_work = first_work.clone(); + let epoch = epoch.clone(); async move { - runner::prepare_local_identity(&genesis_path, &first_signer, &first_work).await?; + runner::prepare_local_identity(&genesis_path, &epoch, &first_signer, &first_work) + .await?; std::future::pending::>().await } }); @@ -944,7 +951,6 @@ async fn iroh_ceremony_resumes_after_validator_restart() -> TestResult { let bundle_directories = (1..=3) .map(|participant| root.path().join(format!("bundle-{participant}"))) .collect::>(); - let epoch = "66".repeat(32); let coordinate = runner::coordinate_common_files( &board, &board_directory, @@ -957,6 +963,8 @@ async fn iroh_ceremony_resumes_after_validator_restart() -> TestResult { &ticket, &genesis.path, &signers[0], + 2, + &epoch, &work_directories[0], &bundle_directories[0], false, @@ -966,6 +974,8 @@ async fn iroh_ceremony_resumes_after_validator_restart() -> TestResult { &ticket, &genesis.path, &signers[1], + 2, + &epoch, &work_directories[1], &bundle_directories[1], false, @@ -975,6 +985,8 @@ async fn iroh_ceremony_resumes_after_validator_restart() -> TestResult { &ticket, &genesis.path, &signers[2], + 2, + &epoch, &work_directories[2], &bundle_directories[2], false, @@ -995,6 +1007,13 @@ async fn iroh_ceremony_resumes_after_validator_restart() -> TestResult { assert_ne!(secret_shares[0], secret_shares[1]); assert_ne!(secret_shares[1], secret_shares[2]); assert_ne!(secret_shares[0], secret_shares[2]); + let common_files = [MANIFEST_FILE, DECRYPTION_CONFIG_FILE, CONTEXT_CONFIG_FILE]; + assert!(common_files.iter().all(|name| { + let expected = fs_err::read(board_directory.join("ceremony").join(name)).unwrap(); + work_directories + .iter() + .all(|work| fs_err::read(work.join("ceremony").join(name)).unwrap() == expected) + })); board.shutdown().await?; Ok(()) diff --git a/compose/validator.yml b/compose/validator.yml index 50f7e5d3eb..659edada71 100644 --- a/compose/validator.yml +++ b/compose/validator.yml @@ -68,6 +68,8 @@ services: exec miden-validator dkg run \ --board-file "$${TICKET}" \ --genesis "$${GENESIS}" \ + --threshold 2 \ + --epoch "$${EPOCH}" \ --signing-key.hex "$${SIGNING_KEY}" \ --work-directory "$${ROOT}/validator-$${PARTICIPANT}/work" \ --output-directory "$${ROOT}/validator-$${PARTICIPANT}/bundle" diff --git a/docs/external/src/network-operator/validator.md b/docs/external/src/network-operator/validator.md index e5e1f71c33..219b350f22 100644 --- a/docs/external/src/network-operator/validator.md +++ b/docs/external/src/network-operator/validator.md @@ -63,21 +63,23 @@ Each validator then runs the full ceremony with its own signing key and private miden-validator dkg run \ --board-file \ --genesis genesis.dat \ + --threshold 2 \ + --epoch <32-byte-hex-epoch> \ --signing-key.kms-id \ --work-directory storage-key-work \ --output-directory storage-key ``` Both commands can restart with the same data and work directories. Give `--ticket-output` a new path when restarting the -board because it will not overwrite a ticket file. The board prepares the common files after all signed registrations -arrive. Each validator checks every artifact, writes its own storage key bundle, and confirms that all validators -produced the same public output. A board directory from an older format cannot be reopened; start that ceremony again in -a new directory. - -The commands below provide a manual recovery path. First, each operator creates a DKG identity for the agreed storage-key -epoch and sends `registration.toml` to the coordinator. The registration proves ownership of the DKG identity secret. -The signing key must match one key in genesis. Use `--signing-key.hex` instead of KMS only for local or private -deployments. +board because it will not overwrite a ticket file. Each validator collects the signed registrations and derives the +common files from its expected threshold and epoch. It rejects different board copies, checks every later artifact, +writes its own storage key bundle, and confirms that all validators produced the same public output. A board directory +from an older format cannot be reopened; start that ceremony again in a new directory. + +The commands below provide a manual recovery path. First, each operator creates a DKG identity for the agreed +storage-key epoch and sends `registration.toml` to the coordinator. The registration proves ownership of the DKG +identity secret. The signing key must match one key in genesis. Use `--signing-key.hex` instead of KMS only for local or +private deployments. ```bash miden-validator dkg identity \ From 7de87358e48b4674ef18c3a879b84118ab96c4ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Wed, 5 Aug 2026 23:00:19 -0400 Subject: [PATCH 11/28] feat(validator): scope DKG board uploads by participant --- bin/validator/src/commands/dkg/board.rs | 173 +++++++++++++----- bin/validator/src/commands/dkg/board/tests.rs | 78 ++++++-- bin/validator/src/commands/dkg/runner.rs | 30 ++- bin/validator/src/commands/dkg/tests.rs | 22 ++- compose/validator.yml | 8 +- .../src/network-operator/validator.md | 17 +- 6 files changed, 241 insertions(+), 87 deletions(-) diff --git a/bin/validator/src/commands/dkg/board.rs b/bin/validator/src/commands/dkg/board.rs index 3e297a2346..3fa411813b 100644 --- a/bin/validator/src/commands/dkg/board.rs +++ b/bin/validator/src/commands/dkg/board.rs @@ -1,7 +1,7 @@ //! A bounded, append-only exchange for storage key DKG artifacts. //! //! The board process is the only writer to the Iroh document. Validators receive a read-only -//! document ticket plus a bearer secret for the board's bounded upload protocol. Each +//! document ticket plus a participant-scoped secret for the board's bounded upload protocol. Each //! [`ArtifactSlot`] is valid only while it holds at most one content-addressed value. A second //! distinct value poisons that slot and stops the ceremony. This module only moves and stores //! artifacts. The ceremony phases that use those artifacts are ordered in `runner`. @@ -29,15 +29,15 @@ use iroh_docs::protocol::Docs; use iroh_docs::store::{DownloadPolicy, Query}; use iroh_gossip::net::Gossip; -use super::{decode_fixed_hex, write_new_file}; +use super::{decode_fixed_hex, publish_directory, write_new_file}; const ENDPOINT_SECRET_FILE: &str = "endpoint-secret.hex"; const DOCUMENT_ID_FILE: &str = "document-id.hex"; const BOARD_FORMAT_FILE: &str = "board-format"; -const BOARD_FORMAT: &[u8] = b"bounded-upload-v1\n"; -const UPLOAD_SECRET_FILE: &str = "upload-secret.hex"; -const BOARD_TICKET_PREFIX: &str = "miden-storage-key-dkg-board-v1"; -const UPLOAD_ALPN: &[u8] = b"/miden/storage-key-dkg-board-upload/1"; +const BOARD_FORMAT: &[u8] = b"participant-upload-v2\n"; +const UPLOAD_SECRETS_DIRECTORY: &str = "upload-secrets"; +const BOARD_TICKET_PREFIX: &str = "miden-storage-key-dkg-board-v2"; +const UPLOAD_ALPN: &[u8] = b"/miden/storage-key-dkg-board-upload/2"; const UPLOAD_HEADER_BYTES: usize = 32 + 1 + 4 + 8; const UPLOAD_RESPONSE_BYTES: usize = 1 + 32; const MAX_ARTIFACT_BYTES: u64 = 64 * 1024 * 1024; @@ -49,14 +49,14 @@ const COMMON_ARTIFACT_COUNT: usize = 3; const ARTIFACTS_PER_PARTICIPANT: usize = 6; const MAX_VALUES_PER_SLOT: usize = 2; -/// The board address and read capability, paired with permission to upload bounded artifacts. +/// The board address and read capability, paired with one participant's upload permission. /// -/// This bearer credential contains no DKG private material. Anyone who has it can read ceremony -/// artifacts and poison any slot with a conflicting value, so operators must share it through an -/// authenticated private channel. +/// This credential contains no DKG private material. Its holder can read public ceremony artifacts +/// and upload only to the named participant's slots. #[derive(Clone, Debug)] pub(super) struct BoardTicket { document: DocTicket, + pub(super) participant: u32, upload_secret: [u8; 32], } @@ -64,7 +64,8 @@ impl fmt::Display for BoardTicket { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { write!( formatter, - "{BOARD_TICKET_PREFIX}:{}:{}", + "{BOARD_TICKET_PREFIX}:{}:{}:{}", + self.participant, hex::encode(self.upload_secret), self.document ) @@ -75,8 +76,14 @@ impl FromStr for BoardTicket { type Err = anyhow::Error; fn from_str(value: &str) -> Result { - let mut parts = value.splitn(3, ':'); + let mut parts = value.splitn(4, ':'); ensure!(parts.next() == Some(BOARD_TICKET_PREFIX), "invalid DKG board ticket prefix"); + let participant = parts + .next() + .context("DKG board ticket is missing its participant index")? + .parse::() + .context("invalid DKG board participant index")?; + ensure!(participant > 0, "DKG board participant index must be nonzero"); let secret = parts.next().context("DKG board ticket is missing its upload secret")?; let document = parts.next().context("DKG board ticket is missing its document ticket")?; let upload_secret = decode_fixed_hex::<32>(secret, "DKG board upload secret")?; @@ -85,7 +92,7 @@ impl FromStr for BoardTicket { matches!(document.capability, iroh_docs::Capability::Read(_)), "DKG board document ticket must be read-only" ); - Ok(Self { document, upload_secret }) + Ok(Self { document, participant, upload_secret }) } } @@ -170,6 +177,7 @@ enum Publisher { Local(BoardWriter), Remote { endpoint: Endpoint, + participant: u32, target: EndpointAddr, upload_secret: [u8; 32], }, @@ -178,7 +186,7 @@ enum Publisher { #[derive(Clone, Debug)] struct UploadProtocol { permits: Arc, - upload_secret: [u8; 32], + upload_secrets: Arc>, writer: BoardWriter, } @@ -223,11 +231,11 @@ impl Drop for BoardNode { } impl BoardNode { - /// Creates a new ceremony document and returns its read and upload ticket. + /// Creates a new ceremony document and returns one scoped ticket per participant. pub(super) async fn create( data_directory: &Path, participant_count: usize, - ) -> anyhow::Result<(Self, BoardTicket)> { + ) -> anyhow::Result<(Self, Vec)> { Self::create_with_network(data_directory, participant_count, true).await } @@ -235,7 +243,7 @@ impl BoardNode { data_directory: &Path, participant_count: usize, use_network_services: bool, - ) -> anyhow::Result<(Self, BoardTicket)> { + ) -> anyhow::Result<(Self, Vec)> { let runtime = BoardRuntime::start(data_directory, use_network_services).await?; let document_id_path = data_directory.join(DOCUMENT_ID_FILE); let existing_document = document_id_path.exists(); @@ -261,7 +269,8 @@ impl BoardNode { write_new_file(&data_directory.join(BOARD_FORMAT_FILE), BOARD_FORMAT, true)?; document }; - let upload_secret = load_or_create_upload_secret(data_directory, !existing_document)?; + let upload_secrets = + load_or_create_upload_secrets(data_directory, participant_count, !existing_document)?; document .set_download_policy(DownloadPolicy::NothingExcept(Vec::new())) .await @@ -290,16 +299,27 @@ impl BoardNode { [iroh::TransportAddr::Ip(socket)], )]; } - let ticket = BoardTicket { document: document_ticket, upload_secret }; + let tickets = upload_secrets + .iter() + .enumerate() + .map(|(position, upload_secret)| { + Ok(BoardTicket { + document: document_ticket.clone(), + participant: u32::try_from(position + 1) + .context("too many DKG participants")?, + upload_secret: *upload_secret, + }) + }) + .collect::>>()?; let board = runtime - .attach(document, participant_count, Vec::new(), Some(upload_secret), None) + .attach(document, participant_count, Vec::new(), Some(upload_secrets), None) .await?; board .document .start_sync(Vec::new()) .await .context("failed to start DKG board synchronization")?; - Ok((board, ticket)) + Ok((board, tickets)) } /// Joins an existing ceremony document through its read and upload ticket. @@ -319,7 +339,12 @@ impl BoardNode { ) -> anyhow::Result { let ticket = BoardTicket::from_str(ticket)?; let runtime = BoardRuntime::start(data_directory, use_network_services).await?; - let BoardTicket { document, upload_secret } = ticket; + let BoardTicket { document, participant, upload_secret } = ticket; + ensure!( + usize::try_from(participant).context("participant index does not fit usize")? + <= participant_count, + "DKG board ticket names an unknown participant" + ); let DocTicket { capability, nodes } = document; let target = nodes.first().cloned().context("DKG board ticket has no endpoint")?; let document = runtime @@ -332,7 +357,13 @@ impl BoardNode { .await .context("failed to restrict DKG board downloads")?; let mut board = runtime - .attach(document, participant_count, nodes.clone(), None, Some((target, upload_secret))) + .attach( + document, + participant_count, + nodes.clone(), + None, + Some((target, participant, upload_secret)), + ) .await?; board .document @@ -351,8 +382,13 @@ impl BoardNode { let sync_generation = *self.sync_generation.borrow(); let stored_hash = match &self.publisher { Publisher::Local(writer) => writer.store(slot, value).await?, - Publisher::Remote { endpoint, target, upload_secret } => { - upload_artifact(endpoint, target, upload_secret, slot, value).await? + Publisher::Remote { + endpoint, + participant, + target, + upload_secret, + } => { + upload_artifact(endpoint, target, *participant, upload_secret, slot, value).await? }, }; ensure!(stored_hash == expected_hash, "Iroh stored artifact under an unexpected hash"); @@ -571,12 +607,20 @@ impl UploadProtocol { recv.read_exact(&mut header) .await .context("failed to read DKG board upload header")?; - ensure!( - secrets_match(&header[..32], &self.upload_secret), - "invalid DKG board upload secret" - ); let kind = header[32]; let participant = u32::from_be_bytes(header[33..37].try_into().expect("fixed slice")); + let secret_position = usize::try_from(participant) + .context("participant index does not fit usize")? + .checked_sub(1) + .context("DKG board participant index must be nonzero")?; + let expected_secret = self + .upload_secrets + .get(secret_position) + .context("DKG board upload targets an unknown participant")?; + ensure!( + secrets_match(&header[..32], expected_secret), + "DKG board ticket does not authorize this participant" + ); let length = u64::from_be_bytes(header[37..45].try_into().expect("fixed slice")); ensure!( length > 0 && length <= MAX_ARTIFACT_BYTES, @@ -630,12 +674,17 @@ impl UploadProtocol { async fn upload_artifact( endpoint: &Endpoint, target: &EndpointAddr, + authorized_participant: u32, upload_secret: &[u8; 32], slot: &ArtifactSlot, value: &[u8], ) -> anyhow::Result { validate_artifact_length(value.len())?; let (kind, participant) = slot.upload_fields()?; + ensure!( + participant == authorized_participant, + "DKG board ticket does not authorize participant {participant}" + ); upload_artifact_request( endpoint, target, @@ -762,12 +811,12 @@ impl BoardRuntime { document: Doc, participant_count: usize, sync_targets: Vec, - served_upload_secret: Option<[u8; 32]>, - remote_upload: Option<(EndpointAddr, [u8; 32])>, + served_upload_secrets: Option>, + remote_upload: Option<(EndpointAddr, u32, [u8; 32])>, ) -> anyhow::Result { ensure!(participant_count > 0, "DKG board requires at least one participant"); ensure!( - served_upload_secret.is_some() ^ remote_upload.is_some(), + served_upload_secrets.is_some() ^ remote_upload.is_some(), "DKG board must either serve or submit uploads" ); let artifact_slot_count = participant_count @@ -786,8 +835,9 @@ impl BoardRuntime { lock: Arc::new(tokio::sync::Mutex::new(())), }; let publisher = match remote_upload { - Some((target, upload_secret)) => Publisher::Remote { + Some((target, participant, upload_secret)) => Publisher::Remote { endpoint: self.endpoint.clone(), + participant, target, upload_secret, }, @@ -797,12 +847,16 @@ impl BoardRuntime { .accept(iroh_blobs::ALPN, BlobsProtocol::new(self.blobs.as_ref(), None)) .accept(iroh_gossip::ALPN, self.gossip) .accept(iroh_docs::ALPN, self.docs.clone()); - if let Some(upload_secret) = served_upload_secret { + if let Some(upload_secrets) = served_upload_secrets { + ensure!( + upload_secrets.len() == participant_count, + "DKG board requires one upload secret per participant" + ); router = router.accept( UPLOAD_ALPN, UploadProtocol { permits: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_UPLOADS)), - upload_secret, + upload_secrets: Arc::new(upload_secrets), writer, }, ); @@ -955,32 +1009,57 @@ fn load_or_create_endpoint_secret(data_directory: &Path) -> anyhow::Result anyhow::Result<[u8; 32]> { - let path = data_directory.join(UPLOAD_SECRET_FILE); +) -> anyhow::Result> { + let path = data_directory.join(UPLOAD_SECRETS_DIRECTORY); if path.exists() { - let bytes = fs_err::read_to_string(&path).with_context(|| { - format!("failed to read DKG board upload secret {}", path.display()) - })?; - return decode_fixed_hex::<32>(bytes.trim(), "DKG board upload secret"); + let entry_count = fs_err::read_dir(&path) + .with_context(|| format!("failed to read DKG board upload secrets {}", path.display()))? + .collect::, _>>()? + .len(); + ensure!( + entry_count == participant_count, + "DKG board upload secret count does not match the participant count" + ); + return (1..=participant_count) + .map(|participant| { + let secret_path = path.join(format!("participant-{participant}.hex")); + let bytes = fs_err::read_to_string(&secret_path).with_context(|| { + format!("failed to read DKG board upload secret {}", secret_path.display()) + })?; + decode_fixed_hex::<32>(bytes.trim(), "DKG board upload secret") + }) + .collect(); } ensure!( allow_create, - "this DKG board predates bounded uploads; start a new ceremony in a new data directory" + "this DKG board predates participant-scoped uploads; start a new ceremony in a new data directory" ); - let secret = SecretKey::generate().to_bytes(); - write_new_file(&path, hex::encode(secret).as_bytes(), true)?; - Ok(secret) + let secrets = (0..participant_count) + .map(|_| SecretKey::generate().to_bytes()) + .collect::>(); + publish_directory(&path, |temporary| { + for (position, secret) in secrets.iter().enumerate() { + write_new_file( + &temporary.join(format!("participant-{}.hex", position + 1)), + hex::encode(secret).as_bytes(), + true, + )?; + } + Ok(()) + })?; + Ok(secrets) } fn require_current_board_format(data_directory: &Path) -> anyhow::Result<()> { let path = data_directory.join(BOARD_FORMAT_FILE); let format = fs_err::read(&path).with_context(|| { format!( - "this DKG board predates bounded uploads; start a new ceremony in a new data directory ({})", + "this DKG board predates participant-scoped uploads; start a new ceremony in a new data directory ({})", path.display() ) })?; diff --git a/bin/validator/src/commands/dkg/board/tests.rs b/bin/validator/src/commands/dkg/board/tests.rs index 5eca069f82..b069e64fff 100644 --- a/bin/validator/src/commands/dkg/board/tests.rs +++ b/bin/validator/src/commands/dkg/board/tests.rs @@ -1,7 +1,7 @@ use super::*; impl BoardNode { - async fn create_for_test(data_directory: &Path) -> anyhow::Result<(Self, BoardTicket)> { + async fn create_for_test(data_directory: &Path) -> anyhow::Result<(Self, Vec)> { Self::create_with_network(data_directory, 3, false).await } @@ -24,7 +24,7 @@ impl BoardNode { value: &[u8], ) -> anyhow::Result { match &self.publisher { - Publisher::Remote { endpoint, target, upload_secret } => { + Publisher::Remote { endpoint, target, upload_secret, .. } => { upload_artifact_request( endpoint, target, @@ -54,10 +54,19 @@ impl BoardNode { } } +fn ticket_for(tickets: &[BoardTicket], participant: u32) -> BoardTicket { + tickets + .iter() + .find(|ticket| ticket.participant == participant) + .expect("participant ticket must exist") + .clone() +} + #[tokio::test] async fn artifact_syncs_between_board_nodes() -> anyhow::Result<()> { let root = tempfile::tempdir()?; - let (host, ticket) = BoardNode::create_for_test(&root.path().join("host")).await?; + let (host, tickets) = BoardNode::create_for_test(&root.path().join("host")).await?; + let ticket = ticket_for(&tickets, 1); assert!(matches!(ticket.document.capability, iroh_docs::Capability::Read(_))); let client = BoardNode::join_for_test(&root.path().join("client"), ticket).await?; let slot = ArtifactSlot::Registration(1); @@ -90,13 +99,17 @@ async fn conflicting_artifacts_are_rejected() -> anyhow::Result<()> { async fn board_reopens_the_same_document_after_restart() -> anyhow::Result<()> { let root = tempfile::tempdir()?; let data_directory = root.path().join("host"); - let (host, first_ticket) = BoardNode::create_for_test(&data_directory).await?; + let (host, first_tickets) = BoardNode::create_for_test(&data_directory).await?; host.publish(&ArtifactSlot::Manifest, b"manifest").await?; host.shutdown().await?; - let (host, second_ticket) = BoardNode::create_for_test(&data_directory).await?; - assert_eq!(first_ticket.document.capability.id(), second_ticket.document.capability.id()); - assert_eq!(first_ticket.upload_secret, second_ticket.upload_secret); + let (host, second_tickets) = BoardNode::create_for_test(&data_directory).await?; + assert_eq!(first_tickets.len(), second_tickets.len()); + for (first, second) in first_tickets.iter().zip(&second_tickets) { + assert_eq!(first.participant, second.participant); + assert_eq!(first.document.capability.id(), second.document.capability.id()); + assert_eq!(first.upload_secret, second.upload_secret); + } assert_eq!(host.read_unique(&ArtifactSlot::Manifest).await?, Some(b"manifest".to_vec())); host.shutdown().await?; @@ -104,7 +117,7 @@ async fn board_reopens_the_same_document_after_restart() -> anyhow::Result<()> { } #[tokio::test] -async fn unmarked_board_is_not_reopened_even_with_an_upload_secret() -> anyhow::Result<()> { +async fn unmarked_board_is_not_reopened_even_with_upload_secrets() -> anyhow::Result<()> { let root = tempfile::tempdir()?; let data_directory = root.path().join("host"); let (host, _) = BoardNode::create_for_test(&data_directory).await?; @@ -115,7 +128,7 @@ async fn unmarked_board_is_not_reopened_even_with_an_upload_secret() -> anyhow:: .await .err() .context("legacy board unexpectedly reopened")?; - assert!(error.to_string().contains("predates bounded uploads")); + assert!(error.to_string().contains("predates participant-scoped uploads")); Ok(()) } @@ -123,11 +136,12 @@ async fn unmarked_board_is_not_reopened_even_with_an_upload_secret() -> anyhow:: async fn unknown_participants_and_artifact_kinds_are_rejected_before_body_allocation() -> anyhow::Result<()> { let root = tempfile::tempdir()?; - let (host, ticket) = BoardNode::create_for_test(&root.path().join("host")).await?; + let (host, tickets) = BoardNode::create_for_test(&root.path().join("host")).await?; + let ticket = ticket_for(&tickets, 1); let client = BoardNode::join_for_test(&root.path().join("client"), ticket).await?; let error = client.upload_raw_for_test(1, 99, MAX_ARTIFACT_BYTES, &[]).await.unwrap_err(); - assert!(error.to_string().contains("unknown participant or artifact slot")); + assert!(error.to_string().contains("unknown participant")); let error = client.upload_raw_for_test(255, 1, 16, b"private artifact").await.unwrap_err(); assert!(error.to_string().contains("unknown artifact kind")); assert!(host.read_unique(&ArtifactSlot::Registration(1)).await?.is_none()); @@ -146,7 +160,8 @@ fn oversized_artifacts_are_rejected_before_allocation() { #[tokio::test] async fn oversized_upload_is_rejected_before_body_allocation() -> anyhow::Result<()> { let root = tempfile::tempdir()?; - let (host, ticket) = BoardNode::create_for_test(&root.path().join("host")).await?; + let (host, tickets) = BoardNode::create_for_test(&root.path().join("host")).await?; + let ticket = ticket_for(&tickets, 1); let client = BoardNode::join_for_test(&root.path().join("client"), ticket).await?; let error = client.upload_raw_for_test(1, 1, MAX_ARTIFACT_BYTES + 1, &[]).await.unwrap_err(); assert!(error.to_string().contains("exceeds")); @@ -160,7 +175,8 @@ async fn oversized_upload_is_rejected_before_body_allocation() -> anyhow::Result #[tokio::test] async fn invalid_upload_secret_is_rejected_before_storage() -> anyhow::Result<()> { let root = tempfile::tempdir()?; - let (host, mut ticket) = BoardNode::create_for_test(&root.path().join("host")).await?; + let (host, tickets) = BoardNode::create_for_test(&root.path().join("host")).await?; + let mut ticket = ticket_for(&tickets, 1); ticket.upload_secret[0] ^= 1; let client = BoardNode::join_for_test(&root.path().join("client"), ticket).await?; @@ -168,7 +184,7 @@ async fn invalid_upload_secret_is_rejected_before_storage() -> anyhow::Result<() .publish(&ArtifactSlot::Registration(1), b"signed registration") .await .unwrap_err(); - assert!(error.to_string().contains("invalid DKG board upload secret")); + assert!(error.to_string().contains("does not authorize this participant")); assert!(host.read_unique(&ArtifactSlot::Registration(1)).await?.is_none()); client.shutdown().await?; @@ -176,6 +192,40 @@ async fn invalid_upload_secret_is_rejected_before_storage() -> anyhow::Result<() Ok(()) } +#[tokio::test] +async fn participant_ticket_cannot_publish_another_participants_slot() -> anyhow::Result<()> { + let root = tempfile::tempdir()?; + let (host, tickets) = BoardNode::create_for_test(&root.path().join("host")).await?; + let first = + BoardNode::join_for_test(&root.path().join("first"), ticket_for(&tickets, 1)).await?; + + let error = first + .upload_raw_for_test( + 1, + 2, + u64::try_from(b"wrong registration".len())?, + b"wrong registration", + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("does not authorize this participant")); + assert!(host.read_unique(&ArtifactSlot::Registration(2)).await?.is_none()); + + let second = + BoardNode::join_for_test(&root.path().join("second"), ticket_for(&tickets, 2)).await?; + second.publish(&ArtifactSlot::Registration(2), b"signed registration").await?; + assert_eq!( + host.wait_unique(&ArtifactSlot::Registration(2), Duration::from_secs(10)) + .await?, + b"signed registration" + ); + + first.shutdown().await?; + second.shutdown().await?; + host.shutdown().await?; + Ok(()) +} + #[tokio::test] async fn invalid_download_metadata_is_rejected() -> anyhow::Result<()> { let root = tempfile::tempdir()?; diff --git a/bin/validator/src/commands/dkg/runner.rs b/bin/validator/src/commands/dkg/runner.rs index 9eadd90100..93d3100dce 100644 --- a/bin/validator/src/commands/dkg/runner.rs +++ b/bin/validator/src/commands/dkg/runner.rs @@ -96,9 +96,9 @@ pub(super) struct DkgBoardServeOptions { #[arg(long, value_name = "HEX")] epoch: String, - /// New private file that receives the board ticket for automation. - #[arg(long, value_name = "FILE")] - ticket_output: Option, + /// New private directory that receives one board ticket per genesis validator. + #[arg(long, value_name = "DIR")] + ticket_directory: Option, } /// Inputs for one validator's automatic storage key DKG ceremony runner. @@ -169,12 +169,26 @@ pub(super) async fn run_validator(options: DkgRunOptions) -> anyhow::Result<()> pub(super) async fn serve_board(options: DkgBoardServeOptions) -> anyhow::Result<()> { let genesis = read_trusted_genesis(&options.genesis)?; let participant_count = genesis.inner().header().validator_keys().as_keys().len(); - let (board, ticket) = BoardNode::create(&options.data_directory, participant_count).await?; - if let Some(path) = &options.ticket_output { - write_new_file(path, ticket.to_string().as_bytes(), true)?; - println!("storage key DKG board ticket written to {}", path.display()); + let (board, tickets) = BoardNode::create(&options.data_directory, participant_count).await?; + if let Some(path) = &options.ticket_directory { + publish_directory(path, |temporary| { + for ticket in &tickets { + write_new_file( + &temporary.join(format!("participant-{}.ticket", ticket.participant)), + ticket.to_string().as_bytes(), + true, + )?; + } + Ok(()) + })?; + println!("storage key DKG board tickets written to {}", path.display()); } else { - println!("storage key DKG board ticket:\n{ticket}"); + for ticket in &tickets { + println!( + "storage key DKG board ticket for participant {}:\n{ticket}", + ticket.participant + ); + } } let result = async { diff --git a/bin/validator/src/commands/dkg/tests.rs b/bin/validator/src/commands/dkg/tests.rs index bf46846bfa..35dbe66d6c 100644 --- a/bin/validator/src/commands/dkg/tests.rs +++ b/bin/validator/src/commands/dkg/tests.rs @@ -909,8 +909,8 @@ async fn iroh_ceremony_resumes_after_validator_restart() -> TestResult { let root = tempfile::tempdir()?; let genesis = write_genesis(root.path())?; let board_directory = root.path().join("board"); - let (board, ticket) = board::BoardNode::create_with_network(&board_directory, 3, false).await?; - let ticket = ticket.to_string(); + let (board, tickets) = + board::BoardNode::create_with_network(&board_directory, 3, false).await?; let timeout = Duration::from_mins(2); let restart_checkpoint_timeout = Duration::from_secs(10); let epoch = "66".repeat(32); @@ -945,6 +945,18 @@ async fn iroh_ceremony_resumes_after_validator_restart() -> TestResult { .cloned() .map(ValidatorSigner::new_local) .collect::>(); + let trusted_genesis = read_trusted_genesis(&genesis.path)?; + let validator_keys = trusted_genesis.inner().header().validator_keys().as_keys(); + let tickets = signers + .iter() + .map(|signer| { + let position = validator_keys + .iter() + .position(|key| *key == signer.public_key()) + .context("test signer is missing from genesis")?; + Ok(tickets[position].to_string()) + }) + .collect::>>()?; let work_directories = (1..=3) .map(|participant| root.path().join(format!("work-{participant}"))) .collect::>(); @@ -960,7 +972,7 @@ async fn iroh_ceremony_resumes_after_validator_restart() -> TestResult { timeout, ); let first = runner::run_validator_with_network::( - &ticket, + &tickets[0], &genesis.path, &signers[0], 2, @@ -971,7 +983,7 @@ async fn iroh_ceremony_resumes_after_validator_restart() -> TestResult { timeout, ); let second = runner::run_validator_with_network::( - &ticket, + &tickets[1], &genesis.path, &signers[1], 2, @@ -982,7 +994,7 @@ async fn iroh_ceremony_resumes_after_validator_restart() -> TestResult { timeout, ); let third = runner::run_validator_with_network::( - &ticket, + &tickets[2], &genesis.path, &signers[2], 2, diff --git a/compose/validator.yml b/compose/validator.yml index 659edada71..d53ab7d9d2 100644 --- a/compose/validator.yml +++ b/compose/validator.yml @@ -32,7 +32,7 @@ services: - | set -eu ROOT=/data/storage-key-dkg-check - TICKET="$${ROOT}/board-ticket" + TICKETS="$${ROOT}/board-tickets" GENESIS=/data/genesis/genesis.dat EPOCH="$${MIDEN_VALIDATOR_STORAGE_KEY_EPOCH}" rm -rf "$${ROOT}" @@ -43,7 +43,7 @@ services: --genesis "$${GENESIS}" \ --threshold 2 \ --epoch "$${EPOCH}" \ - --ticket-output "$${TICKET}" & + --ticket-directory "$${TICKETS}" & BOARD_PID=$$! RUNNER_PIDS="" cleanup() { @@ -58,7 +58,7 @@ services: } trap cleanup EXIT INT TERM - while [ ! -s "$${TICKET}" ]; do + while [ ! -s "$${TICKETS}/participant-1.ticket" ]; do kill -0 "$${BOARD_PID}" 2>/dev/null sleep 1 done @@ -66,7 +66,7 @@ services: PARTICIPANT="$$1" SIGNING_KEY="$$2" exec miden-validator dkg run \ - --board-file "$${TICKET}" \ + --board-file "$${TICKETS}/participant-$${PARTICIPANT}.ticket" \ --genesis "$${GENESIS}" \ --threshold 2 \ --epoch "$${EPOCH}" \ diff --git a/docs/external/src/network-operator/validator.md b/docs/external/src/network-operator/validator.md index 219b350f22..cc7368b139 100644 --- a/docs/external/src/network-operator/validator.md +++ b/docs/external/src/network-operator/validator.md @@ -48,14 +48,13 @@ miden-validator dkg board \ --genesis genesis.dat \ --threshold 2 \ --epoch <32-byte-hex-epoch> \ - --ticket-output storage-key-board-ticket + --ticket-directory storage-key-board-tickets ``` -The command writes one board ticket. It contains the board's Iroh address, a read-only document capability, and a bearer -secret for bounded uploads. It contains no private DKG share. Send the file to each genesis validator through the -authenticated bootstrap channel. Anyone with the ticket can read ceremony artifacts or stop the ceremony by uploading a -conflicting value, so do not publish it. Keep the board running until every validator reports ceremony completion, then -stop it with Ctrl-C. +The command writes one ticket per genesis validator. Each ticket contains the board's Iroh address, a shared read-only +document capability, and permission to upload only to that participant's slots. It contains no private DKG share. Send +each file to its validator through the authenticated bootstrap channel. Keep the board running until every validator +reports ceremony completion, then stop it with Ctrl-C. Each validator then runs the full ceremony with its own signing key and private work directory: @@ -70,9 +69,9 @@ miden-validator dkg run \ --output-directory storage-key ``` -Both commands can restart with the same data and work directories. Give `--ticket-output` a new path when restarting the -board because it will not overwrite a ticket file. Each validator collects the signed registrations and derives the -common files from its expected threshold and epoch. It rejects different board copies, checks every later artifact, +Both commands can restart with the same data and work directories. Give `--ticket-directory` a new path when restarting +the board because it will not overwrite a ticket directory. Each validator collects the signed registrations and derives +the common files from its expected threshold and epoch. It rejects different board copies, checks every later artifact, writes its own storage key bundle, and confirms that all validators produced the same public output. A board directory from an older format cannot be reopened; start that ceremony again in a new directory. From 658db1ae022b10ce03cb22aec5ec69d5ccd69555 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Wed, 5 Aug 2026 23:04:20 -0400 Subject: [PATCH 12/28] docs(validator): explain DKG ticket handling --- docs/external/src/network-operator/validator.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/external/src/network-operator/validator.md b/docs/external/src/network-operator/validator.md index cc7368b139..42f2b93029 100644 --- a/docs/external/src/network-operator/validator.md +++ b/docs/external/src/network-operator/validator.md @@ -53,7 +53,8 @@ miden-validator dkg board \ The command writes one ticket per genesis validator. Each ticket contains the board's Iroh address, a shared read-only document capability, and permission to upload only to that participant's slots. It contains no private DKG share. Send -each file to its validator through the authenticated bootstrap channel. Keep the board running until every validator +each file to its validator through the authenticated bootstrap channel, and keep it private. A ticket holder can stop +the ceremony by uploading a conflicting value to that participant's slot. Keep the board running until every validator reports ceremony completion, then stop it with Ctrl-C. Each validator then runs the full ceremony with its own signing key and private work directory: From 0927599965476077b973b26b0fb5b549ca94f76f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Thu, 6 Aug 2026 12:08:07 -0400 Subject: [PATCH 13/28] refactor(validator): trim Iroh DKG exchange --- bin/validator/src/commands/dkg/board.rs | 18 +- bin/validator/src/commands/dkg/runner.rs | 203 +++--------------- .../src/commands/dkg/runner/tests.rs | 29 --- .../network-operator/bootstrap-and-genesis.md | 14 +- .../src/network-operator/validator.md | 13 +- 5 files changed, 44 insertions(+), 233 deletions(-) delete mode 100644 bin/validator/src/commands/dkg/runner/tests.rs diff --git a/bin/validator/src/commands/dkg/board.rs b/bin/validator/src/commands/dkg/board.rs index 3fa411813b..897b184777 100644 --- a/bin/validator/src/commands/dkg/board.rs +++ b/bin/validator/src/commands/dkg/board.rs @@ -46,7 +46,7 @@ const MAX_UPLOAD_ERROR_BYTES: usize = 1024; const UPLOAD_TIMEOUT: Duration = Duration::from_secs(30); const PEER_READY_TIMEOUT: Duration = Duration::from_secs(30); const COMMON_ARTIFACT_COUNT: usize = 3; -const ARTIFACTS_PER_PARTICIPANT: usize = 6; +const ARTIFACTS_PER_PARTICIPANT: usize = 4; const MAX_VALUES_PER_SLOT: usize = 2; /// The board address and read capability, paired with one participant's upload permission. @@ -105,9 +105,7 @@ pub(super) enum ArtifactSlot { ContextConfig, DecryptionDealing(u32), ContextDealing(u32), - Transcript(u32), TranscriptAcceptance(u32), - FinalConfirmation(u32), } impl ArtifactSlot { @@ -121,13 +119,9 @@ impl ArtifactSlot { format!("dealing/{participant}/decryption/") }, Self::ContextDealing(participant) => format!("dealing/{participant}/context/"), - Self::Transcript(participant) => format!("acceptance/{participant}/transcript/"), Self::TranscriptAcceptance(participant) => { format!("acceptance/{participant}/signature/") }, - Self::FinalConfirmation(participant) => { - format!("final/{participant}/confirmation/") - }, } } @@ -140,9 +134,7 @@ impl ArtifactSlot { Self::Registration(participant) => (1, *participant), Self::DecryptionDealing(participant) => (2, *participant), Self::ContextDealing(participant) => (3, *participant), - Self::Transcript(participant) => (4, *participant), - Self::TranscriptAcceptance(participant) => (5, *participant), - Self::FinalConfirmation(participant) => (6, *participant), + Self::TranscriptAcceptance(participant) => (4, *participant), Self::Manifest | Self::DecryptionConfig | Self::ContextConfig => { anyhow::bail!("only the DKG board may publish common ceremony artifacts") }, @@ -156,9 +148,7 @@ impl ArtifactSlot { 1 => Ok(Self::Registration(participant)), 2 => Ok(Self::DecryptionDealing(participant)), 3 => Ok(Self::ContextDealing(participant)), - 4 => Ok(Self::Transcript(participant)), - 5 => Ok(Self::TranscriptAcceptance(participant)), - 6 => Ok(Self::FinalConfirmation(participant)), + 4 => Ok(Self::TranscriptAcceptance(participant)), _ => anyhow::bail!("DKG board upload contains an unknown artifact kind"), } } @@ -954,9 +944,7 @@ fn allowed_slot_prefixes(participant_count: usize) -> anyhow::Result ArtifactSlot::Registration(participant).prefix(), ArtifactSlot::DecryptionDealing(participant).prefix(), ArtifactSlot::ContextDealing(participant).prefix(), - ArtifactSlot::Transcript(participant).prefix(), ArtifactSlot::TranscriptAcceptance(participant).prefix(), - ArtifactSlot::FinalConfirmation(participant).prefix(), ]); } Ok(prefixes) diff --git a/bin/validator/src/commands/dkg/runner.rs b/bin/validator/src/commands/dkg/runner.rs index 93d3100dce..6611fe7d7a 100644 --- a/bin/validator/src/commands/dkg/runner.rs +++ b/bin/validator/src/commands/dkg/runner.rs @@ -13,36 +13,25 @@ use super::{ CONTEXT_DEALING_FILE, DECRYPTION_CONFIG_FILE, DECRYPTION_DEALING_FILE, - Deserialize, - Digest, - EPOCH_FILE, GoldenGroup, IDENTITY_SECRET_FILE, MANIFEST_FILE, OsRng, PRIVATE_STATE_FILE, - PUBLIC_KEY_SET_FILE, REGISTRATION_FILE, Registration, - Rpo256, - SETUP_CONTEXT_FILE, SecpSecqBackend, Serializable, - Serialize, - Sha256, StorageGroup, TRANSCRIPT_ACCEPTANCE_FILE, TRANSCRIPT_FILE, ValidatorSigningKey, WireMessage, - Word, Zeroizing, accept_transcript, deal, decode_fixed_hex, decode_identity_secret, - decode_validator_public_key, - decode_validator_signature, finalize, generate_identity, prepare, @@ -64,19 +53,6 @@ const ACCEPTANCE_DIRECTORY: &str = "acceptance"; const PUBLIC_ACCEPTANCES_DIRECTORY: &str = "public-acceptances"; const BOARD_DIRECTORY: &str = "board"; const CEREMONY_WAIT_TIMEOUT: Duration = Duration::from_hours(24); -const PUBLIC_OUTPUT_DIGEST_DOMAIN: &[u8] = b"miden-storage-key-dkg-public-output-v1"; -const FINAL_CONFIRMATION_VERSION: &str = "miden-storage-key-dkg-final-confirmation-v1"; -const FINAL_CONFIRMATION_SIGNATURE_DOMAIN: &[u8] = - b"miden-storage-key-dkg-final-confirmation-signature-v1"; - -#[derive(Deserialize, Serialize)] -struct FinalConfirmation { - version: String, - validator_public_key: String, - public_output_sha256: String, - validator_signature: String, -} - /// Inputs for the shared storage key DKG board. #[derive(clap::Args)] pub(super) struct DkgBoardServeOptions { @@ -98,19 +74,15 @@ pub(super) struct DkgBoardServeOptions { /// New private directory that receives one board ticket per genesis validator. #[arg(long, value_name = "DIR")] - ticket_directory: Option, + ticket_directory: PathBuf, } /// Inputs for one validator's automatic storage key DKG ceremony runner. #[derive(clap::Args)] pub(super) struct DkgRunOptions { - /// Read and upload ticket printed by `dkg board`. - #[arg(long, value_name = "BOARD_TICKET", required_unless_present = "board_file")] - board: Option, - /// Private file containing the read and upload board ticket. - #[arg(long, value_name = "FILE", conflicts_with = "board")] - board_file: Option, + #[arg(long, value_name = "FILE")] + board_file: PathBuf, /// Trusted genesis block for the network. #[arg(long, value_name = "FILE")] @@ -139,17 +111,12 @@ pub(super) struct DkgRunOptions { /// Runs one validator through every DKG phase. pub(super) async fn run_validator(options: DkgRunOptions) -> anyhow::Result<()> { - let board = if let Some(ticket) = options.board { - ticket - } else { - let path = options.board_file.context("a board ticket or board ticket file is required")?; - fs_err::read_to_string(&path) - .with_context(|| { - format!("failed to read storage key DKG board ticket {}", path.display()) - })? - .trim() - .to_owned() - }; + let board = fs_err::read_to_string(&options.board_file) + .with_context(|| { + format!("failed to read storage key DKG board ticket {}", options.board_file.display()) + })? + .trim() + .to_owned(); ensure!(!board.is_empty(), "storage key DKG board ticket must not be empty"); let signer = options.signing_key.into_signer().await?; run_validator_with_network::( @@ -170,26 +137,20 @@ pub(super) async fn serve_board(options: DkgBoardServeOptions) -> anyhow::Result let genesis = read_trusted_genesis(&options.genesis)?; let participant_count = genesis.inner().header().validator_keys().as_keys().len(); let (board, tickets) = BoardNode::create(&options.data_directory, participant_count).await?; - if let Some(path) = &options.ticket_directory { - publish_directory(path, |temporary| { - for ticket in &tickets { - write_new_file( - &temporary.join(format!("participant-{}.ticket", ticket.participant)), - ticket.to_string().as_bytes(), - true, - )?; - } - Ok(()) - })?; - println!("storage key DKG board tickets written to {}", path.display()); - } else { + publish_directory(&options.ticket_directory, |temporary| { for ticket in &tickets { - println!( - "storage key DKG board ticket for participant {}:\n{ticket}", - ticket.participant - ); + write_new_file( + &temporary.join(format!("participant-{}.ticket", ticket.participant)), + ticket.to_string().as_bytes(), + true, + )?; } - } + Ok(()) + })?; + println!( + "storage key DKG board tickets written to {}", + options.ticket_directory.display() + ); let result = async { coordinate_common_files( @@ -464,12 +425,6 @@ where ) .await?; } - publish_named_file( - board, - &ArtifactSlot::Transcript(participant.get()), - &acceptance_directory.join(TRANSCRIPT_FILE), - ) - .await?; publish_named_file( board, &ArtifactSlot::TranscriptAcceptance(participant.get()), @@ -477,12 +432,10 @@ where ) .await?; - let (transcript, acceptances) = wait_for_acceptances(board, participant_count, timeout).await?; + let acceptances = wait_for_acceptances(board, participant_count, timeout).await?; let public_acceptances_directory = work_directory.join(PUBLIC_ACCEPTANCES_DIRECTORY); - let mut acceptance_files = vec![(TRANSCRIPT_FILE.to_owned(), transcript)]; - acceptance_files.extend(acceptances); - materialize_or_compare(&public_acceptances_directory, &acceptance_files)?; - let transcript_path = public_acceptances_directory.join(TRANSCRIPT_FILE); + materialize_or_compare(&public_acceptances_directory, &acceptances)?; + let transcript_path = acceptance_directory.join(TRANSCRIPT_FILE); let transcript_acceptances = participant_files( &public_acceptances_directory, "transcript-acceptance", @@ -510,23 +463,6 @@ where output_directory, )?; - let digest = public_output_digest(output_directory)?; - let confirmation = sign_final_confirmation(signer, ceremony.genesis_commitment, digest).await?; - board - .publish(&ArtifactSlot::FinalConfirmation(participant.get()), &confirmation) - .await?; - for position in 0..participant_count { - let other = participant_at(position)?; - let other_confirmation = board - .wait_unique(&ArtifactSlot::FinalConfirmation(other.get()), timeout) - .await?; - validate_final_confirmation( - &other_confirmation, - &ceremony.manifest.participants[position].validator_public_key, - ceremony.genesis_commitment, - digest, - )?; - } println!("storage key DKG completed for participant {}.", participant.get()); Ok(()) } @@ -625,18 +561,10 @@ async fn wait_for_acceptances( board: &BoardNode, participant_count: usize, timeout: Duration, -) -> anyhow::Result<(Vec, Vec<(String, Vec)>)> { - let mut transcript = None; +) -> anyhow::Result)>> { let mut acceptances = Vec::with_capacity(participant_count); for position in 0..participant_count { let participant = participant_at(position)?; - let candidate = - board.wait_unique(&ArtifactSlot::Transcript(participant.get()), timeout).await?; - if let Some(expected) = &transcript { - ensure!(candidate == *expected, "validators accepted different DKG transcripts"); - } else { - transcript = Some(candidate); - } acceptances.push(( format!("transcript-acceptance-{}.toml", participant.get()), board @@ -644,7 +572,7 @@ async fn wait_for_acceptances( .await?, )); } - Ok((transcript.context("ceremony has no participants")?, acceptances)) + Ok(acceptances) } async fn publish_named_file( @@ -689,82 +617,3 @@ fn participant_files( }) .collect() } - -fn public_output_digest(bundle_directory: &Path) -> anyhow::Result<[u8; 32]> { - let mut digest = Sha256::new(); - digest.update(PUBLIC_OUTPUT_DIGEST_DOMAIN); - for name in [EPOCH_FILE, SETUP_CONTEXT_FILE, PUBLIC_KEY_SET_FILE] { - let bytes = fs_err::read(bundle_directory.join(name))?; - digest.update(u64::try_from(bytes.len())?.to_be_bytes()); - digest.update(bytes); - } - Ok(digest.finalize().into()) -} - -async fn sign_final_confirmation( - signer: &ValidatorSigner, - genesis_commitment: Word, - public_output_sha256: [u8; 32], -) -> anyhow::Result> { - let validator_public_key = signer.public_key(); - let signature = signer - .sign_commitment(final_confirmation_commitment(genesis_commitment, public_output_sha256)) - .await - .context("failed to sign DKG final confirmation")?; - let confirmation = FinalConfirmation { - version: FINAL_CONFIRMATION_VERSION.to_owned(), - validator_public_key: hex::encode(validator_public_key.to_bytes()), - public_output_sha256: hex::encode(public_output_sha256), - validator_signature: hex::encode(signature.to_bytes()), - }; - toml::to_string_pretty(&confirmation) - .context("failed to encode DKG final confirmation") - .map(String::into_bytes) -} - -fn validate_final_confirmation( - bytes: &[u8], - expected_validator_public_key: &str, - genesis_commitment: Word, - expected_public_output_sha256: [u8; 32], -) -> anyhow::Result<()> { - let confirmation: FinalConfirmation = - toml::from_slice(bytes).context("invalid DKG final confirmation")?; - ensure!( - confirmation.version == FINAL_CONFIRMATION_VERSION, - "unsupported DKG final confirmation version" - ); - ensure!( - confirmation.validator_public_key == expected_validator_public_key, - "DKG final confirmation belongs to another validator" - ); - ensure!( - confirmation.public_output_sha256 == hex::encode(expected_public_output_sha256), - "validators produced different storage key outputs" - ); - let validator_public_key = decode_validator_public_key(&confirmation.validator_public_key)?; - let signature = decode_validator_signature(&confirmation.validator_signature)?; - ensure!( - signature.verify( - final_confirmation_commitment(genesis_commitment, expected_public_output_sha256), - &validator_public_key, - ), - "invalid DKG final confirmation signature" - ); - Ok(()) -} - -fn final_confirmation_commitment(genesis_commitment: Word, public_output_sha256: [u8; 32]) -> Word { - let mut bytes = Vec::with_capacity( - FINAL_CONFIRMATION_SIGNATURE_DOMAIN.len() - + Word::SERIALIZED_SIZE - + public_output_sha256.len(), - ); - bytes.extend_from_slice(FINAL_CONFIRMATION_SIGNATURE_DOMAIN); - bytes.extend_from_slice(&genesis_commitment.to_bytes()); - bytes.extend_from_slice(&public_output_sha256); - Rpo256::hash(&bytes) -} - -#[cfg(test)] -mod tests; diff --git a/bin/validator/src/commands/dkg/runner/tests.rs b/bin/validator/src/commands/dkg/runner/tests.rs deleted file mode 100644 index 4363bc9960..0000000000 --- a/bin/validator/src/commands/dkg/runner/tests.rs +++ /dev/null @@ -1,29 +0,0 @@ -use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey; - -use super::*; - -#[tokio::test] -/// Guards the final agreement step against copying one validator's signature into another slot. -async fn final_confirmation_cannot_be_copied_between_validator_slots() -> anyhow::Result<()> { - let first = ValidatorSigner::new_local(SigningKey::new()); - let second = ValidatorSigner::new_local(SigningKey::new()); - let genesis_commitment = Word::default(); - let digest = [7; 32]; - let confirmation = sign_final_confirmation(&first, genesis_commitment, digest).await?; - - validate_final_confirmation( - &confirmation, - &hex::encode(first.public_key().to_bytes()), - genesis_commitment, - digest, - )?; - let error = validate_final_confirmation( - &confirmation, - &hex::encode(second.public_key().to_bytes()), - genesis_commitment, - digest, - ) - .unwrap_err(); - assert!(error.to_string().contains("another validator")); - Ok(()) -} diff --git a/docs/external/src/network-operator/bootstrap-and-genesis.md b/docs/external/src/network-operator/bootstrap-and-genesis.md index 471486ea3d..f78878bc91 100644 --- a/docs/external/src/network-operator/bootstrap-and-genesis.md +++ b/docs/external/src/network-operator/bootstrap-and-genesis.md @@ -149,15 +149,15 @@ The key each validator operator starts their validator with must match the publi ## Storage Key Ceremony -After genesis is built, every listed validator must join one offline DKG ceremony. The ceremony creates the shared -public storage key and one distinct secret share per validator. No coordinator can derive those shares. +After genesis is built, every listed validator must join one DKG ceremony before validator startup. The ceremony creates +the shared public storage key and one distinct secret share per validator. No coordinator can derive those shares. For the normal flow, one operator starts the durable Iroh board and sends its private ticket to each validator through -the authenticated bootstrap channel. Each validator runs the full ceremony with the signing key committed in genesis. -The board carries only public ceremony artifacts. Validator signatures authenticate registrations and transcript -checkpoints. Each validator keeps its identity, private DKG state, and final secret share local. The manual file -commands remain available for recovery. The DKG and database bootstrap may run in either order, but both must finish -before the validator starts. +an authenticated and confidential bootstrap channel. Each validator runs the full ceremony with the signing key +committed in genesis. The board carries only public ceremony artifacts. Validator signatures authenticate registrations +and transcript checkpoints. Each validator keeps its identity, private DKG state, and final secret share local. The +manual file commands remain available for recovery. The DKG and database bootstrap may run in either order, but both +must finish before the validator starts. All listed validators must contribute to the ceremony even when the recovery threshold is lower. If any participant drops out or any transcript differs, discard the incomplete ceremony and start a new one with fresh identities and diff --git a/docs/external/src/network-operator/validator.md b/docs/external/src/network-operator/validator.md index 42f2b93029..9a4f078d7e 100644 --- a/docs/external/src/network-operator/validator.md +++ b/docs/external/src/network-operator/validator.md @@ -53,9 +53,12 @@ miden-validator dkg board \ The command writes one ticket per genesis validator. Each ticket contains the board's Iroh address, a shared read-only document capability, and permission to upload only to that participant's slots. It contains no private DKG share. Send -each file to its validator through the authenticated bootstrap channel, and keep it private. A ticket holder can stop -the ceremony by uploading a conflicting value to that participant's slot. Keep the board running until every validator -reports ceremony completion, then stop it with Ctrl-C. +each file through an authenticated and confidential bootstrap channel. A ticket holder can stop the ceremony by +uploading a conflicting value to that participant's slot. Keep the board running until every validator reports ceremony +completion, then stop it with Ctrl-C. + +The current command uses Iroh's public discovery and relay services. The board and validators need outbound network +access. This is a setup flow run before validator startup, not an isolated network transport. Each validator then runs the full ceremony with its own signing key and private work directory: @@ -73,8 +76,8 @@ miden-validator dkg run \ Both commands can restart with the same data and work directories. Give `--ticket-directory` a new path when restarting the board because it will not overwrite a ticket directory. Each validator collects the signed registrations and derives the common files from its expected threshold and epoch. It rejects different board copies, checks every later artifact, -writes its own storage key bundle, and confirms that all validators produced the same public output. A board directory -from an older format cannot be reopened; start that ceremony again in a new directory. +and validates its storage key bundle against the transcript accepted by every validator. A board directory from an older +format cannot be reopened; start that ceremony again in a new directory. The commands below provide a manual recovery path. First, each operator creates a DKG identity for the agreed storage-key epoch and sends `registration.toml` to the coordinator. The registration proves ownership of the DKG From 4277da8a9b5b0aea7e5fba3cfe86f904c8b054e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Thu, 6 Aug 2026 12:12:20 -0400 Subject: [PATCH 14/28] fix(validator): version reduced DKG board schema --- bin/validator/src/commands/dkg/board.rs | 6 ++--- bin/validator/src/commands/dkg/board/tests.rs | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/bin/validator/src/commands/dkg/board.rs b/bin/validator/src/commands/dkg/board.rs index 897b184777..a69a03f352 100644 --- a/bin/validator/src/commands/dkg/board.rs +++ b/bin/validator/src/commands/dkg/board.rs @@ -34,10 +34,10 @@ use super::{decode_fixed_hex, publish_directory, write_new_file}; const ENDPOINT_SECRET_FILE: &str = "endpoint-secret.hex"; const DOCUMENT_ID_FILE: &str = "document-id.hex"; const BOARD_FORMAT_FILE: &str = "board-format"; -const BOARD_FORMAT: &[u8] = b"participant-upload-v2\n"; +const BOARD_FORMAT: &[u8] = b"participant-upload-v3\n"; const UPLOAD_SECRETS_DIRECTORY: &str = "upload-secrets"; -const BOARD_TICKET_PREFIX: &str = "miden-storage-key-dkg-board-v2"; -const UPLOAD_ALPN: &[u8] = b"/miden/storage-key-dkg-board-upload/2"; +const BOARD_TICKET_PREFIX: &str = "miden-storage-key-dkg-board-v3"; +const UPLOAD_ALPN: &[u8] = b"/miden/storage-key-dkg-board-upload/3"; const UPLOAD_HEADER_BYTES: usize = 32 + 1 + 4 + 8; const UPLOAD_RESPONSE_BYTES: usize = 1 + 32; const MAX_ARTIFACT_BYTES: u64 = 64 * 1024 * 1024; diff --git a/bin/validator/src/commands/dkg/board/tests.rs b/bin/validator/src/commands/dkg/board/tests.rs index b069e64fff..dd7ff8abd7 100644 --- a/bin/validator/src/commands/dkg/board/tests.rs +++ b/bin/validator/src/commands/dkg/board/tests.rs @@ -132,6 +132,29 @@ async fn unmarked_board_is_not_reopened_even_with_upload_secrets() -> anyhow::Re Ok(()) } +#[tokio::test] +async fn previous_board_format_is_not_reopened() -> anyhow::Result<()> { + let root = tempfile::tempdir()?; + let data_directory = root.path().join("host"); + let (host, _) = BoardNode::create_for_test(&data_directory).await?; + host.shutdown().await?; + fs_err::write(data_directory.join(BOARD_FORMAT_FILE), b"participant-upload-v2\n")?; + + let error = BoardNode::create_for_test(&data_directory) + .await + .err() + .context("old board format unexpectedly reopened")?; + assert!(error.to_string().contains("unsupported DKG board format")); + Ok(()) +} + +#[test] +fn previous_board_ticket_version_is_rejected() { + let error = BoardTicket::from_str("miden-storage-key-dkg-board-v2:1:00:invalid") + .expect_err("old board ticket unexpectedly parsed"); + assert!(error.to_string().contains("ticket prefix")); +} + #[tokio::test] async fn unknown_participants_and_artifact_kinds_are_rejected_before_body_allocation() -> anyhow::Result<()> { From 19555f87758801be4091add282e060bc5d24d2c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Mon, 10 Aug 2026 08:21:51 -0400 Subject: [PATCH 15/28] fix(validator): use Golden 0.2 dealer messages in Iroh runner --- bin/validator/src/commands/dkg/runner.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/bin/validator/src/commands/dkg/runner.rs b/bin/validator/src/commands/dkg/runner.rs index 6611fe7d7a..a2e5159d5d 100644 --- a/bin/validator/src/commands/dkg/runner.rs +++ b/bin/validator/src/commands/dkg/runner.rs @@ -26,7 +26,6 @@ use super::{ TRANSCRIPT_ACCEPTANCE_FILE, TRANSCRIPT_FILE, ValidatorSigningKey, - WireMessage, Zeroizing, accept_transcript, deal, @@ -265,7 +264,6 @@ pub(super) async fn run_validator_with_network( ) -> anyhow::Result<()> where B: EvrfProofBackend, - B::Proof: WireMessage, { fs_err::create_dir_all(work_directory).with_context(|| { format!("failed to create DKG work directory {}", work_directory.display()) @@ -319,7 +317,6 @@ async fn run_validator_on_board( ) -> anyhow::Result<()> where B: EvrfProofBackend, - B::Proof: WireMessage, { let identity_directory = work_directory.join(IDENTITY_DIRECTORY); publish_named_file( From 1dded214332d7f40c69b40c079d1cbd13f3a4dd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Fri, 14 Aug 2026 10:47:17 -0400 Subject: [PATCH 16/28] fix(validator): publish DKG board metadata atomically --- bin/validator/src/commands/dkg/board.rs | 127 +++++++++--------- bin/validator/src/commands/dkg/board/tests.rs | 31 ++++- 2 files changed, 91 insertions(+), 67 deletions(-) diff --git a/bin/validator/src/commands/dkg/board.rs b/bin/validator/src/commands/dkg/board.rs index a69a03f352..736606f739 100644 --- a/bin/validator/src/commands/dkg/board.rs +++ b/bin/validator/src/commands/dkg/board.rs @@ -32,9 +32,10 @@ use iroh_gossip::net::Gossip; use super::{decode_fixed_hex, publish_directory, write_new_file}; const ENDPOINT_SECRET_FILE: &str = "endpoint-secret.hex"; +const BOARD_METADATA_DIRECTORY: &str = "board-meta"; const DOCUMENT_ID_FILE: &str = "document-id.hex"; const BOARD_FORMAT_FILE: &str = "board-format"; -const BOARD_FORMAT: &[u8] = b"participant-upload-v3\n"; +const BOARD_FORMAT: &[u8] = b"participant-upload-v4\n"; const UPLOAD_SECRETS_DIRECTORY: &str = "upload-secrets"; const BOARD_TICKET_PREFIX: &str = "miden-storage-key-dkg-board-v3"; const UPLOAD_ALPN: &[u8] = b"/miden/storage-key-dkg-board-upload/3"; @@ -235,32 +236,30 @@ impl BoardNode { use_network_services: bool, ) -> anyhow::Result<(Self, Vec)> { let runtime = BoardRuntime::start(data_directory, use_network_services).await?; - let document_id_path = data_directory.join(DOCUMENT_ID_FILE); - let existing_document = document_id_path.exists(); - let document = if existing_document { - require_current_board_format(data_directory)?; + let metadata_directory = data_directory.join(BOARD_METADATA_DIRECTORY); + let (document, upload_secrets) = if metadata_directory.exists() { + require_current_board_format(&metadata_directory)?; + let document_id_path = metadata_directory.join(DOCUMENT_ID_FILE); let id = fs_err::read_to_string(&document_id_path).with_context(|| { format!("failed to read Iroh document ID {}", document_id_path.display()) })?; let id = decode_fixed_hex::<32>(id.trim(), "Iroh document ID")?; - runtime + let document = runtime .docs .open(iroh_docs::NamespaceId::from(&id)) .await .context("failed to open Iroh document")? - .context("persisted Iroh document is missing")? + .context("persisted Iroh document is missing")?; + let upload_secrets = load_upload_secrets(&metadata_directory, participant_count)?; + (document, upload_secrets) } else { let document = runtime.docs.create().await.context("failed to create Iroh document")?; - write_new_file( - &document_id_path, - hex::encode(document.id().to_bytes()).as_bytes(), - true, - )?; - write_new_file(&data_directory.join(BOARD_FORMAT_FILE), BOARD_FORMAT, true)?; - document + let upload_secrets = (0..participant_count) + .map(|_| SecretKey::generate().to_bytes()) + .collect::>(); + publish_board_metadata(&metadata_directory, &document, &upload_secrets)?; + (document, upload_secrets) }; - let upload_secrets = - load_or_create_upload_secrets(data_directory, participant_count, !existing_document)?; document .set_download_policy(DownloadPolicy::NothingExcept(Vec::new())) .await @@ -997,60 +996,64 @@ fn load_or_create_endpoint_secret(data_directory: &Path) -> anyhow::Result anyhow::Result> { - let path = data_directory.join(UPLOAD_SECRETS_DIRECTORY); - if path.exists() { - let entry_count = fs_err::read_dir(&path) - .with_context(|| format!("failed to read DKG board upload secrets {}", path.display()))? - .collect::, _>>()? - .len(); - ensure!( - entry_count == participant_count, - "DKG board upload secret count does not match the participant count" - ); - return (1..=participant_count) - .map(|participant| { - let secret_path = path.join(format!("participant-{participant}.hex")); - let bytes = fs_err::read_to_string(&secret_path).with_context(|| { - format!("failed to read DKG board upload secret {}", secret_path.display()) - })?; - decode_fixed_hex::<32>(bytes.trim(), "DKG board upload secret") - }) - .collect(); - } - ensure!( - allow_create, - "this DKG board predates participant-scoped uploads; start a new ceremony in a new data directory" - ); - - let secrets = (0..participant_count) - .map(|_| SecretKey::generate().to_bytes()) - .collect::>(); - publish_directory(&path, |temporary| { - for (position, secret) in secrets.iter().enumerate() { +fn publish_board_metadata( + path: &Path, + document: &Doc, + upload_secrets: &[[u8; 32]], +) -> anyhow::Result<()> { + publish_directory(path, |temporary| { + write_new_file( + &temporary.join(DOCUMENT_ID_FILE), + hex::encode(document.id().to_bytes()).as_bytes(), + true, + )?; + write_new_file(&temporary.join(BOARD_FORMAT_FILE), BOARD_FORMAT, true)?; + let upload_secrets_directory = temporary.join(UPLOAD_SECRETS_DIRECTORY); + fs_err::create_dir(&upload_secrets_directory).with_context(|| { + format!( + "failed to create DKG board upload secrets {}", + upload_secrets_directory.display() + ) + })?; + for (position, secret) in upload_secrets.iter().enumerate() { write_new_file( - &temporary.join(format!("participant-{}.hex", position + 1)), + &upload_secrets_directory.join(format!("participant-{}.hex", position + 1)), hex::encode(secret).as_bytes(), true, )?; } Ok(()) - })?; - Ok(secrets) + }) } -fn require_current_board_format(data_directory: &Path) -> anyhow::Result<()> { - let path = data_directory.join(BOARD_FORMAT_FILE); - let format = fs_err::read(&path).with_context(|| { - format!( - "this DKG board predates participant-scoped uploads; start a new ceremony in a new data directory ({})", - path.display() - ) - })?; +fn load_upload_secrets( + metadata_directory: &Path, + participant_count: usize, +) -> anyhow::Result> { + let path = metadata_directory.join(UPLOAD_SECRETS_DIRECTORY); + let entry_count = fs_err::read_dir(&path) + .with_context(|| format!("failed to read DKG board upload secrets {}", path.display()))? + .collect::, _>>()? + .len(); + ensure!( + entry_count == participant_count, + "DKG board upload secret count does not match the participant count" + ); + (1..=participant_count) + .map(|participant| { + let secret_path = path.join(format!("participant-{participant}.hex")); + let bytes = fs_err::read_to_string(&secret_path).with_context(|| { + format!("failed to read DKG board upload secret {}", secret_path.display()) + })?; + decode_fixed_hex::<32>(bytes.trim(), "DKG board upload secret") + }) + .collect() +} + +fn require_current_board_format(metadata_directory: &Path) -> anyhow::Result<()> { + let path = metadata_directory.join(BOARD_FORMAT_FILE); + let format = fs_err::read(&path) + .with_context(|| format!("failed to read DKG board format {}", path.display()))?; ensure!( format == BOARD_FORMAT, "unsupported DKG board format; start a new ceremony in a new data directory" diff --git a/bin/validator/src/commands/dkg/board/tests.rs b/bin/validator/src/commands/dkg/board/tests.rs index dd7ff8abd7..bf442ce1b2 100644 --- a/bin/validator/src/commands/dkg/board/tests.rs +++ b/bin/validator/src/commands/dkg/board/tests.rs @@ -117,18 +117,36 @@ async fn board_reopens_the_same_document_after_restart() -> anyhow::Result<()> { } #[tokio::test] -async fn unmarked_board_is_not_reopened_even_with_upload_secrets() -> anyhow::Result<()> { +async fn board_metadata_is_published_as_one_directory() -> anyhow::Result<()> { + let root = tempfile::tempdir()?; + let data_directory = root.path().join("host"); + let (host, _) = BoardNode::create_for_test(&data_directory).await?; + + let metadata_directory = data_directory.join(BOARD_METADATA_DIRECTORY); + assert!(metadata_directory.join(DOCUMENT_ID_FILE).is_file()); + assert!(metadata_directory.join(BOARD_FORMAT_FILE).is_file()); + assert!(metadata_directory.join(UPLOAD_SECRETS_DIRECTORY).is_dir()); + assert!(!data_directory.join(DOCUMENT_ID_FILE).exists()); + assert!(!data_directory.join(BOARD_FORMAT_FILE).exists()); + assert!(!data_directory.join(UPLOAD_SECRETS_DIRECTORY).exists()); + + host.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn incomplete_board_metadata_is_rejected() -> anyhow::Result<()> { let root = tempfile::tempdir()?; let data_directory = root.path().join("host"); let (host, _) = BoardNode::create_for_test(&data_directory).await?; host.shutdown().await?; - fs_err::remove_file(data_directory.join(BOARD_FORMAT_FILE))?; + fs_err::remove_file(data_directory.join(BOARD_METADATA_DIRECTORY).join(BOARD_FORMAT_FILE))?; let error = BoardNode::create_for_test(&data_directory) .await .err() - .context("legacy board unexpectedly reopened")?; - assert!(error.to_string().contains("predates participant-scoped uploads")); + .context("incomplete board metadata unexpectedly reopened")?; + assert!(error.to_string().contains("failed to read DKG board format")); Ok(()) } @@ -138,7 +156,10 @@ async fn previous_board_format_is_not_reopened() -> anyhow::Result<()> { let data_directory = root.path().join("host"); let (host, _) = BoardNode::create_for_test(&data_directory).await?; host.shutdown().await?; - fs_err::write(data_directory.join(BOARD_FORMAT_FILE), b"participant-upload-v2\n")?; + fs_err::write( + data_directory.join(BOARD_METADATA_DIRECTORY).join(BOARD_FORMAT_FILE), + b"participant-upload-v3\n", + )?; let error = BoardNode::create_for_test(&data_directory) .await From e21e75878b77cdf1a4516324f235bcf3e90afa20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Fri, 14 Aug 2026 10:50:46 -0400 Subject: [PATCH 17/28] fix(validator): reject legacy DKG board state --- bin/validator/src/commands/dkg/board.rs | 6 +++++ bin/validator/src/commands/dkg/board/tests.rs | 24 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/bin/validator/src/commands/dkg/board.rs b/bin/validator/src/commands/dkg/board.rs index 736606f739..0cdc6e154f 100644 --- a/bin/validator/src/commands/dkg/board.rs +++ b/bin/validator/src/commands/dkg/board.rs @@ -253,6 +253,12 @@ impl BoardNode { let upload_secrets = load_upload_secrets(&metadata_directory, participant_count)?; (document, upload_secrets) } else { + ensure!( + !data_directory.join(DOCUMENT_ID_FILE).exists() + && !data_directory.join(BOARD_FORMAT_FILE).exists() + && !data_directory.join(UPLOAD_SECRETS_DIRECTORY).exists(), + "unsupported DKG board format; start a new ceremony in a new data directory" + ); let document = runtime.docs.create().await.context("failed to create Iroh document")?; let upload_secrets = (0..participant_count) .map(|_| SecretKey::generate().to_bytes()) diff --git a/bin/validator/src/commands/dkg/board/tests.rs b/bin/validator/src/commands/dkg/board/tests.rs index bf442ce1b2..6dbd2b1562 100644 --- a/bin/validator/src/commands/dkg/board/tests.rs +++ b/bin/validator/src/commands/dkg/board/tests.rs @@ -150,6 +150,30 @@ async fn incomplete_board_metadata_is_rejected() -> anyhow::Result<()> { Ok(()) } +#[tokio::test] +async fn legacy_board_metadata_is_rejected() -> anyhow::Result<()> { + let root = tempfile::tempdir()?; + let data_directory = root.path().join("host"); + let upload_secrets_directory = data_directory.join(UPLOAD_SECRETS_DIRECTORY); + fs_err::create_dir_all(&upload_secrets_directory)?; + fs_err::write(data_directory.join(DOCUMENT_ID_FILE), hex::encode([0; 32]))?; + fs_err::write(data_directory.join(BOARD_FORMAT_FILE), b"participant-upload-v3\n")?; + for participant in 1..=3 { + fs_err::write( + upload_secrets_directory.join(format!("participant-{participant}.hex")), + hex::encode([0; 32]), + )?; + } + + let error = BoardNode::create_for_test(&data_directory) + .await + .err() + .context("legacy board metadata unexpectedly reopened")?; + assert!(error.to_string().contains("unsupported DKG board format")); + assert!(!data_directory.join(BOARD_METADATA_DIRECTORY).exists()); + Ok(()) +} + #[tokio::test] async fn previous_board_format_is_not_reopened() -> anyhow::Result<()> { let root = tempfile::tempdir()?; From 2101652dc14eb04699b098686cdfd51ab9df69b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Fri, 14 Aug 2026 10:55:23 -0400 Subject: [PATCH 18/28] fix(validator): persist Iroh endpoint key atomically --- bin/validator/src/commands/dkg/board.rs | 22 +++++++++++++++---- bin/validator/src/commands/dkg/board/tests.rs | 19 ++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/bin/validator/src/commands/dkg/board.rs b/bin/validator/src/commands/dkg/board.rs index 0cdc6e154f..688892cc21 100644 --- a/bin/validator/src/commands/dkg/board.rs +++ b/bin/validator/src/commands/dkg/board.rs @@ -8,6 +8,7 @@ use std::collections::BTreeMap; use std::fmt; +use std::io::Write; use std::path::Path; use std::str::FromStr; use std::sync::Arc; @@ -991,14 +992,27 @@ async fn inspect_document_metadata( fn load_or_create_endpoint_secret(data_directory: &Path) -> anyhow::Result { let path = data_directory.join(ENDPOINT_SECRET_FILE); if path.exists() { - let bytes = fs_err::read_to_string(&path) + let encoded = fs_err::read_to_string(&path) .with_context(|| format!("failed to read Iroh endpoint secret {}", path.display()))?; - let bytes = decode_fixed_hex::<32>(bytes.trim(), "Iroh endpoint secret")?; - return Ok(SecretKey::from_bytes(&bytes)); + return SecretKey::from_str(encoded.trim()).context("invalid Iroh endpoint secret"); } let secret = SecretKey::generate(); - write_new_file(&path, hex::encode(secret.to_bytes()).as_bytes(), true)?; + let mut temporary = tempfile::Builder::new() + .prefix(".endpoint-secret-") + .tempfile_in(data_directory) + .context("failed to create temporary Iroh endpoint secret")?; + temporary + .write_all(hex::encode(secret.to_bytes()).as_bytes()) + .context("failed to write temporary Iroh endpoint secret")?; + temporary + .as_file() + .sync_all() + .context("failed to sync temporary Iroh endpoint secret")?; + temporary + .persist_noclobber(&path) + .map_err(|error| error.error) + .with_context(|| format!("failed to publish Iroh endpoint secret {}", path.display()))?; Ok(secret) } diff --git a/bin/validator/src/commands/dkg/board/tests.rs b/bin/validator/src/commands/dkg/board/tests.rs index 6dbd2b1562..7b3201d0ee 100644 --- a/bin/validator/src/commands/dkg/board/tests.rs +++ b/bin/validator/src/commands/dkg/board/tests.rs @@ -62,6 +62,25 @@ fn ticket_for(tickets: &[BoardTicket], participant: u32) -> BoardTicket { .clone() } +#[test] +fn endpoint_secret_is_persisted_privately() -> anyhow::Result<()> { + let data_directory = tempfile::tempdir()?; + let first = load_or_create_endpoint_secret(data_directory.path())?; + let second = load_or_create_endpoint_secret(data_directory.path())?; + assert_eq!(first.to_bytes(), second.to_bytes()); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let mode = fs_err::metadata(data_directory.path().join(ENDPOINT_SECRET_FILE))? + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + } + Ok(()) +} + #[tokio::test] async fn artifact_syncs_between_board_nodes() -> anyhow::Result<()> { let root = tempfile::tempdir()?; From 37e617422b6ec33cbd1e2adc8bf9f12f4d3b2814 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Fri, 14 Aug 2026 10:59:25 -0400 Subject: [PATCH 19/28] fix(validator): sync DKG state directories --- bin/validator/src/commands/dkg.rs | 14 ++++++++++++++ bin/validator/src/commands/dkg/board.rs | 3 ++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/bin/validator/src/commands/dkg.rs b/bin/validator/src/commands/dkg.rs index 7194c00115..773cff11e7 100644 --- a/bin/validator/src/commands/dkg.rs +++ b/bin/validator/src/commands/dkg.rs @@ -1767,6 +1767,20 @@ fn publish_directory( write(temporary.path())?; fs_err::rename(temporary.path(), output_directory) .context("failed to publish output directory")?; + sync_directory(parent)?; + Ok(()) +} + +#[cfg(unix)] +fn sync_directory(directory: &Path) -> anyhow::Result<()> { + std::fs::File::open(directory) + .with_context(|| format!("failed to open directory {}", directory.display()))? + .sync_all() + .with_context(|| format!("failed to sync directory {}", directory.display())) +} + +#[cfg(not(unix))] +fn sync_directory(_directory: &Path) -> anyhow::Result<()> { Ok(()) } diff --git a/bin/validator/src/commands/dkg/board.rs b/bin/validator/src/commands/dkg/board.rs index 688892cc21..2671739d41 100644 --- a/bin/validator/src/commands/dkg/board.rs +++ b/bin/validator/src/commands/dkg/board.rs @@ -30,7 +30,7 @@ use iroh_docs::protocol::Docs; use iroh_docs::store::{DownloadPolicy, Query}; use iroh_gossip::net::Gossip; -use super::{decode_fixed_hex, publish_directory, write_new_file}; +use super::{decode_fixed_hex, publish_directory, sync_directory, write_new_file}; const ENDPOINT_SECRET_FILE: &str = "endpoint-secret.hex"; const BOARD_METADATA_DIRECTORY: &str = "board-meta"; @@ -1013,6 +1013,7 @@ fn load_or_create_endpoint_secret(data_directory: &Path) -> anyhow::Result Date: Fri, 14 Aug 2026 11:07:09 -0400 Subject: [PATCH 20/28] fix(validator): durably publish DKG directories --- bin/validator/src/commands/dkg.rs | 25 +++++++++++++++++++++++++ bin/validator/src/commands/dkg/board.rs | 25 +++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/bin/validator/src/commands/dkg.rs b/bin/validator/src/commands/dkg.rs index 773cff11e7..f8bba50fe1 100644 --- a/bin/validator/src/commands/dkg.rs +++ b/bin/validator/src/commands/dkg.rs @@ -1765,6 +1765,7 @@ fn publish_directory( .tempdir_in(parent) .context("failed to create temporary output directory")?; write(temporary.path())?; + sync_directory_tree(temporary.path())?; fs_err::rename(temporary.path(), output_directory) .context("failed to publish output directory")?; sync_directory(parent)?; @@ -1784,6 +1785,30 @@ fn sync_directory(_directory: &Path) -> anyhow::Result<()> { Ok(()) } +#[cfg(unix)] +fn sync_directory_tree(directory: &Path) -> anyhow::Result<()> { + for entry in fs_err::read_dir(directory) + .with_context(|| format!("failed to read directory {}", directory.display()))? + { + let entry = entry?; + let file_type = entry.file_type()?; + if file_type.is_dir() { + sync_directory_tree(&entry.path())?; + } else if file_type.is_file() { + std::fs::File::open(entry.path()) + .with_context(|| format!("failed to open file {}", entry.path().display()))? + .sync_all() + .with_context(|| format!("failed to sync file {}", entry.path().display()))?; + } + } + sync_directory(directory) +} + +#[cfg(not(unix))] +fn sync_directory_tree(_directory: &Path) -> anyhow::Result<()> { + Ok(()) +} + /// Creates one ceremony file without replacing an existing file. fn write_new_file(path: &Path, bytes: &[u8], private: bool) -> anyhow::Result<()> { let mut options = std::fs::OpenOptions::new(); diff --git a/bin/validator/src/commands/dkg/board.rs b/bin/validator/src/commands/dkg/board.rs index 2671739d41..8c9204b5c8 100644 --- a/bin/validator/src/commands/dkg/board.rs +++ b/bin/validator/src/commands/dkg/board.rs @@ -764,10 +764,23 @@ fn secrets_match(candidate: &[u8], expected: &[u8; 32]) -> bool { impl BoardRuntime { async fn start(data_directory: &Path, use_network_services: bool) -> anyhow::Result { + let absolute_data_directory = if data_directory.is_absolute() { + data_directory.to_owned() + } else { + std::env::current_dir() + .context("failed to determine current directory")? + .join(data_directory) + }; + let existing_ancestor = absolute_data_directory + .ancestors() + .find(|ancestor| ancestor.exists()) + .context("Iroh data directory has no existing ancestor")? + .to_owned(); fs_err::create_dir_all(data_directory).with_context(|| { format!("failed to create Iroh data directory {}", data_directory.display()) })?; let secret = load_or_create_endpoint_secret(data_directory)?; + sync_created_directory_parents(&absolute_data_directory, &existing_ancestor)?; let builder = if use_network_services { Endpoint::builder(presets::N0) } else { @@ -1017,6 +1030,18 @@ fn load_or_create_endpoint_secret(data_directory: &Path) -> anyhow::Result anyhow::Result<()> { + let mut current = directory; + while current != existing_ancestor { + current = current.parent().context("Iroh data directory escaped its ancestor")?; + sync_directory(current)?; + } + Ok(()) +} + fn publish_board_metadata( path: &Path, document: &Doc, From 27e0ef5684013f42cfa954eee3129cb6683f676d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Fri, 14 Aug 2026 11:12:52 -0400 Subject: [PATCH 21/28] fix(validator): persist DKG parent directories --- bin/validator/src/commands/dkg.rs | 25 ++++++++++++++++- bin/validator/src/commands/dkg/board.rs | 35 ++++++------------------ bin/validator/src/commands/dkg/runner.rs | 3 +- 3 files changed, 34 insertions(+), 29 deletions(-) diff --git a/bin/validator/src/commands/dkg.rs b/bin/validator/src/commands/dkg.rs index f8bba50fe1..9d9a17c9a0 100644 --- a/bin/validator/src/commands/dkg.rs +++ b/bin/validator/src/commands/dkg.rs @@ -1759,7 +1759,7 @@ fn publish_directory( ) -> anyhow::Result<()> { ensure!(!output_directory.exists(), "output directory already exists"); let parent = output_directory.parent().unwrap_or_else(|| Path::new(".")); - fs_err::create_dir_all(parent).context("failed to create output parent directory")?; + durably_create_directory_all(parent).context("failed to create output parent directory")?; let temporary = tempfile::Builder::new() .prefix(".storage-key-dkg-") .tempdir_in(parent) @@ -1785,6 +1785,29 @@ fn sync_directory(_directory: &Path) -> anyhow::Result<()> { Ok(()) } +fn durably_create_directory_all(directory: &Path) -> anyhow::Result<()> { + let absolute_directory = if directory.is_absolute() { + directory.to_owned() + } else { + std::env::current_dir() + .context("failed to determine current directory")? + .join(directory) + }; + let existing_ancestor = absolute_directory + .ancestors() + .find(|ancestor| ancestor.exists()) + .context("directory has no existing ancestor")? + .to_owned(); + fs_err::create_dir_all(directory)?; + + let mut current = absolute_directory.as_path(); + while current != existing_ancestor { + current = current.parent().context("directory escaped its existing ancestor")?; + sync_directory(current)?; + } + Ok(()) +} + #[cfg(unix)] fn sync_directory_tree(directory: &Path) -> anyhow::Result<()> { for entry in fs_err::read_dir(directory) diff --git a/bin/validator/src/commands/dkg/board.rs b/bin/validator/src/commands/dkg/board.rs index 8c9204b5c8..5d412c8746 100644 --- a/bin/validator/src/commands/dkg/board.rs +++ b/bin/validator/src/commands/dkg/board.rs @@ -30,7 +30,13 @@ use iroh_docs::protocol::Docs; use iroh_docs::store::{DownloadPolicy, Query}; use iroh_gossip::net::Gossip; -use super::{decode_fixed_hex, publish_directory, sync_directory, write_new_file}; +use super::{ + decode_fixed_hex, + durably_create_directory_all, + publish_directory, + sync_directory, + write_new_file, +}; const ENDPOINT_SECRET_FILE: &str = "endpoint-secret.hex"; const BOARD_METADATA_DIRECTORY: &str = "board-meta"; @@ -764,23 +770,10 @@ fn secrets_match(candidate: &[u8], expected: &[u8; 32]) -> bool { impl BoardRuntime { async fn start(data_directory: &Path, use_network_services: bool) -> anyhow::Result { - let absolute_data_directory = if data_directory.is_absolute() { - data_directory.to_owned() - } else { - std::env::current_dir() - .context("failed to determine current directory")? - .join(data_directory) - }; - let existing_ancestor = absolute_data_directory - .ancestors() - .find(|ancestor| ancestor.exists()) - .context("Iroh data directory has no existing ancestor")? - .to_owned(); - fs_err::create_dir_all(data_directory).with_context(|| { + durably_create_directory_all(data_directory).with_context(|| { format!("failed to create Iroh data directory {}", data_directory.display()) })?; let secret = load_or_create_endpoint_secret(data_directory)?; - sync_created_directory_parents(&absolute_data_directory, &existing_ancestor)?; let builder = if use_network_services { Endpoint::builder(presets::N0) } else { @@ -1030,18 +1023,6 @@ fn load_or_create_endpoint_secret(data_directory: &Path) -> anyhow::Result anyhow::Result<()> { - let mut current = directory; - while current != existing_ancestor { - current = current.parent().context("Iroh data directory escaped its ancestor")?; - sync_directory(current)?; - } - Ok(()) -} - fn publish_board_metadata( path: &Path, document: &Doc, diff --git a/bin/validator/src/commands/dkg/runner.rs b/bin/validator/src/commands/dkg/runner.rs index a2e5159d5d..dc0012af38 100644 --- a/bin/validator/src/commands/dkg/runner.rs +++ b/bin/validator/src/commands/dkg/runner.rs @@ -31,6 +31,7 @@ use super::{ deal, decode_fixed_hex, decode_identity_secret, + durably_create_directory_all, finalize, generate_identity, prepare, @@ -265,7 +266,7 @@ pub(super) async fn run_validator_with_network( where B: EvrfProofBackend, { - fs_err::create_dir_all(work_directory).with_context(|| { + durably_create_directory_all(work_directory).with_context(|| { format!("failed to create DKG work directory {}", work_directory.display()) })?; let genesis = read_trusted_genesis(genesis_path)?; From 005325f56086be2310e585f1e908250b0fb658c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Fri, 14 Aug 2026 11:19:50 -0400 Subject: [PATCH 22/28] refactor(validator): use canonical Iroh board tickets --- Cargo.lock | 2 + Cargo.toml | 2 + bin/validator/Cargo.toml | 2 + bin/validator/src/commands/dkg/board.rs | 67 +++++++++++-------- bin/validator/src/commands/dkg/board/tests.rs | 30 ++++++++- 5 files changed, 73 insertions(+), 30 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 915a813b85..5a8bd20e07 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5710,6 +5710,7 @@ dependencies = [ "iroh-blobs", "iroh-docs", "iroh-gossip", + "iroh-tickets", "miden-node-db", "miden-node-proto", "miden-node-proto-build", @@ -5719,6 +5720,7 @@ dependencies = [ "miden-protocol", "miden-testing", "miden-tx", + "postcard", "rand 0.10.2", "rand_chacha 0.3.1", "rand_core 0.6.4", diff --git a/Cargo.toml b/Cargo.toml index 62c08d1a81..ef7d67343b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -98,11 +98,13 @@ iroh = { default-features = false, features = ["tls-ring"], versio iroh-blobs = { default-features = false, features = ["fs-store"], version = "=0.103.0" } iroh-docs = { default-features = false, features = ["fs-store"], version = "=0.101.0" } iroh-gossip = { default-features = false, features = ["net"], version = "=0.101.0" } +iroh-tickets = { version = "=1.0.0" } itertools = { version = "0.14" } libsqlite3-sys = { features = ["bundled"], version = "0.35" } lru = { default-features = false, version = "0.18" } miette = { version = "7.6" } opentelemetry = { version = "0.31" } +postcard = { features = ["use-std"], version = "1.1.3" } pretty_assertions = { version = "1.4" } proc-macro2 = { version = "1.0" } # prost and protox are from different authors and are _not_ released in diff --git a/bin/validator/Cargo.toml b/bin/validator/Cargo.toml index a079a0bae8..6297c3b8d2 100644 --- a/bin/validator/Cargo.toml +++ b/bin/validator/Cargo.toml @@ -39,6 +39,7 @@ iroh = { workspace = true } iroh-blobs = { workspace = true } iroh-docs = { workspace = true } iroh-gossip = { workspace = true } +iroh-tickets = { workspace = true } miden-node-db = { workspace = true } miden-node-proto = { workspace = true } miden-node-proto-build = { features = ["internal"], workspace = true } @@ -47,6 +48,7 @@ miden-node-tracing = { workspace = true } miden-node-utils = { features = ["testing"], workspace = true } miden-protocol = { workspace = true } miden-tx = { features = ["concurrent"], workspace = true } +postcard = { workspace = true } rand_core_06 = { workspace = true } serde = { workspace = true } sha2 = { workspace = true } diff --git a/bin/validator/src/commands/dkg/board.rs b/bin/validator/src/commands/dkg/board.rs index 5d412c8746..b364b76f7d 100644 --- a/bin/validator/src/commands/dkg/board.rs +++ b/bin/validator/src/commands/dkg/board.rs @@ -29,6 +29,8 @@ use iroh_docs::engine::LiveEvent; use iroh_docs::protocol::Docs; use iroh_docs::store::{DownloadPolicy, Query}; use iroh_gossip::net::Gossip; +use iroh_tickets::{ParseError, Ticket}; +use serde::{Deserialize, Serialize}; use super::{ decode_fixed_hex, @@ -44,7 +46,6 @@ const DOCUMENT_ID_FILE: &str = "document-id.hex"; const BOARD_FORMAT_FILE: &str = "board-format"; const BOARD_FORMAT: &[u8] = b"participant-upload-v4\n"; const UPLOAD_SECRETS_DIRECTORY: &str = "upload-secrets"; -const BOARD_TICKET_PREFIX: &str = "miden-storage-key-dkg-board-v3"; const UPLOAD_ALPN: &[u8] = b"/miden/storage-key-dkg-board-upload/3"; const UPLOAD_HEADER_BYTES: usize = 32 + 1 + 4 + 8; const UPLOAD_RESPONSE_BYTES: usize = 1 + 32; @@ -61,46 +62,58 @@ const MAX_VALUES_PER_SLOT: usize = 2; /// /// This credential contains no DKG private material. Its holder can read public ceremony artifacts /// and upload only to the named participant's slots. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub(super) struct BoardTicket { document: DocTicket, pub(super) participant: u32, upload_secret: [u8; 32], } +#[derive(Deserialize, Serialize)] +enum BoardTicketWireFormat { + Variant0(BoardTicket), +} + +impl Ticket for BoardTicket { + const KIND: &'static str = "miden-storage-key-dkg-board"; + + fn encode_bytes(&self) -> Vec { + postcard::to_stdvec(&BoardTicketWireFormat::Variant0(self.clone())) + .expect("postcard serialization failed") + } + + fn decode_bytes(bytes: &[u8]) -> Result { + let BoardTicketWireFormat::Variant0(ticket) = postcard::from_bytes(bytes)?; + if ticket.participant == 0 { + return Err(ParseError::verification_failed( + "DKG board participant index must be nonzero", + )); + } + if !matches!(ticket.document.capability, iroh_docs::Capability::Read(_)) { + return Err(ParseError::verification_failed( + "DKG board document ticket must be read-only", + )); + } + if ticket.document.nodes.is_empty() { + return Err(ParseError::verification_failed( + "DKG board document addressing info cannot be empty", + )); + } + Ok(ticket) + } +} + impl fmt::Display for BoardTicket { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - formatter, - "{BOARD_TICKET_PREFIX}:{}:{}:{}", - self.participant, - hex::encode(self.upload_secret), - self.document - ) + formatter.write_str(&Ticket::encode_string(self)) } } impl FromStr for BoardTicket { - type Err = anyhow::Error; + type Err = ParseError; fn from_str(value: &str) -> Result { - let mut parts = value.splitn(4, ':'); - ensure!(parts.next() == Some(BOARD_TICKET_PREFIX), "invalid DKG board ticket prefix"); - let participant = parts - .next() - .context("DKG board ticket is missing its participant index")? - .parse::() - .context("invalid DKG board participant index")?; - ensure!(participant > 0, "DKG board participant index must be nonzero"); - let secret = parts.next().context("DKG board ticket is missing its upload secret")?; - let document = parts.next().context("DKG board ticket is missing its document ticket")?; - let upload_secret = decode_fixed_hex::<32>(secret, "DKG board upload secret")?; - let document = DocTicket::from_str(document).context("invalid Iroh document ticket")?; - ensure!( - matches!(document.capability, iroh_docs::Capability::Read(_)), - "DKG board document ticket must be read-only" - ); - Ok(Self { document, participant, upload_secret }) + Ticket::decode_string(value) } } diff --git a/bin/validator/src/commands/dkg/board/tests.rs b/bin/validator/src/commands/dkg/board/tests.rs index 7b3201d0ee..961fbe541b 100644 --- a/bin/validator/src/commands/dkg/board/tests.rs +++ b/bin/validator/src/commands/dkg/board/tests.rs @@ -213,10 +213,34 @@ async fn previous_board_format_is_not_reopened() -> anyhow::Result<()> { } #[test] -fn previous_board_ticket_version_is_rejected() { - let error = BoardTicket::from_str("miden-storage-key-dkg-board-v2:1:00:invalid") +fn legacy_board_ticket_is_rejected() { + BoardTicket::from_str("miden-storage-key-dkg-board-v3:1:00:invalid") .expect_err("old board ticket unexpectedly parsed"); - assert!(error.to_string().contains("ticket prefix")); +} + +#[tokio::test] +async fn board_ticket_round_trips_and_validates_fields() -> anyhow::Result<()> { + let root = tempfile::tempdir()?; + let (host, mut tickets) = BoardNode::create_for_test(root.path()).await?; + let ticket = tickets.remove(0); + let encoded = ticket.to_string(); + let decoded = BoardTicket::from_str(&encoded)?; + assert_eq!(decoded.to_string(), encoded); + + let mut invalid = ticket.clone(); + invalid.participant = 0; + let error = BoardTicket::from_str(&invalid.to_string()) + .expect_err("zero participant ticket unexpectedly parsed"); + assert!(error.to_string().contains("must be nonzero")); + + invalid = ticket; + invalid.document.nodes.clear(); + let error = BoardTicket::from_str(&invalid.to_string()) + .expect_err("ticket without addressing info unexpectedly parsed"); + assert!(error.to_string().contains("addressing info cannot be empty")); + + host.shutdown().await?; + Ok(()) } #[tokio::test] From fc776b386cb7d8eb97aa17800c3cf06659de2e99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Fri, 14 Aug 2026 11:23:40 -0400 Subject: [PATCH 23/28] fix(validator): parse DKG board tickets before setup --- bin/validator/src/commands/dkg/board.rs | 5 ++--- bin/validator/src/commands/dkg/board/tests.rs | 2 +- bin/validator/src/commands/dkg/runner.rs | 7 ++++--- bin/validator/src/commands/dkg/tests.rs | 8 ++++---- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/bin/validator/src/commands/dkg/board.rs b/bin/validator/src/commands/dkg/board.rs index b364b76f7d..bf3462ac0c 100644 --- a/bin/validator/src/commands/dkg/board.rs +++ b/bin/validator/src/commands/dkg/board.rs @@ -340,7 +340,7 @@ impl BoardNode { /// Joins an existing ceremony document through its read and upload ticket. pub(super) async fn join( data_directory: &Path, - ticket: &str, + ticket: BoardTicket, participant_count: usize, ) -> anyhow::Result { Self::join_with_network(data_directory, ticket, participant_count, true).await @@ -348,11 +348,10 @@ impl BoardNode { pub(super) async fn join_with_network( data_directory: &Path, - ticket: &str, + ticket: BoardTicket, participant_count: usize, use_network_services: bool, ) -> anyhow::Result { - let ticket = BoardTicket::from_str(ticket)?; let runtime = BoardRuntime::start(data_directory, use_network_services).await?; let BoardTicket { document, participant, upload_secret } = ticket; ensure!( diff --git a/bin/validator/src/commands/dkg/board/tests.rs b/bin/validator/src/commands/dkg/board/tests.rs index 961fbe541b..51e559ec55 100644 --- a/bin/validator/src/commands/dkg/board/tests.rs +++ b/bin/validator/src/commands/dkg/board/tests.rs @@ -6,7 +6,7 @@ impl BoardNode { } async fn join_for_test(data_directory: &Path, ticket: BoardTicket) -> anyhow::Result { - Self::join_with_network(data_directory, &ticket.to_string(), 3, false).await + Self::join_with_network(data_directory, ticket, 3, false).await } fn local_writer_for_test(&self) -> &BoardWriter { diff --git a/bin/validator/src/commands/dkg/runner.rs b/bin/validator/src/commands/dkg/runner.rs index dc0012af38..209256e8ff 100644 --- a/bin/validator/src/commands/dkg/runner.rs +++ b/bin/validator/src/commands/dkg/runner.rs @@ -7,7 +7,7 @@ use golden_core::{EvrfProofBackend, ParticipantIndex}; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::PublicKey; use miden_validator::ValidatorSigner; -use super::board::{ArtifactSlot, BoardNode}; +use super::board::{ArtifactSlot, BoardNode, BoardTicket}; use super::{ CONTEXT_CONFIG_FILE, CONTEXT_DEALING_FILE, @@ -118,9 +118,10 @@ pub(super) async fn run_validator(options: DkgRunOptions) -> anyhow::Result<()> .trim() .to_owned(); ensure!(!board.is_empty(), "storage key DKG board ticket must not be empty"); + let board = board.parse::().context("invalid storage key DKG board ticket")?; let signer = options.signing_key.into_signer().await?; run_validator_with_network::( - &board, + board, &options.genesis, &signer, options.threshold.get(), @@ -253,7 +254,7 @@ async fn wait_for_registrations( reason = "the inputs separate ceremony policy, durable paths, and test networking" )] pub(super) async fn run_validator_with_network( - ticket: &str, + ticket: BoardTicket, genesis_path: &Path, signer: &ValidatorSigner, threshold: usize, diff --git a/bin/validator/src/commands/dkg/tests.rs b/bin/validator/src/commands/dkg/tests.rs index 35dbe66d6c..87d0f99891 100644 --- a/bin/validator/src/commands/dkg/tests.rs +++ b/bin/validator/src/commands/dkg/tests.rs @@ -954,7 +954,7 @@ async fn iroh_ceremony_resumes_after_validator_restart() -> TestResult { .iter() .position(|key| *key == signer.public_key()) .context("test signer is missing from genesis")?; - Ok(tickets[position].to_string()) + Ok(tickets[position].clone()) }) .collect::>>()?; let work_directories = (1..=3) @@ -972,7 +972,7 @@ async fn iroh_ceremony_resumes_after_validator_restart() -> TestResult { timeout, ); let first = runner::run_validator_with_network::( - &tickets[0], + tickets[0].clone(), &genesis.path, &signers[0], 2, @@ -983,7 +983,7 @@ async fn iroh_ceremony_resumes_after_validator_restart() -> TestResult { timeout, ); let second = runner::run_validator_with_network::( - &tickets[1], + tickets[1].clone(), &genesis.path, &signers[1], 2, @@ -994,7 +994,7 @@ async fn iroh_ceremony_resumes_after_validator_restart() -> TestResult { timeout, ); let third = runner::run_validator_with_network::( - &tickets[2], + tickets[2].clone(), &genesis.path, &signers[2], 2, From 5d1b88f5d901ee89fed07677cf39bf5f741e2caa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Mon, 17 Aug 2026 13:40:11 -0400 Subject: [PATCH 24/28] chore: normalize lockfile after rebase --- Cargo.lock | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5a8bd20e07..7834498b25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1544,7 +1544,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" dependencies = [ - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -1966,7 +1966,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c54e03a951783e8b327515db3f2a2fd0e3bed362a96b066f341ce66ed49b4ead" dependencies = [ "data-encoding", - "syn 3.0.3", + "syn 1.0.109", ] [[package]] @@ -3157,7 +3157,7 @@ dependencies = [ "jni 0.22.4", "rand 0.10.2", "rustls 0.23.43", - "thiserror 2.0.19", + "thiserror 2.0.20", "tinyvec", "tokio", "tokio-rustls 0.26.4", @@ -3179,7 +3179,7 @@ dependencies = [ "prefix-trie", "rand 0.10.2", "ring", - "thiserror 2.0.19", + "thiserror 2.0.20", "tinyvec", "tracing", "url", @@ -3207,7 +3207,7 @@ dependencies = [ "rustls 0.23.43", "smallvec", "system-configuration", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-rustls 0.26.4", "tracing", @@ -3811,7 +3811,7 @@ dependencies = [ "serde-error", "strum", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-stream", "tokio-util", @@ -5966,7 +5966,7 @@ dependencies = [ "log", "netlink-packet-core", "netlink-sys", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -6056,7 +6056,7 @@ dependencies = [ "rustc-hash", "rustls 0.23.43", "socket2 0.6.5", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-stream", "tracing", @@ -6084,7 +6084,7 @@ dependencies = [ "rustls-pki-types", "slab", "sorted-index-buffer", - "thiserror 2.0.19", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -10071,7 +10071,7 @@ dependencies = [ "futures", "log", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", "windows", "windows-core", ] @@ -10107,7 +10107,7 @@ dependencies = [ "pharos", "rustc_version 0.4.1", "send_wrapper", - "thiserror 2.0.19", + "thiserror 2.0.20", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", From 82653c7d8b3763bf8c7301e7346be2a069d00cd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Mon, 17 Aug 2026 14:12:11 -0400 Subject: [PATCH 25/28] refactor: move validator command tests to a separate file --- bin/validator/src/commands/mod.rs | 491 +--------------------------- bin/validator/src/commands/tests.rs | 477 +++++++++++++++++++++++++++ 2 files changed, 478 insertions(+), 490 deletions(-) create mode 100644 bin/validator/src/commands/tests.rs diff --git a/bin/validator/src/commands/mod.rs b/bin/validator/src/commands/mod.rs index 4713bf6e8f..6e80242189 100644 --- a/bin/validator/src/commands/mod.rs +++ b/bin/validator/src/commands/mod.rs @@ -516,493 +516,4 @@ impl ValidatorSigningKey { // ================================================================================================ #[cfg(test)] -mod tests { - use std::collections::BTreeMap; - use std::path::Path; - - use golden_core::{GoldenGroup, GoldenScalar, ParticipantIndex, SessionId}; - use golden_ehtdh1::wire::{from_wire_bytes, to_wire_bytes}; - use golden_ehtdh1::{ - DecryptionShare, - PublicKeySet, - PublicShare, - SecretShare, - SetupContext, - derive_context_session_id, - }; - use golden_halo2curves::golden_group::{Secp256k1GoldenGroup, Secp256k1Scalar}; - use miden_protocol::Word; - use miden_protocol::account::auth::AuthScheme; - use miden_protocol::transaction::{TransactionId, TransactionInputs}; - use miden_protocol::utils::serde::{Deserializable, Serializable}; - use miden_testing::{Auth, MockChainBuilder}; - use rand_chacha_03::ChaCha20Rng; - use rand_chacha_03::rand_core::SeedableRng; - - use super::*; - - type TestStorageGroup = Secp256k1GoldenGroup; - - const TEST_SIGNING_KEY_HEX: &str = - "0101010101010101010101010101010101010101010101010101010101010101"; - const TEST_ENCRYPTION_KEY_HEX: &str = - "0202020202020202020202020202020202020202020202020202020202020202"; - - const BASE_START_ARGS: [&str; 8] = [ - "miden-validator", - "start", - "--listen", - "127.0.0.1:50101", - "--data-directory", - "/tmp/validator-data", - "--signing-key.hex", - TEST_SIGNING_KEY_HEX, - ]; - const ENCRYPTION_KEY_ARGS: [&str; 2] = ["--encryption-key.hex", TEST_ENCRYPTION_KEY_HEX]; - const STORAGE_KEY_ARGS: [&str; 8] = [ - "--storage-key.epoch", - "0909090909090909090909090909090909090909090909090909090909090909", - "--storage-key.setup-context", - "/tmp/setup.bin", - "--storage-key.public-key-set", - "/tmp/public.bin", - "--storage-key.secret-share", - "/tmp/secret.bin", - ]; - - fn parse_start(extra: &[&str]) -> Result { - ValidatorCommand::try_parse_from( - BASE_START_ARGS - .iter() - .copied() - .chain(extra.iter().copied()) - .chain(STORAGE_KEY_ARGS.iter().copied()), - ) - } - - fn test_operator_keys(epoch: u8, setup_marker: u8) -> Vec { - let participants = [1, 2, 3].map(|value| ParticipantIndex::new(value).unwrap()); - let decryption_secret = Secp256k1Scalar::from_u64(11).unwrap(); - let decryption_coefficient = Secp256k1Scalar::from_u64(7).unwrap(); - let context_coefficient = Secp256k1Scalar::from_u64(13).unwrap(); - let mut public_shares = BTreeMap::new(); - let secret_shares = participants.map(|participant| { - let point = participant.to_scalar::().unwrap(); - let decryption = decryption_secret.add(&decryption_coefficient.mul(&point)); - let context = context_coefficient.mul(&point); - public_shares.insert( - participant, - PublicShare { - decryption: TestStorageGroup::mul_generator(&decryption), - context: TestStorageGroup::mul_generator(&context), - }, - ); - SecretShare { participant, decryption, context } - }); - let public_key_set = PublicKeySet::new( - 2, - TestStorageGroup::mul_generator(&decryption_secret), - public_shares, - ) - .unwrap(); - let decryption_session_id = SessionId([2; 32]); - let setup_context = SetupContext { - backend_id: TestStorageGroup::BACKEND_ID.to_owned(), - threshold: 2, - registry_root: [1; 32], - participants: participants.to_vec(), - decryption_session_id, - context_session_id: derive_context_session_id(decryption_session_id), - decryption_transcript_root: [setup_marker; 32], - context_transcript_root: [4; 32], - epoch: [epoch; 32], - }; - secret_shares - .into_iter() - .map(|secret_share| { - GoldenOperatorKey::new( - StorageKeyEpoch::new([epoch; 32]), - setup_context.clone(), - public_key_set.clone(), - secret_share, - ) - .unwrap() - }) - .collect() - } - - fn transaction_inputs() -> TransactionInputs { - let mut builder = MockChainBuilder::new(); - let account = builder - .add_existing_wallet(Auth::BasicAuth { - auth_scheme: AuthScheme::Falcon512Poseidon2, - }) - .unwrap(); - builder.build().unwrap().get_transaction_inputs(&account, &[], &[]).unwrap() - } - - fn issue_error(record: &Path, output: &Path, operator_key: &GoldenOperatorKey) -> String { - let error = issue_private_record_share::issue(record, output, operator_key).unwrap_err(); - format!("{error:#}") - } - - fn assert_issue_error( - record: &Path, - output: &Path, - operator_key: &GoldenOperatorKey, - expected: &str, - ) { - let error = issue_error(record, output, operator_key); - assert!(error.contains(expected), "expected {expected:?} in {error:?}"); - } - - fn issue_share_file(record: &Path, output: &Path, operator_key: &GoldenOperatorKey) -> Vec { - issue_private_record_share::issue(record, output, operator_key).unwrap(); - fs_err::read(output).unwrap() - } - - async fn store_private_record( - writer: &miden_validator::db::ValidatorDbWriter, - record: miden_validator::StoredPrivateRecord, - ) { - writer.insert_validated_private_transaction(record).await.unwrap(); - } - - async fn export_record_file( - data_directory: &Path, - encoded_transaction_id: &str, - encoded_validator_id: &str, - output: &Path, - ) { - export_private_record::export(PrivateRecordExportOptions { - data_directory: data_directory.to_path_buf(), - transaction_id: encoded_transaction_id.to_owned(), - validator_id: encoded_validator_id.to_owned(), - output: output.to_path_buf(), - }) - .await - .unwrap(); - } - - const BASE_GENESIS_ARGS: [&str; 6] = [ - "miden-validator", - "genesis", - "--genesis-block-directory", - "/tmp/genesis", - "--accounts-directory", - "/tmp/accounts", - ]; - - fn parse_genesis(extra: &[&str]) -> Result { - ValidatorCommand::try_parse_from( - BASE_GENESIS_ARGS.iter().copied().chain(extra.iter().copied()), - ) - } - - #[test] - fn genesis_validator_keys_parse_from_repeated_flags() { - let keys = [7u8, 8].map(|seed| { - SigningKey::read_from_bytes(&[seed; 32]).expect("test signing key should decode") - }); - let hex_keys = keys.clone().map(|key| hex::encode(key.public_key().to_bytes())); - let command = - parse_genesis(&["--validator.key", &hex_keys[0], "--validator.key", &hex_keys[1]]) - .expect("genesis with explicit validator keys must parse"); - let ValidatorCommand::Genesis { validator_keys, .. } = command else { - panic!("expected the genesis command"); - }; - assert_eq!(validator_keys, keys.map(|key| key.public_key()).to_vec()); - } - - #[test] - fn genesis_requires_an_explicit_validator_set() { - let Err(error) = parse_genesis(&[]) else { - panic!("genesis without an explicit validator set must be rejected"); - }; - assert_eq!(error.kind(), clap::error::ErrorKind::MissingRequiredArgument); - - let key = SigningKey::read_from_bytes(&[7; 32]).expect("test signing key should decode"); - let key_hex = hex::encode(key.public_key().to_bytes()); - parse_genesis(&["--config", "/tmp/genesis.toml", "--validator.key", &key_hex]) - .expect("--config with an explicit validator set must parse"); - } - - #[test] - fn genesis_rejects_an_invalid_validator_key() { - let Err(error) = parse_genesis(&["--validator.key", "not-hex"]) else { - panic!("an invalid validator key must be rejected"); - }; - assert_eq!(error.kind(), clap::error::ErrorKind::ValueValidation); - } - - #[test] - fn encryption_key_is_required() { - let Err(error) = parse_start(&[]) else { - panic!("start without an encryption key must be rejected"); - }; - assert_eq!(error.kind(), clap::error::ErrorKind::MissingRequiredArgument); - } - - #[test] - fn signing_key_is_required() { - let Err(error) = ValidatorCommand::try_parse_from( - ["miden-validator", "start", "--listen", "127.0.0.1:50101"] - .into_iter() - .chain(["--data-directory", "/tmp/validator-data"]) - .chain(ENCRYPTION_KEY_ARGS) - .chain(STORAGE_KEY_ARGS), - ) else { - panic!("start without a signing key must be rejected"); - }; - assert_eq!(error.kind(), clap::error::ErrorKind::MissingRequiredArgument); - } - - #[test] - fn admin_listener_is_opt_in() { - let command = - parse_start(&ENCRYPTION_KEY_ARGS).expect("start without an admin listener must parse"); - let ValidatorCommand::Start { admin_listen, .. } = command else { - panic!("expected the start command"); - }; - assert_eq!(admin_listen, None); - - let command = parse_start( - &[ENCRYPTION_KEY_ARGS.as_slice(), &["--admin.listen", "127.0.0.1:50102"]].concat(), - ) - .expect("start with an admin listener must parse"); - let ValidatorCommand::Start { admin_listen, .. } = command else { - panic!("expected the start command"); - }; - assert_eq!(admin_listen, Some("127.0.0.1:50102".parse().unwrap())); - } - - #[test] - fn encryption_key_kms_ciphertext_parses_alone() { - let command = parse_start(&["--encryption-key.kms-ciphertext", "deadbeef"]) - .expect("KMS ciphertext without a hex key must parse"); - let ValidatorCommand::Start { encryption_key, .. } = command else { - panic!("expected the start command"); - }; - assert_eq!(encryption_key.encryption_key_kms_ciphertext.as_deref(), Some("deadbeef")); - assert_eq!(encryption_key.encryption_key, None); - } - - #[test] - fn encryption_key_hex_and_kms_ciphertext_conflict() { - let result = parse_start(&[ - "--encryption-key.hex", - TEST_ENCRYPTION_KEY_HEX, - "--encryption-key.kms-ciphertext", - "deadbeef", - ]); - let Err(error) = result else { - panic!("hex key and KMS ciphertext together must be rejected"); - }; - assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict); - } - - #[test] - fn storage_key_is_required() { - let Err(error) = ValidatorCommand::try_parse_from( - BASE_START_ARGS.into_iter().chain(ENCRYPTION_KEY_ARGS), - ) else { - panic!("start without a storage key must fail"); - }; - assert_eq!(error.kind(), clap::error::ErrorKind::MissingRequiredArgument); - } - - #[test] - fn partial_storage_key_configuration_fails() { - let Err(error) = ValidatorCommand::try_parse_from( - BASE_START_ARGS.into_iter().chain(ENCRYPTION_KEY_ARGS).chain([ - "--storage-key.epoch", - "0909090909090909090909090909090909090909090909090909090909090909", - ]), - ) else { - panic!("a partial storage key must fail"); - }; - assert_eq!(error.kind(), clap::error::ErrorKind::MissingRequiredArgument); - } - - #[test] - fn keygen_command_parses() { - let command = ValidatorCommand::try_parse_from(["miden-validator", "keygen"]) - .expect("the keygen command must parse"); - assert!(matches!(command, ValidatorCommand::Keygen)); - } - - #[test] - fn private_record_share_command_parses() { - let command = ValidatorCommand::try_parse_from([ - "miden-validator", - "issue-private-record-share", - "--record", - "/tmp/record.bin", - "--output", - "/tmp/share.bin", - "--storage-key.epoch", - "0909090909090909090909090909090909090909090909090909090909090909", - "--storage-key.setup-context", - "/tmp/setup.bin", - "--storage-key.public-key-set", - "/tmp/public.bin", - "--storage-key.secret-share", - "/tmp/secret.bin", - ]) - .expect("the local share command must parse"); - - let ValidatorCommand::IssuePrivateRecordShare(PrivateRecordShareOptions { - record, - output, - .. - }) = command - else { - panic!("expected the private record share command"); - }; - assert_eq!(record, PathBuf::from("/tmp/record.bin")); - assert_eq!(output, PathBuf::from("/tmp/share.bin")); - } - - #[test] - fn private_record_export_command_parses() { - let validator_id = SigningKey::read_from_bytes(&[7; 32]).unwrap().public_key().to_bytes(); - let command = ValidatorCommand::try_parse_from([ - "miden-validator", - "export-private-record", - "--data-directory", - "/tmp/validator-data", - "--transaction-id", - "0101010101010101010101010101010101010101010101010101010101010101", - "--validator-id", - &hex::encode(validator_id), - "--output", - "/tmp/record.bin", - ]) - .expect("the private record export command must parse"); - - let ValidatorCommand::ExportPrivateRecord(PrivateRecordExportOptions { - data_directory, - transaction_id, - output, - .. - }) = command - else { - panic!("expected the private record export command"); - }; - assert_eq!(data_directory, PathBuf::from("/tmp/validator-data")); - assert_eq!( - transaction_id, - "0101010101010101010101010101010101010101010101010101010101010101", - ); - assert_eq!(output, PathBuf::from("/tmp/record.bin")); - } - - #[test] - fn private_record_share_output_is_required() { - let result = ValidatorCommand::try_parse_from([ - "miden-validator", - "issue-private-record-share", - "--record", - "/tmp/record.bin", - "--storage-key.epoch", - "0909090909090909090909090909090909090909090909090909090909090909", - "--storage-key.setup-context", - "/tmp/setup.bin", - "--storage-key.public-key-set", - "/tmp/public.bin", - "--storage-key.secret-share", - "/tmp/secret.bin", - ]); - - let Err(error) = result else { - panic!("share output must be explicit"); - }; - assert_eq!(error.kind(), clap::error::ErrorKind::MissingRequiredArgument); - } - - #[tokio::test] - async fn two_validators_issue_shares_for_third_validator_record() { - let directory = tempfile::tempdir().unwrap(); - let writer = miden_validator::db::setup(directory.path().join("validator.sqlite3")) - .await - .unwrap(); - let operator_keys = test_operator_keys(9, 3); - let validator_signers = [7u8, 8, 9].map(|seed| { - SigningKey::read_from_bytes(&[seed; 32]).expect("test signing key should decode") - }); - let transaction_id = TransactionId::from_raw(Word::from([1u32, 2, 3, 4])); - let inputs = transaction_inputs(); - let mut records = Vec::new(); - for (index, signer) in validator_signers.iter().enumerate() { - let record_id = - miden_validator::PrivateRecordId::new(transaction_id, &signer.public_key()); - let context = miden_validator::PrivateRecordContext::new( - miden_validator::PrivateRecordChainId::new([5; 32]), - operator_keys[index].key_epoch(), - transaction_id, - ); - let mut rng = ChaCha20Rng::from_seed([6 + index as u8; 32]); - records.push( - miden_validator::PrivateRecordSealer::from_operator_key(&operator_keys[index]) - .seal(&mut rng, record_id, context, &inputs.to_bytes()) - .unwrap(), - ); - } - assert_ne!(records[0].record_id(), records[1].record_id()); - assert_ne!(records[1].record_id(), records[2].record_id()); - assert_ne!(records[0].encrypted_record_key(), records[1].encrypted_record_key()); - assert_ne!(records[1].encrypted_record_key(), records[2].encrypted_record_key()); - - let target = records.remove(2); - store_private_record(&writer, target.clone()).await; - let target_file = directory.path().join("target-record.bin"); - let encoded_transaction_id = hex::encode(transaction_id.to_bytes()); - let encoded_validator_id = hex::encode(target.record_id().validator_id()); - export_record_file( - directory.path(), - &encoded_transaction_id, - &encoded_validator_id, - &target_file, - ) - .await; - assert_eq!( - miden_validator::StoredPrivateRecord::read_from_bytes( - &fs_err::read(&target_file).unwrap() - ) - .unwrap(), - target, - ); - - let first_output = directory.path().join("share-1.bin"); - let second_output = directory.path().join("share-2.bin"); - let first_share = issue_share_file(&target_file, &first_output, &operator_keys[0]); - let second_share = issue_share_file(&target_file, &second_output, &operator_keys[1]); - let shares = [first_share, second_share]; - for share in &shares { - let decoded = from_wire_bytes::>(share).unwrap(); - assert_eq!(to_wire_bytes(&decoded), *share); - } - let request = miden_validator::PrivateRecordShareRequest::for_record(&target); - let opened = miden_validator::PrivateRecordCombiner::from_operator_key(&operator_keys[2]) - .unwrap() - .open(&request, &target, &shares) - .unwrap(); - assert_eq!(TransactionInputs::read_from_bytes(&opened).unwrap(), inputs); - - let wrong_epoch = test_operator_keys(10, 3).remove(0); - assert_issue_error(&target_file, &first_output, &wrong_epoch, "key epoch does not match"); - - let wrong_setup = test_operator_keys(9, 7).remove(0); - assert_issue_error(&target_file, &first_output, &wrong_setup, "setup does not match"); - - let missing_validator_id = hex::encode(validator_signers[0].public_key().to_bytes()); - let error = export_private_record::export(PrivateRecordExportOptions { - data_directory: directory.path().to_path_buf(), - transaction_id: encoded_transaction_id, - validator_id: missing_validator_id, - output: target_file, - }) - .await; - assert!(format!("{:#}", error.unwrap_err()).contains("was not found")); - } -} +mod tests; diff --git a/bin/validator/src/commands/tests.rs b/bin/validator/src/commands/tests.rs new file mode 100644 index 0000000000..4bb108d175 --- /dev/null +++ b/bin/validator/src/commands/tests.rs @@ -0,0 +1,477 @@ +use std::collections::BTreeMap; +use std::path::Path; + +use golden_core::{GoldenGroup, GoldenScalar, ParticipantIndex, SessionId}; +use golden_ehtdh1::wire::{from_wire_bytes, to_wire_bytes}; +use golden_ehtdh1::{ + DecryptionShare, + PublicKeySet, + PublicShare, + SecretShare, + SetupContext, + derive_context_session_id, +}; +use golden_halo2curves::golden_group::{Secp256k1GoldenGroup, Secp256k1Scalar}; +use miden_protocol::Word; +use miden_protocol::account::auth::AuthScheme; +use miden_protocol::transaction::{TransactionId, TransactionInputs}; +use miden_protocol::utils::serde::{Deserializable, Serializable}; +use miden_testing::{Auth, MockChainBuilder}; +use rand_chacha_03::ChaCha20Rng; +use rand_chacha_03::rand_core::SeedableRng; + +use super::*; + +type TestStorageGroup = Secp256k1GoldenGroup; + +const TEST_SIGNING_KEY_HEX: &str = + "0101010101010101010101010101010101010101010101010101010101010101"; +const TEST_ENCRYPTION_KEY_HEX: &str = + "0202020202020202020202020202020202020202020202020202020202020202"; + +const BASE_START_ARGS: [&str; 8] = [ + "miden-validator", + "start", + "--listen", + "127.0.0.1:50101", + "--data-directory", + "/tmp/validator-data", + "--signing-key.hex", + TEST_SIGNING_KEY_HEX, +]; +const ENCRYPTION_KEY_ARGS: [&str; 2] = ["--encryption-key.hex", TEST_ENCRYPTION_KEY_HEX]; +const STORAGE_KEY_ARGS: [&str; 8] = [ + "--storage-key.epoch", + "0909090909090909090909090909090909090909090909090909090909090909", + "--storage-key.setup-context", + "/tmp/setup.bin", + "--storage-key.public-key-set", + "/tmp/public.bin", + "--storage-key.secret-share", + "/tmp/secret.bin", +]; + +fn parse_start(extra: &[&str]) -> Result { + ValidatorCommand::try_parse_from( + BASE_START_ARGS + .iter() + .copied() + .chain(extra.iter().copied()) + .chain(STORAGE_KEY_ARGS.iter().copied()), + ) +} +fn test_operator_keys(epoch: u8, setup_marker: u8) -> Vec { + let participants = [1, 2, 3].map(|value| ParticipantIndex::new(value).unwrap()); + let decryption_secret = Secp256k1Scalar::from_u64(11).unwrap(); + let decryption_coefficient = Secp256k1Scalar::from_u64(7).unwrap(); + let context_coefficient = Secp256k1Scalar::from_u64(13).unwrap(); + let mut public_shares = BTreeMap::new(); + let secret_shares = participants.map(|participant| { + let point = participant.to_scalar::().unwrap(); + let decryption = decryption_secret.add(&decryption_coefficient.mul(&point)); + let context = context_coefficient.mul(&point); + public_shares.insert( + participant, + PublicShare { + decryption: TestStorageGroup::mul_generator(&decryption), + context: TestStorageGroup::mul_generator(&context), + }, + ); + SecretShare { participant, decryption, context } + }); + let public_key_set = + PublicKeySet::new(2, TestStorageGroup::mul_generator(&decryption_secret), public_shares) + .unwrap(); + let decryption_session_id = SessionId([2; 32]); + let setup_context = SetupContext { + backend_id: TestStorageGroup::BACKEND_ID.to_owned(), + threshold: 2, + registry_root: [1; 32], + participants: participants.to_vec(), + decryption_session_id, + context_session_id: derive_context_session_id(decryption_session_id), + decryption_transcript_root: [setup_marker; 32], + context_transcript_root: [4; 32], + epoch: [epoch; 32], + }; + secret_shares + .into_iter() + .map(|secret_share| { + GoldenOperatorKey::new( + StorageKeyEpoch::new([epoch; 32]), + setup_context.clone(), + public_key_set.clone(), + secret_share, + ) + .unwrap() + }) + .collect() +} + +fn transaction_inputs() -> TransactionInputs { + let mut builder = MockChainBuilder::new(); + let account = builder + .add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + }) + .unwrap(); + builder.build().unwrap().get_transaction_inputs(&account, &[], &[]).unwrap() +} + +fn issue_error(record: &Path, output: &Path, operator_key: &GoldenOperatorKey) -> String { + let error = issue_private_record_share::issue(record, output, operator_key).unwrap_err(); + format!("{error:#}") +} + +fn assert_issue_error( + record: &Path, + output: &Path, + operator_key: &GoldenOperatorKey, + expected: &str, +) { + let error = issue_error(record, output, operator_key); + assert!(error.contains(expected), "expected {expected:?} in {error:?}"); +} + +fn issue_share_file(record: &Path, output: &Path, operator_key: &GoldenOperatorKey) -> Vec { + issue_private_record_share::issue(record, output, operator_key).unwrap(); + fs_err::read(output).unwrap() +} + +async fn store_private_record( + writer: &miden_validator::db::ValidatorDbWriter, + record: miden_validator::StoredPrivateRecord, +) { + writer.insert_validated_private_transaction(record).await.unwrap(); +} + +async fn export_record_file( + data_directory: &Path, + encoded_transaction_id: &str, + encoded_validator_id: &str, + output: &Path, +) { + export_private_record::export(PrivateRecordExportOptions { + data_directory: data_directory.to_path_buf(), + transaction_id: encoded_transaction_id.to_owned(), + validator_id: encoded_validator_id.to_owned(), + output: output.to_path_buf(), + }) + .await + .unwrap(); +} + +const BASE_GENESIS_ARGS: [&str; 6] = [ + "miden-validator", + "genesis", + "--genesis-block-directory", + "/tmp/genesis", + "--accounts-directory", + "/tmp/accounts", +]; + +fn parse_genesis(extra: &[&str]) -> Result { + ValidatorCommand::try_parse_from(BASE_GENESIS_ARGS.iter().copied().chain(extra.iter().copied())) +} + +#[test] +fn genesis_validator_keys_parse_from_repeated_flags() { + let keys = [7u8, 8].map(|seed| { + SigningKey::read_from_bytes(&[seed; 32]).expect("test signing key should decode") + }); + let hex_keys = keys.clone().map(|key| hex::encode(key.public_key().to_bytes())); + let command = + parse_genesis(&["--validator.key", &hex_keys[0], "--validator.key", &hex_keys[1]]) + .expect("genesis with explicit validator keys must parse"); + let ValidatorCommand::Genesis { validator_keys, .. } = command else { + panic!("expected the genesis command"); + }; + assert_eq!(validator_keys, keys.map(|key| key.public_key()).to_vec()); +} + +#[test] +fn genesis_requires_an_explicit_validator_set() { + let Err(error) = parse_genesis(&[]) else { + panic!("genesis without an explicit validator set must be rejected"); + }; + assert_eq!(error.kind(), clap::error::ErrorKind::MissingRequiredArgument); + + let key = SigningKey::read_from_bytes(&[7; 32]).expect("test signing key should decode"); + let key_hex = hex::encode(key.public_key().to_bytes()); + parse_genesis(&["--config", "/tmp/genesis.toml", "--validator.key", &key_hex]) + .expect("--config with an explicit validator set must parse"); +} + +#[test] +fn genesis_rejects_an_invalid_validator_key() { + let Err(error) = parse_genesis(&["--validator.key", "not-hex"]) else { + panic!("an invalid validator key must be rejected"); + }; + assert_eq!(error.kind(), clap::error::ErrorKind::ValueValidation); +} + +#[test] +fn encryption_key_is_required() { + let Err(error) = parse_start(&[]) else { + panic!("start without an encryption key must be rejected"); + }; + assert_eq!(error.kind(), clap::error::ErrorKind::MissingRequiredArgument); +} + +#[test] +fn signing_key_is_required() { + let Err(error) = ValidatorCommand::try_parse_from( + ["miden-validator", "start", "--listen", "127.0.0.1:50101"] + .into_iter() + .chain(["--data-directory", "/tmp/validator-data"]) + .chain(ENCRYPTION_KEY_ARGS) + .chain(STORAGE_KEY_ARGS), + ) else { + panic!("start without a signing key must be rejected"); + }; + assert_eq!(error.kind(), clap::error::ErrorKind::MissingRequiredArgument); +} + +#[test] +fn admin_listener_is_opt_in() { + let command = + parse_start(&ENCRYPTION_KEY_ARGS).expect("start without an admin listener must parse"); + let ValidatorCommand::Start { admin_listen, .. } = command else { + panic!("expected the start command"); + }; + assert_eq!(admin_listen, None); + + let command = parse_start( + &[ENCRYPTION_KEY_ARGS.as_slice(), &["--admin.listen", "127.0.0.1:50102"]].concat(), + ) + .expect("start with an admin listener must parse"); + let ValidatorCommand::Start { admin_listen, .. } = command else { + panic!("expected the start command"); + }; + assert_eq!(admin_listen, Some("127.0.0.1:50102".parse().unwrap())); +} + +#[test] +fn encryption_key_kms_ciphertext_parses_alone() { + let command = parse_start(&["--encryption-key.kms-ciphertext", "deadbeef"]) + .expect("KMS ciphertext without a hex key must parse"); + let ValidatorCommand::Start { encryption_key, .. } = command else { + panic!("expected the start command"); + }; + assert_eq!(encryption_key.encryption_key_kms_ciphertext.as_deref(), Some("deadbeef")); + assert_eq!(encryption_key.encryption_key, None); +} + +#[test] +fn encryption_key_hex_and_kms_ciphertext_conflict() { + let result = parse_start(&[ + "--encryption-key.hex", + TEST_ENCRYPTION_KEY_HEX, + "--encryption-key.kms-ciphertext", + "deadbeef", + ]); + let Err(error) = result else { + panic!("hex key and KMS ciphertext together must be rejected"); + }; + assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict); +} + +#[test] +fn storage_key_is_required() { + let Err(error) = + ValidatorCommand::try_parse_from(BASE_START_ARGS.into_iter().chain(ENCRYPTION_KEY_ARGS)) + else { + panic!("start without a storage key must fail"); + }; + assert_eq!(error.kind(), clap::error::ErrorKind::MissingRequiredArgument); +} + +#[test] +fn partial_storage_key_configuration_fails() { + let Err(error) = ValidatorCommand::try_parse_from( + BASE_START_ARGS.into_iter().chain(ENCRYPTION_KEY_ARGS).chain([ + "--storage-key.epoch", + "0909090909090909090909090909090909090909090909090909090909090909", + ]), + ) else { + panic!("a partial storage key must fail"); + }; + assert_eq!(error.kind(), clap::error::ErrorKind::MissingRequiredArgument); +} + +#[test] +fn keygen_command_parses() { + let command = ValidatorCommand::try_parse_from(["miden-validator", "keygen"]) + .expect("the keygen command must parse"); + assert!(matches!(command, ValidatorCommand::Keygen)); +} + +#[test] +fn private_record_share_command_parses() { + let command = ValidatorCommand::try_parse_from([ + "miden-validator", + "issue-private-record-share", + "--record", + "/tmp/record.bin", + "--output", + "/tmp/share.bin", + "--storage-key.epoch", + "0909090909090909090909090909090909090909090909090909090909090909", + "--storage-key.setup-context", + "/tmp/setup.bin", + "--storage-key.public-key-set", + "/tmp/public.bin", + "--storage-key.secret-share", + "/tmp/secret.bin", + ]) + .expect("the local share command must parse"); + + let ValidatorCommand::IssuePrivateRecordShare(PrivateRecordShareOptions { + record, output, .. + }) = command + else { + panic!("expected the private record share command"); + }; + assert_eq!(record, PathBuf::from("/tmp/record.bin")); + assert_eq!(output, PathBuf::from("/tmp/share.bin")); +} + +#[test] +fn private_record_export_command_parses() { + let validator_id = SigningKey::read_from_bytes(&[7; 32]).unwrap().public_key().to_bytes(); + let command = ValidatorCommand::try_parse_from([ + "miden-validator", + "export-private-record", + "--data-directory", + "/tmp/validator-data", + "--transaction-id", + "0101010101010101010101010101010101010101010101010101010101010101", + "--validator-id", + &hex::encode(validator_id), + "--output", + "/tmp/record.bin", + ]) + .expect("the private record export command must parse"); + + let ValidatorCommand::ExportPrivateRecord(PrivateRecordExportOptions { + data_directory, + transaction_id, + output, + .. + }) = command + else { + panic!("expected the private record export command"); + }; + assert_eq!(data_directory, PathBuf::from("/tmp/validator-data")); + assert_eq!( + transaction_id, + "0101010101010101010101010101010101010101010101010101010101010101", + ); + assert_eq!(output, PathBuf::from("/tmp/record.bin")); +} + +#[test] +fn private_record_share_output_is_required() { + let result = ValidatorCommand::try_parse_from([ + "miden-validator", + "issue-private-record-share", + "--record", + "/tmp/record.bin", + "--storage-key.epoch", + "0909090909090909090909090909090909090909090909090909090909090909", + "--storage-key.setup-context", + "/tmp/setup.bin", + "--storage-key.public-key-set", + "/tmp/public.bin", + "--storage-key.secret-share", + "/tmp/secret.bin", + ]); + + let Err(error) = result else { + panic!("share output must be explicit"); + }; + assert_eq!(error.kind(), clap::error::ErrorKind::MissingRequiredArgument); +} + +#[tokio::test] +async fn two_validators_issue_shares_for_third_validator_record() { + let directory = tempfile::tempdir().unwrap(); + let writer = miden_validator::db::setup(directory.path().join("validator.sqlite3")) + .await + .unwrap(); + let operator_keys = test_operator_keys(9, 3); + let validator_signers = [7u8, 8, 9].map(|seed| { + SigningKey::read_from_bytes(&[seed; 32]).expect("test signing key should decode") + }); + let transaction_id = TransactionId::from_raw(Word::from([1u32, 2, 3, 4])); + let inputs = transaction_inputs(); + let mut records = Vec::new(); + for (index, signer) in validator_signers.iter().enumerate() { + let record_id = miden_validator::PrivateRecordId::new(transaction_id, &signer.public_key()); + let context = miden_validator::PrivateRecordContext::new( + miden_validator::PrivateRecordChainId::new([5; 32]), + operator_keys[index].key_epoch(), + transaction_id, + ); + let mut rng = ChaCha20Rng::from_seed([6 + index as u8; 32]); + records.push( + miden_validator::PrivateRecordSealer::from_operator_key(&operator_keys[index]) + .seal(&mut rng, record_id, context, &inputs.to_bytes()) + .unwrap(), + ); + } + assert_ne!(records[0].record_id(), records[1].record_id()); + assert_ne!(records[1].record_id(), records[2].record_id()); + assert_ne!(records[0].encrypted_record_key(), records[1].encrypted_record_key()); + assert_ne!(records[1].encrypted_record_key(), records[2].encrypted_record_key()); + + let target = records.remove(2); + store_private_record(&writer, target.clone()).await; + let target_file = directory.path().join("target-record.bin"); + let encoded_transaction_id = hex::encode(transaction_id.to_bytes()); + let encoded_validator_id = hex::encode(target.record_id().validator_id()); + export_record_file( + directory.path(), + &encoded_transaction_id, + &encoded_validator_id, + &target_file, + ) + .await; + assert_eq!( + miden_validator::StoredPrivateRecord::read_from_bytes(&fs_err::read(&target_file).unwrap()) + .unwrap(), + target, + ); + + let first_output = directory.path().join("share-1.bin"); + let second_output = directory.path().join("share-2.bin"); + let first_share = issue_share_file(&target_file, &first_output, &operator_keys[0]); + let second_share = issue_share_file(&target_file, &second_output, &operator_keys[1]); + let shares = [first_share, second_share]; + for share in &shares { + let decoded = from_wire_bytes::>(share).unwrap(); + assert_eq!(to_wire_bytes(&decoded), *share); + } + let request = miden_validator::PrivateRecordShareRequest::for_record(&target); + let opened = miden_validator::PrivateRecordCombiner::from_operator_key(&operator_keys[2]) + .unwrap() + .open(&request, &target, &shares) + .unwrap(); + assert_eq!(TransactionInputs::read_from_bytes(&opened).unwrap(), inputs); + + let wrong_epoch = test_operator_keys(10, 3).remove(0); + assert_issue_error(&target_file, &first_output, &wrong_epoch, "key epoch does not match"); + + let wrong_setup = test_operator_keys(9, 7).remove(0); + assert_issue_error(&target_file, &first_output, &wrong_setup, "setup does not match"); + + let missing_validator_id = hex::encode(validator_signers[0].public_key().to_bytes()); + let error = export_private_record::export(PrivateRecordExportOptions { + data_directory: directory.path().to_path_buf(), + transaction_id: encoded_transaction_id, + validator_id: missing_validator_id, + output: target_file, + }) + .await; + assert!(format!("{:#}", error.unwrap_err()).contains("was not found")); +} From d1ca8d9930bb99bb51786572e717aa1264a77277 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Thu, 3 Sep 2026 11:08:04 -0400 Subject: [PATCH 26/28] chore: normalize lockfile after upstream refresh Regenerate the lockfile against the refreshed dependency graph. This removes the stale duplicate lru entry while keeping the reviewed Iroh versions. --- Cargo.lock | 54 ++++++++++++++++++++++++------------------------------ 1 file changed, 24 insertions(+), 30 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7834498b25..0c85b15729 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3149,18 +3149,18 @@ dependencies = [ "futures-channel", "futures-io", "futures-util", - "h2 0.4.15", + "h2", "hickory-proto", "http 1.5.0", "idna", "ipnet", "jni 0.22.4", "rand 0.10.2", - "rustls 0.23.43", + "rustls", "thiserror 2.0.20", "tinyvec", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tracing", "url", ] @@ -3204,12 +3204,12 @@ dependencies = [ "parking_lot", "rand 0.10.2", "resolv-conf", - "rustls 0.23.43", + "rustls", "smallvec", "system-configuration", "thiserror 2.0.20", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tracing", ] @@ -3631,7 +3631,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" dependencies = [ - "socket2 0.6.5", + "socket2", "widestring", "windows-registry", "windows-result", @@ -3683,7 +3683,7 @@ dependencies = [ "rand 0.10.2", "reqwest", "rustc-hash", - "rustls 0.23.43", + "rustls", "rustls-pki-types", "serde", "smallvec", @@ -3772,7 +3772,7 @@ dependencies = [ "ndk-context", "portable-atomic", "rand 0.10.2", - "rustls 0.23.43", + "rustls", "simple-dns", "strum", "tokio", @@ -3830,7 +3830,7 @@ dependencies = [ "derive_more", "futures-concurrency", "hex", - "indexmap 2.14.0", + "indexmap 2.14.1", "iroh", "iroh-base", "iroh-metrics", @@ -3900,12 +3900,12 @@ dependencies = [ "hickory-resolver", "http 1.5.0", "http-body-util", - "hyper 1.11.0", + "hyper", "hyper-util", "iroh-base", "iroh-dns", "iroh-metrics", - "lru 0.18.2", + "lru", "n0-error", "n0-future", "noq", @@ -3915,13 +3915,13 @@ dependencies = [ "postcard", "rand 0.10.2", "reqwest", - "rustls 0.23.43", + "rustls", "rustls-pki-types", "serde", "serde_bytes", "strum", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-util", "tokio-websockets", "tracing", @@ -4410,12 +4410,6 @@ name = "lru" version = "0.18.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25" - -[[package]] -name = "lru" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" dependencies = [ "hashbrown 0.17.1", ] @@ -5224,7 +5218,7 @@ dependencies = [ "http 1.5.0", "humantime", "itertools 0.14.0", - "lru 0.16.4", + "lru", "miden-crypto", "miden-node-tracing", "miden-processor", @@ -6008,7 +6002,7 @@ dependencies = [ "objc2-system-configuration", "pin-project-lite", "serde", - "socket2 0.6.5", + "socket2", "time", "tokio", "tokio-util", @@ -6054,8 +6048,8 @@ dependencies = [ "noq-udp", "pin-project-lite", "rustc-hash", - "rustls 0.23.43", - "socket2 0.6.5", + "rustls", + "socket2", "thiserror 2.0.20", "tokio", "tokio-stream", @@ -6080,7 +6074,7 @@ dependencies = [ "rand_pcg", "ring", "rustc-hash", - "rustls 0.23.43", + "rustls", "rustls-pki-types", "slab", "sorted-index-buffer", @@ -6098,7 +6092,7 @@ checksum = "02bba20e097a5a16cd0ad14ec882fae1e80a092a124e9422fc4dddd92e96a647" dependencies = [ "cfg_aliases", "libc", - "socket2 0.6.5", + "socket2", "tracing", "windows-sys 0.61.2", ] @@ -6829,7 +6823,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64", - "indexmap 2.14.0", + "indexmap 2.14.1", "quick-xml", "serde", "time", @@ -7677,7 +7671,7 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "h2 0.4.15", + "h2", "http 1.5.0", "http-body 1.1.0", "http-body-util", @@ -7698,7 +7692,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -8745,7 +8739,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", @@ -8993,7 +8987,7 @@ dependencies = [ "sha1_smol", "simdutf8", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-util", ] From 078ba31206cef3ae95cd50f63ebdec473006c906 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Fri, 11 Sep 2026 22:18:16 -0400 Subject: [PATCH 27/28] fix(validator): use the current genesis validator API Read validator keys through the validator configuration stored in the genesis header. Remove the unused lockfile entry left by the rebase conflict. --- Cargo.lock | 6 ------ bin/validator/src/commands/dkg/runner.rs | 12 ++++++------ bin/validator/src/commands/dkg/tests.rs | 2 +- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0c85b15729..66755e5873 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7315,12 +7315,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "quick-error" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" - [[package]] name = "quick-xml" version = "0.41.0" diff --git a/bin/validator/src/commands/dkg/runner.rs b/bin/validator/src/commands/dkg/runner.rs index 209256e8ff..b422a55a24 100644 --- a/bin/validator/src/commands/dkg/runner.rs +++ b/bin/validator/src/commands/dkg/runner.rs @@ -136,7 +136,7 @@ pub(super) async fn run_validator(options: DkgRunOptions) -> anyhow::Result<()> pub(super) async fn serve_board(options: DkgBoardServeOptions) -> anyhow::Result<()> { let genesis = read_trusted_genesis(&options.genesis)?; - let participant_count = genesis.inner().header().validator_keys().as_keys().len(); + let participant_count = genesis.inner().header().validator_config().keys().len(); let (board, tickets) = BoardNode::create(&options.data_directory, participant_count).await?; publish_directory(&options.ticket_directory, |temporary| { for ticket in &tickets { @@ -181,7 +181,7 @@ pub(super) async fn coordinate_common_files( timeout: Duration, ) -> anyhow::Result<()> { let genesis = read_trusted_genesis(genesis_path)?; - let validator_keys = genesis.inner().header().validator_keys().as_keys(); + let validator_keys = genesis.inner().header().validator_config().keys(); ensure!( threshold > 0 && threshold <= validator_keys.len(), "threshold must be between 1 and {}", @@ -271,7 +271,7 @@ where format!("failed to create DKG work directory {}", work_directory.display()) })?; let genesis = read_trusted_genesis(genesis_path)?; - let validator_keys = genesis.inner().header().validator_keys().as_keys(); + let validator_keys = genesis.inner().header().validator_config().keys(); let participant_count = validator_keys.len(); ensure!( threshold > 0 && threshold <= participant_count, @@ -330,7 +330,7 @@ where let genesis = read_trusted_genesis(genesis_path)?; let registrations = - wait_for_registrations(board, genesis.inner().header().validator_keys().as_keys(), timeout) + wait_for_registrations(board, genesis.inner().header().validator_config().keys(), timeout) .await?; let registration_directory = work_directory.join(REGISTRATIONS_DIRECTORY); materialize_or_compare(®istration_directory, ®istrations)?; @@ -490,8 +490,8 @@ fn participant_for_validator( let position = genesis .inner() .header() - .validator_keys() - .as_keys() + .validator_config() + .keys() .iter() .position(|key| key == validator_key) .context("validator signing key is not committed by genesis")?; diff --git a/bin/validator/src/commands/dkg/tests.rs b/bin/validator/src/commands/dkg/tests.rs index 87d0f99891..da68014a43 100644 --- a/bin/validator/src/commands/dkg/tests.rs +++ b/bin/validator/src/commands/dkg/tests.rs @@ -946,7 +946,7 @@ async fn iroh_ceremony_resumes_after_validator_restart() -> TestResult { .map(ValidatorSigner::new_local) .collect::>(); let trusted_genesis = read_trusted_genesis(&genesis.path)?; - let validator_keys = trusted_genesis.inner().header().validator_keys().as_keys(); + let validator_keys = trusted_genesis.inner().header().validator_config().keys(); let tickets = signers .iter() .map(|signer| { From 02b38366fc070464c22c6d4b7b65a9af40f383a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Sat, 12 Sep 2026 13:57:34 -0400 Subject: [PATCH 28/28] fix(docker): avoid duplicating release binaries --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index e593e76f17..0d0b8f31e5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -112,7 +112,7 @@ RUN --mount=type=cache,sharing=locked,id=cargo-registry-${TARGETARCH},target=/us --bin miden-remote-prover \ --bin miden-benchmark && \ mkdir -p /app/bin && \ - cp /app/target/release/miden-node \ + mv /app/target/release/miden-node \ /app/target/release/miden-validator \ /app/target/release/miden-note-transport \ /app/target/release/miden-ntx-builder \