From 4ee2f38b5ce3d975010f66fd756f90961c81a444 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/15] feat(validator): automate Golden DKG over Iroh --- .config/cspell.yaml | 1 + CHANGELOG.md | 1 + Cargo.lock | 1785 +++++++++++++++++++++- 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, 3787 insertions(+), 37 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.md b/CHANGELOG.md index 39e6457240..e8e2a03a02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 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 e94dcca4fd..44119bd3ff 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" @@ -481,6 +516,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.91" @@ -492,6 +539,26 @@ dependencies = [ "syn 3.0.3", ] +[[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" @@ -1010,6 +1077,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" @@ -1063,6 +1149,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" @@ -1152,6 +1244,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.9.3" @@ -1235,6 +1336,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" @@ -1274,6 +1378,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" @@ -1324,7 +1434,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.1", "cipher 0.5.2", "poly1305", @@ -1458,6 +1568,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" @@ -1483,6 +1602,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" @@ -1537,6 +1665,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" @@ -1628,6 +1766,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" @@ -1695,6 +1842,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" @@ -1716,7 +1872,9 @@ dependencies = [ "curve25519-dalek-derive", "digest 0.11.3", "fiat-crypto", + "rand_core 0.10.1", "rustc_version 0.4.1", + "serde", "subtle", "zeroize", ] @@ -1815,6 +1973,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" @@ -1894,6 +2078,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" dependencies = [ "const-oid", + "pem-rfc7468", "zeroize", ] @@ -1940,6 +2125,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.11" @@ -2017,6 +2208,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" @@ -2034,6 +2237,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" @@ -2088,6 +2302,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ "pkcs8", + "serdect", "signature", ] @@ -2099,6 +2314,7 @@ checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" dependencies = [ "curve25519-dalek", "ed25519", + "rand_core 0.10.1", "serde", "sha2 0.11.0", "signature", @@ -2145,6 +2361,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" @@ -2154,6 +2382,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" @@ -2224,6 +2463,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" @@ -2396,6 +2655,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.33" @@ -2406,6 +2678,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.33" @@ -2429,6 +2714,19 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +[[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.33" @@ -2475,6 +2773,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" @@ -2541,6 +2870,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" @@ -2753,6 +3092,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" @@ -2791,6 +3139,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", ] @@ -2803,6 +3153,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" @@ -2822,16 +3186,93 @@ 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" + "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" @@ -3168,6 +3609,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" @@ -3256,11 +3703,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" @@ -3346,6 +4158,22 @@ dependencies = [ "syn 2.0.119", ] +[[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" @@ -3355,7 +4183,7 @@ dependencies = [ "cfg-if", "combine", "jni-macros", - "jni-sys", + "jni-sys 0.4.1", "log", "simd_cesu8", "thiserror 2.0.19", @@ -3376,6 +4204,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" @@ -3644,6 +4481,15 @@ version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +[[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" @@ -3660,6 +4506,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" @@ -4391,7 +5243,7 @@ dependencies = [ "http-body-util", "humantime", "itertools 0.14.0", - "lru", + "lru 0.16.4", "miden-crypto", "miden-node-tracing-macro", "miden-protocol", @@ -4789,11 +5641,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", @@ -4880,27 +5737,229 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] -name = "minimal-lexical" -version = "0.2.1" +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "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 = "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.2" +name = "netlink-sys" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +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" @@ -4924,6 +5983,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" @@ -5032,6 +6153,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" @@ -5054,6 +6287,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" @@ -5358,6 +6597,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" @@ -5386,6 +6635,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" @@ -5421,6 +6676,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" @@ -5448,6 +6712,16 @@ dependencies = [ "indexmap 2.14.0", ] +[[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" @@ -5496,6 +6770,19 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[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" @@ -5540,7 +6827,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" dependencies = [ "cpufeatures 0.3.0", - "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]] @@ -5548,6 +6847,9 @@ name = "portable-atomic" version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" +dependencies = [ + "serde", +] [[package]] name = "portable-atomic-util" @@ -5558,6 +6860,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.5" @@ -5582,6 +6919,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" @@ -5661,12 +7009,38 @@ dependencies = [ ] [[package]] -name = "proc-macro-crate" -version = "3.5.0" +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +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 = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +checksum = "8a5b4b77fdb63c1eca72173d68d24501c54ab1269409f6b672c85deb18af69de" dependencies = [ - "toml_edit", + "proc-macro2", + "quote", + "syn 1.0.109", + "syn-mid", + "version_check", ] [[package]] @@ -5691,6 +7065,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" @@ -5889,6 +7269,15 @@ 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" @@ -6086,6 +7475,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" @@ -6115,6 +7517,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" @@ -6144,6 +7555,18 @@ dependencies = [ "syn 3.0.3", ] +[[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" @@ -6195,6 +7618,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-core", + "futures-util", "h2 0.4.15", "http 1.5.0", "http-body 1.1.0", @@ -6217,15 +7641,23 @@ dependencies = [ "sync_wrapper", "tokio", "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" @@ -6468,7 +7900,7 @@ checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", - "jni", + "jni 0.22.4", "log", "once_cell", "rustls 0.23.43", @@ -6634,6 +8066,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" @@ -6677,6 +8125,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" @@ -6687,6 +8141,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" @@ -6710,6 +8173,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" @@ -6852,6 +8325,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" @@ -6962,6 +8441,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" @@ -7003,6 +8491,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" @@ -7012,6 +8517,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.2" @@ -7091,6 +8602,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" @@ -7130,6 +8662,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.6.1" @@ -7183,6 +8726,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" @@ -7298,6 +8847,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", + "js-sys", "num-conv", "powerfmt", "serde_core", @@ -7440,6 +8990,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.4+spec-1.1.0" @@ -7920,6 +9493,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" @@ -7973,6 +9556,7 @@ version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ + "getrandom 0.4.3", "js-sys", "wasm-bindgen", ] @@ -8117,6 +9701,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.103" @@ -8134,6 +9731,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", + "serde", "wasm-bindgen", ] @@ -8146,6 +9744,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" @@ -8301,13 +9914,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]] @@ -8319,20 +9941,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]] @@ -8344,18 +9981,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" @@ -8368,24 +10023,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" @@ -8407,6 +10086,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.0" @@ -8424,6 +10118,25 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[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 84139473d5..b5800a81a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -88,6 +88,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.16" } diff --git a/bin/validator/Cargo.toml b/bin/validator/Cargo.toml index 2c7e7e40c1..d57a74a477 100644 --- a/bin/validator/Cargo.toml +++ b/bin/validator/Cargo.toml @@ -26,11 +26,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 } @@ -43,7 +48,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 7714049397..d49c41eb70 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 f871c71ced..0335b2c3c1 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::{ @@ -902,3 +904,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 e4da998abd10d377fac0f595c9cb67e44ce44644 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/15] 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 eeddd7f434..745b83ffea 100644 --- a/docs/external/src/network-operator/bootstrap-and-genesis.md +++ b/docs/external/src/network-operator/bootstrap-and-genesis.md @@ -154,10 +154,12 @@ configuration's `validators` list. 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 a1f93c0cbc..f4cc400c23 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 e4b5326dc4bd4ee3b62edbec73a64c525f9f435c 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/15] 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 1aa93454b2..874e76a203 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 9cb29bcc9c..eb9e71037e 100644 --- a/docs/external/src/local-network-development.md +++ b/docs/external/src/local-network-development.md @@ -235,6 +235,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 1cf9b0c0f416d388cf63f1794cb35b829782410a 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/15] 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 1b8294ae176e82e934272fe7201b74b1b9ec49fe 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/15] docs: move Iroh DKG changelog to PR metadata --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8e2a03a02..39e6457240 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,6 @@ ## 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 04a03338f347ff1d68a3878fcb85b3f226603209 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/15] 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 96cc22ee65..b52366c96f 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_utils::logging::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 43238b2b0556da814bbb0abb13bfdef258fef6eb 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/15] 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 d49c41eb70..9ccb3608dc 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 874e76a203..9f32a8d249 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 eb9e71037e..6bdaf24c0c 100644 --- a/docs/external/src/local-network-development.md +++ b/docs/external/src/local-network-development.md @@ -235,15 +235,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 f4cc400c23..a7d5274f53 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 86d3fd7d0122706a3720e7adda532cb093eb66d3 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/15] 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 0335b2c3c1..e52afb1466 100644 --- a/bin/validator/src/commands/dkg/tests.rs +++ b/bin/validator/src/commands/dkg/tests.rs @@ -905,7 +905,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 3c4f51d16d..cc080fb515 100644 --- a/bin/validator/src/commands/mod.rs +++ b/bin/validator/src/commands/mod.rs @@ -294,7 +294,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 b52366c96f..96cc22ee65 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_utils::logging::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 9f32a8d249..c2cc261f1b 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 a7d5274f53..0fe5266ef3 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 a21cb0b67152fa2c89e6b6ce067fc9a1c20fc286 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/15] 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 0fe5266ef3..9ceb955bcb 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 27c5e93487d21820de2aef5c203d6d13eb457493 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/15] 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 e52afb1466..6b6aa53324 100644 --- a/bin/validator/src/commands/dkg/tests.rs +++ b/bin/validator/src/commands/dkg/tests.rs @@ -906,6 +906,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())?; @@ -914,6 +918,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()); @@ -921,8 +926,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 } }); @@ -949,7 +956,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, @@ -962,6 +968,8 @@ async fn iroh_ceremony_resumes_after_validator_restart() -> TestResult { &ticket, &genesis.path, &signers[0], + 2, + &epoch, &work_directories[0], &bundle_directories[0], false, @@ -971,6 +979,8 @@ async fn iroh_ceremony_resumes_after_validator_restart() -> TestResult { &ticket, &genesis.path, &signers[1], + 2, + &epoch, &work_directories[1], &bundle_directories[1], false, @@ -980,6 +990,8 @@ async fn iroh_ceremony_resumes_after_validator_restart() -> TestResult { &ticket, &genesis.path, &signers[2], + 2, + &epoch, &work_directories[2], &bundle_directories[2], false, @@ -1000,6 +1012,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 c2cc261f1b..75d3b1ba22 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 9ceb955bcb..c20a9f1747 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 15a3a4a93eec5cbbe5f2402a53287524e258e74a 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/15] 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 6b6aa53324..9160ef9ffd 100644 --- a/bin/validator/src/commands/dkg/tests.rs +++ b/bin/validator/src/commands/dkg/tests.rs @@ -914,8 +914,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); @@ -950,6 +950,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::>(); @@ -965,7 +977,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, @@ -976,7 +988,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, @@ -987,7 +999,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 75d3b1ba22..f7a6e947df 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 c20a9f1747..8bb0cd1b9c 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 bb341a00436d3a6f47a37cd16e3996ee4fbf0532 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/15] 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 8bb0cd1b9c..40aaa7b46c 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 df1a32855564c67fa06789f4dc244838665f3e22 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/15] 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 745b83ffea..81c58636b6 100644 --- a/docs/external/src/network-operator/bootstrap-and-genesis.md +++ b/docs/external/src/network-operator/bootstrap-and-genesis.md @@ -151,15 +151,15 @@ configuration's `validators` list. ## 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 40aaa7b46c..f61418ff07 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 ee125e28d8df1e51d607e1b9f02d69398503ac08 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/15] 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 0ee1a12f155e0b645ea475bebae1154b92522431 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/15] 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(