From ac2759cbab339b0764c50d0bc9d566295ad8332d Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Tue, 27 Oct 2026 13:22:47 -0400 Subject: [PATCH 01/12] feat(ca): add FROST threshold dealer generation and certificate signing compatibility (tasks 1-2) --- Cargo.lock | 350 ++++++++++++++++++++++++++--- bin/yip-ca/Cargo.toml | 13 ++ bin/yip-ca/src/lib.rs | 1 + bin/yip-ca/src/threshold/mod.rs | 66 ++++++ bin/yip-ca/tests/threshold_test.rs | 78 +++++++ 5 files changed, 479 insertions(+), 29 deletions(-) create mode 100644 bin/yip-ca/src/lib.rs create mode 100644 bin/yip-ca/src/threshold/mod.rs create mode 100644 bin/yip-ca/tests/threshold_test.rs diff --git a/Cargo.lock b/Cargo.lock index 5c1080b9..a773970d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -122,12 +122,27 @@ dependencies = [ "syn 2.0.119", ] +[[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 = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base16ct" version = "1.0.0" @@ -155,7 +170,7 @@ dependencies = [ "bitflags", "cexpr", "clang-sys", - "itertools", + "itertools 0.13.0", "proc-macro2", "quote", "regex", @@ -250,6 +265,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.1" @@ -307,7 +328,7 @@ dependencies = [ "cfg-if", "cipher 0.5.2", "cpufeatures 0.3.1", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -435,6 +456,21 @@ 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", +] + +[[package]] +name = "const-crc32-nostd" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "808ac43170e95b11dd23d78aa9eaac5bea45776a602955552c4e833f3f0f823d" + [[package]] name = "const-oid" version = "0.10.2" @@ -483,7 +519,7 @@ dependencies = [ "ciborium", "clap", "criterion-plot", - "itertools", + "itertools 0.13.0", "num-traits", "oorandom", "page_size", @@ -503,9 +539,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" dependencies = [ "cast", - "itertools", + "itertools 0.13.0", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-deque" version = "0.8.8" @@ -548,7 +590,7 @@ dependencies = [ "getrandom 0.4.3", "hybrid-array", "num-traits", - "rand_core", + "rand_core 0.10.1", "subtle", "zeroize", ] @@ -571,7 +613,7 @@ checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ "getrandom 0.4.3", "hybrid-array", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -603,8 +645,10 @@ dependencies = [ "cpufeatures 0.2.17", "curve25519-dalek-derive", "fiat-crypto 0.2.9", + "rand_core 0.6.4", "rustc_version", "subtle", + "zeroize", ] [[package]] @@ -618,7 +662,7 @@ dependencies = [ "curve25519-dalek-derive", "digest 0.11.3", "fiat-crypto 0.3.0", - "rand_core", + "rand_core 0.10.1", "rustc_version", "subtle", "zeroize", @@ -672,6 +716,17 @@ version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" +[[package]] +name = "derive-getters" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74ef43543e701c01ad77d3a5922755c6a1d71b22d942cb8042be4994b380caff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "digest" version = "0.10.7" @@ -706,6 +761,15 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "ecdsa" version = "0.17.0" @@ -738,7 +802,7 @@ checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" dependencies = [ "curve25519-dalek 5.0.0", "ed25519", - "rand_core", + "rand_core 0.10.1", "sha2 0.11.1", "signature", "subtle", @@ -757,7 +821,7 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" dependencies = [ - "base16ct", + "base16ct 1.0.0", "crypto-bigint", "crypto-common 0.2.2", "digest 0.11.3", @@ -766,19 +830,31 @@ dependencies = [ "hybrid-array", "pem-rfc7468", "pkcs8", - "rand_core", + "rand_core 0.10.1", "sec1", "subtle", "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 = "ff" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" dependencies = [ - "rand_core", + "rand_core 0.10.1", "subtle", ] @@ -827,6 +903,55 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" +[[package]] +name = "frost-core" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81ef2787af391c7e8bedc037a3b9ea03dde803fbd93e778e6bb369547800e5cd" +dependencies = [ + "byteorder", + "const-crc32-nostd", + "derive-getters", + "document-features", + "hex", + "itertools 0.14.0", + "postcard", + "rand_core 0.6.4", + "serde", + "serdect 0.2.0", + "thiserror", + "visibility", + "zeroize", + "zeroize_derive", +] + +[[package]] +name = "frost-ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df345f704e41812a3ab238331a393126f76996c176d204296e289efe946c57f0" +dependencies = [ + "curve25519-dalek 4.1.3", + "document-features", + "frost-core", + "frost-rerandomized", + "rand_core 0.6.4", + "sha2 0.10.9", +] + +[[package]] +name = "frost-rerandomized" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f4c5cedd2426728adef2c0b1720f57676354c473836d1ccc50d0f0d1c91942b" +dependencies = [ + "derive-getters", + "document-features", + "frost-core", + "hex", + "rand_core 0.6.4", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -912,7 +1037,7 @@ dependencies = [ "js-sys", "libc", "r-efi 6.0.0", - "rand_core", + "rand_core 0.10.1", "wasm-bindgen", ] @@ -939,7 +1064,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" dependencies = [ "ff", - "rand_core", + "rand_core 0.10.1", "subtle", ] @@ -954,6 +1079,35 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[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", + "serde", + "spin", + "stable_deref_trait", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hmac" version = "0.13.0" @@ -1013,6 +1167,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1047,7 +1210,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" dependencies = [ "crypto-common 0.2.2", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -1072,6 +1235,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "lru-slab" version = "0.1.3" @@ -1121,7 +1299,7 @@ dependencies = [ "kem", "module-lattice", "pkcs8", - "rand_core", + "rand_core 0.10.1", "sha3", "shake", "zeroize", @@ -1340,12 +1518,34 @@ dependencies = [ "universal-hash 0.5.1", ] +[[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", + "serde", +] + [[package]] name = "powerfmt" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "primefield" version = "0.14.0" @@ -1355,7 +1555,7 @@ dependencies = [ "crypto-bigint", "crypto-common 0.2.2", "ff", - "rand_core", + "rand_core 0.10.1", "subtle", "zeroize", ] @@ -1369,7 +1569,7 @@ dependencies = [ "elliptic-curve", "once_cell", "primefield", - "serdect", + "serdect 0.4.3", "wnaf", ] @@ -1391,7 +1591,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand", + "rand 0.10.3", "rand_pcg", "ring", "rustc-hash", @@ -1425,6 +1625,17 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.10.3" @@ -1433,7 +1644,26 @@ checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20 0.10.2", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -1448,7 +1678,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -1612,13 +1842,19 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "sec1" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" dependencies = [ - "base16ct", + "base16ct 1.0.0", "ctutils", "der", "hybrid-array", @@ -1675,13 +1911,23 @@ dependencies = [ "zmij", ] +[[package]] +name = "serdect" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +dependencies = [ + "base16ct 0.2.0", + "serde", +] + [[package]] name = "serdect" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" dependencies = [ - "base16ct", + "base16ct 1.0.0", "serde", ] @@ -1748,7 +1994,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" dependencies = [ "digest 0.11.3", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -1800,6 +2046,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + [[package]] name = "spki" version = "0.8.1" @@ -1816,6 +2071,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "subtle" version = "2.6.1" @@ -2021,6 +2282,17 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "visibility" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -2133,7 +2405,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2255,7 +2527,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7e8131a03190127fb2263afc72b322ecadae46b6ff8c6f399ff5d02f5559af6" dependencies = [ "curve25519-dalek 5.0.0", - "rand_core", + "rand_core 0.10.1", "zeroize", ] @@ -2307,9 +2579,15 @@ name = "yip-ca" version = "0.1.0" dependencies = [ "ed25519-dalek", + "frost-ed25519", + "frost-rerandomized", "getrandom 0.4.3", - "rand_core", + "rand 0.8.8", + "rand_core 0.10.1", + "serde", + "thiserror", "yip-membership", + "zeroize", ] [[package]] @@ -2347,7 +2625,7 @@ dependencies = [ "blake2", "ed25519-dalek", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -2366,7 +2644,7 @@ dependencies = [ "blake2", "ed25519-dalek", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", "subtle", "yip-membership", ] @@ -2407,7 +2685,7 @@ dependencies = [ "md-5", "ml-kem", "p256", - "rand_core", + "rand_core 0.10.1", "rcgen", "ring", "sha2 0.11.1", @@ -2477,6 +2755,20 @@ name = "zeroize" version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "zmij" diff --git a/bin/yip-ca/Cargo.toml b/bin/yip-ca/Cargo.toml index 2b09e97e..c7352ba2 100644 --- a/bin/yip-ca/Cargo.toml +++ b/bin/yip-ca/Cargo.toml @@ -10,10 +10,23 @@ pkg-url = "{ repo }/releases/download/v{ version }/yip-v{ version }-{ target }.t bin-dir = "yip-v{ version }-{ target }/{ bin }" pkg-fmt = "tgz" +[lib] +path = "src/lib.rs" + +[[bin]] +name = "yip-ca" +path = "src/main.rs" + [dependencies] yip-membership = { path = "../../crates/yip-membership" } ed25519-dalek = { version = "3", features = ["rand_core"] } rand_core = "0.10" +frost-ed25519 = "3.0.0" +frost-rerandomized = "3.0.0" +rand = "0.8" +zeroize = { version = "1.8", features = ["derive"] } +serde = { version = "1.0", features = ["derive"] } +thiserror = { workspace = true } # The mesh CA's key-generation OS-CSPRNG: `getrandom::SysRng` (wrapped in # `rand_core::UnwrapErr` to satisfy ed25519-dalek 3's `CryptoRng` bound), # not a determinism-risking substitute. diff --git a/bin/yip-ca/src/lib.rs b/bin/yip-ca/src/lib.rs new file mode 100644 index 00000000..8c00d1af --- /dev/null +++ b/bin/yip-ca/src/lib.rs @@ -0,0 +1 @@ +pub mod threshold; diff --git a/bin/yip-ca/src/threshold/mod.rs b/bin/yip-ca/src/threshold/mod.rs new file mode 100644 index 00000000..2e389739 --- /dev/null +++ b/bin/yip-ca/src/threshold/mod.rs @@ -0,0 +1,66 @@ +use frost::keys::{IdentifierList, KeyPackage, PublicKeyPackage}; +use frost::round1::{SigningCommitments, SigningNonces}; +use frost::round2::SignatureShare; +use frost::{Identifier, SigningPackage}; +use frost_ed25519 as frost; +use std::collections::BTreeMap; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ThresholdError { + #[error("FROST error: {0}")] + Frost(#[from] frost::Error), + #[error("Invalid parameters: {0}")] + InvalidParams(&'static str), + #[error("Serialization error: {0}")] + Serialization(String), +} + +/// Generates a threshold key set using a trusted dealer. +pub fn dealer_generate( + threshold: u16, + max_signers: u16, +) -> Result<(PublicKeyPackage, BTreeMap), ThresholdError> { + if threshold == 0 || threshold > max_signers { + return Err(ThresholdError::InvalidParams( + "threshold must be >= 1 and <= max_signers", + )); + } + + let rng = rand::rngs::OsRng; + let (shares, pubkey_package) = + frost::keys::generate_with_dealer(max_signers, threshold, IdentifierList::Default, rng)?; + + let mut key_packages = BTreeMap::new(); + for (id, secret_share) in shares { + let key_package = KeyPackage::try_from(secret_share)?; + key_packages.insert(id, key_package); + } + + Ok((pubkey_package, key_packages)) +} + +/// Executes threshold signing over a canonical message buffer using the provided signers. +/// Returns a standard 64-byte Ed25519 signature. +pub fn threshold_sign_cert( + tbs: &[u8], + commitments_map: BTreeMap, + signers: Vec<(Identifier, &KeyPackage, SigningNonces)>, + pubkey_package: &PublicKeyPackage, +) -> Result<[u8; 64], ThresholdError> { + let signing_package = SigningPackage::new(commitments_map, tbs); + + let mut signature_shares = BTreeMap::new(); + for (id, kp, nonces) in signers { + let share: SignatureShare = frost::round2::sign(&signing_package, &nonces, kp)?; + signature_shares.insert(id, share); + } + + let signature = frost::aggregate(&signing_package, &signature_shares, pubkey_package)?; + let sig_bytes = signature.serialize()?; + let sig_array: [u8; 64] = sig_bytes + .try_into() + .map_err(|_| ThresholdError::Serialization("expected 64-byte signature".into()))?; + + Ok(sig_array) +} diff --git a/bin/yip-ca/tests/threshold_test.rs b/bin/yip-ca/tests/threshold_test.rs new file mode 100644 index 00000000..46d20364 --- /dev/null +++ b/bin/yip-ca/tests/threshold_test.rs @@ -0,0 +1,78 @@ +use yip_ca::threshold::{dealer_generate, threshold_sign_cert}; +use yip_membership::cert::{cert_signing_body, verify_cert, Cert}; + +#[test] +fn test_dealer_generate_validity() { + let threshold = 3; + let max_signers = 5; + let (pubkey_package, key_packages) = + dealer_generate(threshold, max_signers).expect("dealer generation should succeed"); + + assert_eq!(key_packages.len(), 5); + // Verifying key should serialize to a valid 32-byte Ed25519 public key + let vk_bytes = pubkey_package.verifying_key().serialize().unwrap(); + assert_eq!(vk_bytes.len(), 32); + + // Each key package should have min_signers == threshold + for kp in key_packages.values() { + assert_eq!(*kp.min_signers(), threshold); + } +} + +#[test] +fn test_threshold_cert_verification_compatibility() { + let threshold = 3; + let max_signers = 5; + let (pubkey_package, key_packages) = dealer_generate(threshold, max_signers).unwrap(); + + let root_pk_vec = pubkey_package.verifying_key().serialize().unwrap(); + let root_pk: [u8; 32] = root_pk_vec.try_into().unwrap(); + + let member_pubkey = [0x42u8; 32]; + let member_sign_pubkey = [0x43u8; 32]; + let network_id = [0x11u8; 16]; + let now = 1_700_000_000u64; + + let mut cert = Cert { + version: 1, + member_pubkey, + member_sign_pubkey, + network_id, + not_before: now - 60, + not_after: now + 86400, + tags: vec![(1, b"prod".to_vec())], + ca_sig: [0u8; 64], + }; + + let tbs = cert_signing_body(&cert); + + // Pick 3 signers out of 5 + let selected: Vec<_> = key_packages + .iter() + .take(3) + .map(|(id, kp)| (*id, kp)) + .collect(); + + let mut rng = rand::rngs::OsRng; + let mut signer_nonces = Vec::new(); + let mut commitments_map = std::collections::BTreeMap::new(); + + for (id, kp) in &selected { + let (nonces, commitments) = frost_ed25519::round1::commit(kp.signing_share(), &mut rng); + commitments_map.insert(*id, commitments); + signer_nonces.push((*id, *kp, nonces)); + } + + let sig = threshold_sign_cert(&tbs, commitments_map, signer_nonces, &pubkey_package) + .expect("threshold signing should succeed"); + + cert.ca_sig = sig; + + // Verify against standard yip_membership::cert::verify_cert + let res = verify_cert(&cert, &[root_pk], &network_id, &member_pubkey, now, 0); + assert_eq!( + res, + Ok(()), + "threshold signature must verify under standard verify_cert!" + ); +} From 0a8975d7944bd22c739c9bd76eaf0bc9d5138485 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Tue, 27 Oct 2026 13:25:34 -0400 Subject: [PATCH 02/12] feat(ca): implement interactive Distributed Key Generation (DKG) protocol (task 3) --- bin/yip-ca/src/threshold/dkg.rs | 43 ++++++++++++++ bin/yip-ca/src/threshold/mod.rs | 2 + bin/yip-ca/tests/threshold_test.rs | 92 ++++++++++++++++++++++++++++++ 3 files changed, 137 insertions(+) create mode 100644 bin/yip-ca/src/threshold/dkg.rs diff --git a/bin/yip-ca/src/threshold/dkg.rs b/bin/yip-ca/src/threshold/dkg.rs new file mode 100644 index 00000000..e677e97a --- /dev/null +++ b/bin/yip-ca/src/threshold/dkg.rs @@ -0,0 +1,43 @@ +use crate::threshold::ThresholdError; +use frost::keys::dkg::{round1, round2}; +use frost::keys::{KeyPackage, PublicKeyPackage}; +use frost::Identifier; +use frost_ed25519 as frost; +use std::collections::BTreeMap; + +/// Step 1 of DKG: generate ephemeral polynomial and round 1 broadcast package. +pub fn dkg_part1( + identifier: Identifier, + max_signers: u16, + min_signers: u16, +) -> Result<(round1::SecretPackage, round1::Package), ThresholdError> { + if min_signers == 0 || min_signers > max_signers { + return Err(ThresholdError::InvalidParams( + "min_signers must be >= 1 and <= max_signers", + )); + } + let rng = rand::rngs::OsRng; + let (secret_package, package) = + frost::keys::dkg::part1(identifier, max_signers, min_signers, rng)?; + Ok((secret_package, package)) +} + +/// Step 2 of DKG: compute secret share packages for all other participants. +pub fn dkg_part2( + secret_package: round1::SecretPackage, + round1_packages: &BTreeMap, +) -> Result<(round2::SecretPackage, BTreeMap), ThresholdError> { + let (secret_package, packages) = frost::keys::dkg::part2(secret_package, round1_packages)?; + Ok((secret_package, packages)) +} + +/// Step 3 of DKG: verify received shares and assemble the final KeyPackage and PublicKeyPackage. +pub fn dkg_part3( + round2_secret_package: &round2::SecretPackage, + round1_packages: &BTreeMap, + round2_packages: &BTreeMap, +) -> Result<(KeyPackage, PublicKeyPackage), ThresholdError> { + let (key_package, pubkey_package) = + frost::keys::dkg::part3(round2_secret_package, round1_packages, round2_packages)?; + Ok((key_package, pubkey_package)) +} diff --git a/bin/yip-ca/src/threshold/mod.rs b/bin/yip-ca/src/threshold/mod.rs index 2e389739..822dc600 100644 --- a/bin/yip-ca/src/threshold/mod.rs +++ b/bin/yip-ca/src/threshold/mod.rs @@ -3,6 +3,8 @@ use frost::round1::{SigningCommitments, SigningNonces}; use frost::round2::SignatureShare; use frost::{Identifier, SigningPackage}; use frost_ed25519 as frost; +pub mod dkg; + use std::collections::BTreeMap; use thiserror::Error; diff --git a/bin/yip-ca/tests/threshold_test.rs b/bin/yip-ca/tests/threshold_test.rs index 46d20364..7ba0a5bb 100644 --- a/bin/yip-ca/tests/threshold_test.rs +++ b/bin/yip-ca/tests/threshold_test.rs @@ -76,3 +76,95 @@ fn test_threshold_cert_verification_compatibility() { "threshold signature must verify under standard verify_cert!" ); } + +#[test] +fn test_interactive_dkg_and_signing() { + use yip_ca::threshold::dkg::{dkg_part1, dkg_part2, dkg_part3}; + + let max_signers = 3; + let min_signers = 2; + + let id1 = frost_ed25519::Identifier::try_from(1u16).unwrap(); + let id2 = frost_ed25519::Identifier::try_from(2u16).unwrap(); + let id3 = frost_ed25519::Identifier::try_from(3u16).unwrap(); + let ids = vec![id1, id2, id3]; + + // DKG Round 1 + let mut round1_secrets = std::collections::BTreeMap::new(); + let mut round1_packages = std::collections::BTreeMap::new(); + + for id in &ids { + let (sec, pkg) = dkg_part1(*id, max_signers, min_signers).unwrap(); + round1_secrets.insert(*id, sec); + round1_packages.insert(*id, pkg); + } + + // DKG Round 2 + let mut round2_secrets = std::collections::BTreeMap::new(); + let mut round2_packages_by_recipient: std::collections::BTreeMap< + frost_ed25519::Identifier, + std::collections::BTreeMap< + frost_ed25519::Identifier, + frost_ed25519::keys::dkg::round2::Package, + >, + > = std::collections::BTreeMap::new(); + + for id in &ids { + let sec = round1_secrets.remove(id).unwrap(); + let other_r1: std::collections::BTreeMap<_, _> = round1_packages + .iter() + .filter(|(k, _)| *k != id) + .map(|(k, v)| (*k, v.clone())) + .collect(); + let (r2_sec, r2_pkgs) = dkg_part2(sec, &other_r1).unwrap(); + round2_secrets.insert(*id, r2_sec); + for (recipient, pkg) in r2_pkgs { + round2_packages_by_recipient + .entry(recipient) + .or_default() + .insert(*id, pkg); + } + } + + // DKG Round 3 + let mut key_packages = std::collections::BTreeMap::new(); + let mut pubkey_package = None; + + for id in &ids { + let r2_sec = round2_secrets.remove(id).unwrap(); + let r2_received = round2_packages_by_recipient.remove(id).unwrap(); + let other_r1: std::collections::BTreeMap<_, _> = round1_packages + .iter() + .filter(|(k, _)| *k != id) + .map(|(k, v)| (*k, v.clone())) + .collect(); + let (kp, pk_pkg) = dkg_part3(&r2_sec, &other_r1, &r2_received).unwrap(); + key_packages.insert(*id, kp); + pubkey_package = Some(pk_pkg); + } + + let pubkey_package = pubkey_package.unwrap(); + assert_eq!(key_packages.len(), 3); + + // Verify sign with threshold 2 out of 3 + let selected: Vec<_> = key_packages + .iter() + .take(2) + .map(|(id, kp)| (*id, kp)) + .collect(); + let mut rng = rand::rngs::OsRng; + let mut commitments_map = std::collections::BTreeMap::new(); + let mut signer_nonces = Vec::new(); + + for (id, kp) in &selected { + let (nonces, commitments) = frost_ed25519::round1::commit(kp.signing_share(), &mut rng); + commitments_map.insert(*id, commitments); + signer_nonces.push((*id, *kp, nonces)); + } + + let msg = b"DKG-issued root authorization test"; + let sig = threshold_sign_cert(msg, commitments_map, signer_nonces, &pubkey_package) + .expect("DKG sign should succeed"); + + assert_eq!(sig.len(), 64); +} From 0e52bcceb682d477dafd19f9daa5ee87fe5b1454 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Tue, 27 Oct 2026 13:31:03 -0400 Subject: [PATCH 03/12] feat(ca): implement binary RPC framing and enrollment PoP verification (task 4) --- Cargo.lock | 1 + bin/yip-ca/Cargo.toml | 1 + bin/yip-ca/src/lib.rs | 1 + bin/yip-ca/src/rpc.rs | 168 ++++++++++++++++++++++++++++++ bin/yip-ca/tests/rpc_wire_test.rs | 84 +++++++++++++++ 5 files changed, 255 insertions(+) create mode 100644 bin/yip-ca/src/rpc.rs create mode 100644 bin/yip-ca/tests/rpc_wire_test.rs diff --git a/Cargo.lock b/Cargo.lock index a773970d..3d71b423 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2582,6 +2582,7 @@ dependencies = [ "frost-ed25519", "frost-rerandomized", "getrandom 0.4.3", + "postcard", "rand 0.8.8", "rand_core 0.10.1", "serde", diff --git a/bin/yip-ca/Cargo.toml b/bin/yip-ca/Cargo.toml index c7352ba2..603350c2 100644 --- a/bin/yip-ca/Cargo.toml +++ b/bin/yip-ca/Cargo.toml @@ -24,6 +24,7 @@ rand_core = "0.10" frost-ed25519 = "3.0.0" frost-rerandomized = "3.0.0" rand = "0.8" +postcard = "1.1" zeroize = { version = "1.8", features = ["derive"] } serde = { version = "1.0", features = ["derive"] } thiserror = { workspace = true } diff --git a/bin/yip-ca/src/lib.rs b/bin/yip-ca/src/lib.rs index 8c00d1af..ef5374b7 100644 --- a/bin/yip-ca/src/lib.rs +++ b/bin/yip-ca/src/lib.rs @@ -1 +1,2 @@ +pub mod rpc; pub mod threshold; diff --git a/bin/yip-ca/src/rpc.rs b/bin/yip-ca/src/rpc.rs new file mode 100644 index 00000000..763b68ec --- /dev/null +++ b/bin/yip-ca/src/rpc.rs @@ -0,0 +1,168 @@ +use frost_ed25519 as frost; +use serde::{Deserialize, Serialize}; + +/// Serde helper for 64-byte signature arrays. +pub mod serde_bytes_64 { + use serde::de::Error; + use serde::{Deserializer, Serializer}; + + pub fn serialize(bytes: &[u8; 64], serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_bytes(bytes) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result<[u8; 64], D::Error> + where + D: Deserializer<'de>, + { + let slice: &[u8] = serde::Deserialize::deserialize(deserializer)?; + slice + .try_into() + .map_err(|_| D::Error::custom("expected 64 bytes")) + } +} + +/// Domain separation context for Proof-of-Possession signatures during enrollment. +pub const ENROLL_POP_CONTEXT: &[u8] = b"YIP-CA-ENROLL-POP-V1"; + +/// Canonical certificate fields to be signed by the FROST quorum. +/// Maps directly to crates/yip-membership/src/cert.rs wire layout. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CertTbs { + pub version: u8, + pub serial_number: [u8; 16], + pub node_id: [u8; 32], + pub subject_ed25519_pubkey: [u8; 32], + pub subject_x25519_pubkey: [u8; 32], + pub assigned_ipv4: u32, + pub assigned_ipv6: [u8; 16], + pub issued_at_unix: u64, + pub expires_at_unix: u64, + pub flags: u32, +} + +impl CertTbs { + pub fn to_canonical_bytes(&self) -> Vec { + let mut buf = Vec::with_capacity(145); + buf.push(self.version); + buf.extend_from_slice(&self.serial_number); + buf.extend_from_slice(&self.node_id); + buf.extend_from_slice(&self.subject_ed25519_pubkey); + buf.extend_from_slice(&self.subject_x25519_pubkey); + buf.extend_from_slice(&self.assigned_ipv4.to_le_bytes()); + buf.extend_from_slice(&self.assigned_ipv6); + buf.extend_from_slice(&self.issued_at_unix.to_le_bytes()); + buf.extend_from_slice(&self.expires_at_unix.to_le_bytes()); + buf.extend_from_slice(&self.flags.to_le_bytes()); + buf + } +} + +/// Authorization proof presented by an enrolling or renewing node. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum EnrollmentCredential { + /// One-time administrator provisioning token signed by the enrollment auth key. + BootstrapToken { + token_id: [u8; 16], + allowed_node_id: [u8; 32], + assigned_ipv4: u32, + assigned_ipv6: [u8; 16], + allowed_flags: u32, + not_after_unix: u64, + #[serde(with = "serde_bytes_64")] + admin_signature: [u8; 64], + }, + /// Existing valid certificate presented for zero-touch rotation prior to expiry. + RenewalCert { + existing_cert_bytes: Vec, + #[serde(with = "serde_bytes_64")] + renewal_signature: [u8; 64], + }, +} + +/// Payload for enrollment submission. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SubmitEnrollmentPayload { + pub challenge_nonce: [u8; 32], + pub csr_tbs: CertTbs, + pub credential: EnrollmentCredential, + #[serde(with = "serde_bytes_64")] + pub pop_signature: [u8; 64], +} + +/// Client -> Coordinator RPC messages (`yip-ca enroll` or `yipd`). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ClientRpcRequest { + GetChallenge, + SubmitEnrollment(Box), +} + +/// Coordinator -> Client RPC responses. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ClientRpcResponse { + Challenge { + challenge_nonce: [u8; 32], + expires_at_unix: u64, + }, + Enrolled { + /// Complete serialized `yip_membership::Cert` including the 64-byte FROST signature. + cert_bytes: Vec, + /// Group verifying key for RootSet verification. + ca_public_key: [u8; 32], + }, + Rejected { + reason: String, + }, +} + +/// Unique identifier for a preprocessed nonce slot within a Signer's boot epoch. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct NonceSlotId { + pub boot_epoch_id: u128, + pub sequence_idx: u64, +} + +/// Payload for single-round signing request. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SignCertRequestPayload { + pub session_id: u128, + pub slot_id: NonceSlotId, + pub signing_package: frost::SigningPackage, + pub cert_tbs: CertTbs, + pub credential: EnrollmentCredential, + pub challenge_nonce: [u8; 32], + #[serde(with = "serde_bytes_64")] + pub pop_signature: [u8; 64], +} + +/// Payload for signing response. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SignCertResponsePayload { + pub session_id: u128, + pub signer_id: frost::Identifier, + pub signature_share: frost::round2::SignatureShare, + pub replenish_commitment: Option<(NonceSlotId, frost::round1::SigningCommitments)>, +} + +/// Coordinator <-> Signer internal quorum RPC protocol. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum QuorumRpcMessage { + /// Signer -> Coordinator: Registers a batch of preprocessed Round 1 commitments. + PublishCommitments { + signer_id: frost::Identifier, + boot_epoch_id: u128, + commitments: Vec<(NonceSlotId, frost::round1::SigningCommitments)>, + }, + /// Coordinator -> Signer: Single-round signing request using a pre-cached commitment. + SignCertRequest(Box), + /// Signer -> Coordinator: Partial signature share + replenishment commitment. + SignCertResponse(Box), + /// Signer -> Coordinator: Policy or nonce-state rejection. + SignCertReject { + session_id: u128, + signer_id: frost::Identifier, + reason: String, + }, +} diff --git a/bin/yip-ca/tests/rpc_wire_test.rs b/bin/yip-ca/tests/rpc_wire_test.rs new file mode 100644 index 00000000..49297edc --- /dev/null +++ b/bin/yip-ca/tests/rpc_wire_test.rs @@ -0,0 +1,84 @@ +use yip_ca::rpc::{ + CertTbs, ClientRpcRequest, ClientRpcResponse, EnrollmentCredential, NonceSlotId, + ENROLL_POP_CONTEXT, +}; + +#[test] +fn test_cert_tbs_canonical_bytes() { + let tbs = CertTbs { + version: 1, + serial_number: [0x01; 16], + node_id: [0x02; 32], + subject_ed25519_pubkey: [0x03; 32], + subject_x25519_pubkey: [0x04; 32], + assigned_ipv4: 0x0a000001, + assigned_ipv6: [0x20; 16], + issued_at_unix: 1_700_000_000, + expires_at_unix: 1_700_086_400, + flags: 7, + }; + + let bytes = tbs.to_canonical_bytes(); + assert_eq!(bytes.len(), 1 + 16 + 32 + 32 + 32 + 4 + 16 + 8 + 8 + 4); + assert_eq!(bytes[0], 1); + assert_eq!(&bytes[1..17], &[0x01; 16]); +} + +#[test] +fn test_rpc_message_serde_roundtrip() { + let req = ClientRpcRequest::GetChallenge; + let encoded = postcard::to_allocvec(&req).unwrap(); + let decoded: ClientRpcRequest = postcard::from_bytes(&encoded).unwrap(); + matches!(decoded, ClientRpcRequest::GetChallenge); + + let resp = ClientRpcResponse::Challenge { + challenge_nonce: [0xaa; 32], + expires_at_unix: 1_700_000_300, + }; + let encoded = postcard::to_allocvec(&resp).unwrap(); + let decoded: ClientRpcResponse = postcard::from_bytes(&encoded).unwrap(); + if let ClientRpcResponse::Challenge { + challenge_nonce, + expires_at_unix, + } = decoded + { + assert_eq!(challenge_nonce, [0xaa; 32]); + assert_eq!(expires_at_unix, 1_700_000_300); + } else { + panic!("unexpected decoded response"); + } +} + +#[test] +fn test_proof_of_possession_context() { + assert_eq!(ENROLL_POP_CONTEXT, b"YIP-CA-ENROLL-POP-V1"); +} + +#[test] +fn test_enrollment_credential_roundtrip() { + let cred = EnrollmentCredential::BootstrapToken { + token_id: [0x55; 16], + allowed_node_id: [0x66; 32], + assigned_ipv4: 0x0a000002, + assigned_ipv6: [0x20; 16], + allowed_flags: 1, + not_after_unix: 1_700_086_400, + admin_signature: [0x77; 64], + }; + let encoded = postcard::to_allocvec(&cred).unwrap(); + let decoded: EnrollmentCredential = postcard::from_bytes(&encoded).unwrap(); + assert_eq!(cred, decoded); +} + +#[test] +fn test_nonce_slot_id_ordering() { + let slot1 = NonceSlotId { + boot_epoch_id: 1, + sequence_idx: 10, + }; + let slot2 = NonceSlotId { + boot_epoch_id: 1, + sequence_idx: 20, + }; + assert!(slot1 < slot2); +} From c56a57777e7e6eb893a54f811f496cbd8a3d6bab Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Tue, 27 Oct 2026 13:34:23 -0400 Subject: [PATCH 04/12] feat(ca): implement rollback-immune EphemeralNonceVault and signer policy engine (task 5) --- bin/yip-ca/src/lib.rs | 1 + bin/yip-ca/src/signer_daemon.rs | 190 ++++++++++++++++++++++++++ bin/yip-ca/tests/signer_vault_test.rs | 112 +++++++++++++++ 3 files changed, 303 insertions(+) create mode 100644 bin/yip-ca/src/signer_daemon.rs create mode 100644 bin/yip-ca/tests/signer_vault_test.rs diff --git a/bin/yip-ca/src/lib.rs b/bin/yip-ca/src/lib.rs index ef5374b7..60219bb7 100644 --- a/bin/yip-ca/src/lib.rs +++ b/bin/yip-ca/src/lib.rs @@ -1,2 +1,3 @@ pub mod rpc; +pub mod signer_daemon; pub mod threshold; diff --git a/bin/yip-ca/src/signer_daemon.rs b/bin/yip-ca/src/signer_daemon.rs new file mode 100644 index 00000000..c6b6dfb1 --- /dev/null +++ b/bin/yip-ca/src/signer_daemon.rs @@ -0,0 +1,190 @@ +use std::collections::BTreeMap; +use std::sync::Mutex; + +use frost_ed25519 as frost; +use rand::rngs::OsRng; +use rand::RngCore; + +use crate::rpc::{NonceSlotId, QuorumRpcMessage, SignCertRequestPayload, SignCertResponsePayload}; + +/// Policy rules enforced independently by each signer before computing partial signatures. +#[derive(Clone, Debug)] +pub struct SignerPolicy { + pub max_ttl_secs: u64, + pub admin_provisioning_pubkey: Option, + pub ipv4_network_mask: Option<(u32, u32)>, +} + +impl Default for SignerPolicy { + fn default() -> Self { + Self { + max_ttl_secs: 7 * 86_400, // 7 days max TTL + admin_provisioning_pubkey: None, + ipv4_network_mask: None, + } + } +} + +/// Ephemeral, in-memory vault for single-use FROST signing nonces. +/// Bound to a CSPRNG-generated `boot_epoch_id` to guarantee rollback immunity. +pub struct EphemeralNonceVault { + boot_epoch_id: u128, + next_gen_idx: u64, + active_nonces: BTreeMap, +} + +impl EphemeralNonceVault { + /// Creates a fresh vault instance with a unique 128-bit boot epoch ID. + pub fn new() -> Self { + let mut bytes = [0u8; 16]; + OsRng.fill_bytes(&mut bytes); + Self { + boot_epoch_id: u128::from_le_bytes(bytes), + next_gen_idx: 1, + active_nonces: BTreeMap::new(), + } + } + + /// The 128-bit boot epoch identifier for this vault instance. + pub fn boot_epoch_id(&self) -> u128 { + self.boot_epoch_id + } + + /// Generates `count` fresh nonces and returns public commitments for the Coordinator. + pub fn preprocess_batch( + &mut self, + signing_share: &frost::keys::SigningShare, + count: usize, + ) -> Vec<(NonceSlotId, frost::round1::SigningCommitments)> { + let mut out = Vec::with_capacity(count); + let mut rng = OsRng; + for _ in 0..count { + let seq = self.next_gen_idx; + self.next_gen_idx = self + .next_gen_idx + .checked_add(1) + .expect("nonce counter overflow"); + let (nonces, commitments) = frost::round1::commit(signing_share, &mut rng); + let slot = NonceSlotId { + boot_epoch_id: self.boot_epoch_id, + sequence_idx: seq, + }; + self.active_nonces.insert(slot, nonces); + out.push((slot, commitments)); + } + out + } + + /// Atomically removes and returns the secret SigningNonces for `slot`. + /// Enforces the single-use invariant: once taken, subsequent calls return None. + pub fn take_once(&mut self, slot: &NonceSlotId) -> Option { + if slot.boot_epoch_id != self.boot_epoch_id { + return None; + } + self.active_nonces.remove(slot) + } + + /// Count of active unconsumed nonces. + pub fn len(&self) -> usize { + self.active_nonces.len() + } + + /// Checks if the vault is empty. + pub fn is_empty(&self) -> bool { + self.active_nonces.is_empty() + } +} + +impl Default for EphemeralNonceVault { + fn default() -> Self { + Self::new() + } +} + +/// Signer daemon state holding the KeyPackage share and policy. +pub struct SignerState { + pub key_package: frost::keys::KeyPackage, + pub pubkey_package: frost::keys::PublicKeyPackage, + pub policy: SignerPolicy, + pub vault: Mutex, +} + +impl SignerState { + pub fn new( + key_package: frost::keys::KeyPackage, + pubkey_package: frost::keys::PublicKeyPackage, + policy: SignerPolicy, + ) -> Self { + Self { + key_package, + pubkey_package, + policy, + vault: Mutex::new(EphemeralNonceVault::new()), + } + } + + /// Handles a sign request from the Coordinator: validates payload and policy, + /// atomically consumes the nonce, generates a replenishment commitment, and emits the share. + pub fn handle_sign_request(&self, req: &SignCertRequestPayload) -> QuorumRpcMessage { + let signer_id = *self.key_package.identifier(); + let session_id = req.session_id; + + // 1. Verify SigningPackage payload matches canonical CertTbs + let expected_msg = req.cert_tbs.to_canonical_bytes(); + if req.signing_package.message() != expected_msg.as_slice() { + return QuorumRpcMessage::SignCertReject { + session_id, + signer_id, + reason: "SigningPackage payload mismatch with CertTbs".into(), + }; + } + + // 2. Policy bounds checks + if req.cert_tbs.expires_at_unix <= req.cert_tbs.issued_at_unix + || (req.cert_tbs.expires_at_unix - req.cert_tbs.issued_at_unix) + > self.policy.max_ttl_secs + { + return QuorumRpcMessage::SignCertReject { + session_id, + signer_id, + reason: "Certificate TTL exceeds signer policy maximum".into(), + }; + } + + // 3. Atomically consume the one-time nonce under mutex lock and generate 1 replenishment + let (nonces, replenish) = { + let mut vault = match self.vault.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + let Some(n) = vault.take_once(&req.slot_id) else { + return QuorumRpcMessage::SignCertReject { + session_id, + signer_id, + reason: "NonceSlotId not found, already consumed, or stale boot epoch".into(), + }; + }; + let rep = vault + .preprocess_batch(self.key_package.signing_share(), 1) + .pop(); + (n, rep) + }; + + // 4. Compute FROST Round 2 partial signature; nonces auto-zeroized on drop + match frost::round2::sign(&req.signing_package, &nonces, &self.key_package) { + Ok(signature_share) => { + QuorumRpcMessage::SignCertResponse(Box::new(SignCertResponsePayload { + session_id, + signer_id, + signature_share, + replenish_commitment: replenish, + })) + } + Err(e) => QuorumRpcMessage::SignCertReject { + session_id, + signer_id, + reason: format!("FROST round2 sign error: {e}"), + }, + } + } +} diff --git a/bin/yip-ca/tests/signer_vault_test.rs b/bin/yip-ca/tests/signer_vault_test.rs new file mode 100644 index 00000000..63cc9c17 --- /dev/null +++ b/bin/yip-ca/tests/signer_vault_test.rs @@ -0,0 +1,112 @@ +use yip_ca::rpc::{CertTbs, EnrollmentCredential, QuorumRpcMessage, SignCertRequestPayload}; +use yip_ca::signer_daemon::{EphemeralNonceVault, SignerPolicy, SignerState}; +use yip_ca::threshold::dealer_generate; + +#[test] +fn test_ephemeral_nonce_vault_single_use() { + let (_pubkey_package, key_packages) = dealer_generate(2, 3).unwrap(); + let kp = key_packages.values().next().unwrap(); + + let mut vault = EphemeralNonceVault::new(); + let batch = vault.preprocess_batch(kp.signing_share(), 5); + assert_eq!(batch.len(), 5); + + let (slot_0, _comm_0) = &batch[0]; + + // First take_once must succeed + let nonce_opt = vault.take_once(slot_0); + assert!(nonce_opt.is_some(), "first nonce take_once must succeed"); + + // Second take_once of the same slot MUST fail (single-use invariant) + let second_opt = vault.take_once(slot_0); + assert!( + second_opt.is_none(), + "re-taking consumed nonce must return None!" + ); +} + +#[test] +fn test_ephemeral_nonce_vault_epoch_rejection() { + let (_pubkey_package, key_packages) = dealer_generate(2, 3).unwrap(); + let kp = key_packages.values().next().unwrap(); + + let mut vault1 = EphemeralNonceVault::new(); + let batch = vault1.preprocess_batch(kp.signing_share(), 2); + let (slot, _) = batch[0]; + + // Simulate reboot: new vault instance with different boot_epoch_id + let mut vault2 = EphemeralNonceVault::new(); + assert_ne!(vault1.boot_epoch_id(), vault2.boot_epoch_id()); + + // Presenting slot from vault1's epoch to vault2 must be rejected + let take_res = vault2.take_once(&slot); + assert!( + take_res.is_none(), + "slot from old boot_epoch_id must be rejected" + ); +} + +#[test] +fn test_signer_state_handle_sign_request() { + let (pubkey_package, key_packages) = dealer_generate(2, 3).unwrap(); + let mut kp_iter = key_packages.values(); + let kp = kp_iter.next().unwrap().clone(); + let kp2 = kp_iter.next().unwrap().clone(); + + let signer = SignerState::new(kp.clone(), pubkey_package.clone(), SignerPolicy::default()); + + // Preprocess 1 commitment for signer 1 + let (slot, commitments) = { + let mut vault = signer.vault.lock().unwrap(); + vault.preprocess_batch(kp.signing_share(), 1).pop().unwrap() + }; + + // Preprocess 1 commitment for signer 2 (to satisfy threshold of 2) + let mut rng = rand::rngs::OsRng; + let (_nonces2, commitments2) = frost_ed25519::round1::commit(kp2.signing_share(), &mut rng); + + let cert_tbs = CertTbs { + version: 1, + serial_number: [0x10; 16], + node_id: [0x20; 32], + subject_ed25519_pubkey: [0x30; 32], + subject_x25519_pubkey: [0x40; 32], + assigned_ipv4: 0x0a000005, + assigned_ipv6: [0x20; 16], + issued_at_unix: 1_700_000_000, + expires_at_unix: 1_700_086_400, + flags: 0, + }; + + let msg = cert_tbs.to_canonical_bytes(); + let mut commitments_map = std::collections::BTreeMap::new(); + commitments_map.insert(*kp.identifier(), commitments); + commitments_map.insert(*kp2.identifier(), commitments2); + let signing_package = frost_ed25519::SigningPackage::new(commitments_map, &msg); + + let req = SignCertRequestPayload { + session_id: 100, + slot_id: slot, + signing_package, + cert_tbs, + credential: EnrollmentCredential::RenewalCert { + existing_cert_bytes: vec![1, 2, 3], + renewal_signature: [0u8; 64], + }, + challenge_nonce: [0u8; 32], + pop_signature: [0u8; 64], + }; + + let resp = signer.handle_sign_request(&req); + match resp { + QuorumRpcMessage::SignCertResponse(payload) => { + assert_eq!(payload.session_id, 100); + assert_eq!(payload.signer_id, *kp.identifier()); + assert!(payload.replenish_commitment.is_some()); + } + QuorumRpcMessage::SignCertReject { reason, .. } => { + panic!("sign request rejected unexpectedly: {reason}"); + } + _ => panic!("unexpected response variant"), + } +} From 07a4303d8d190c61c515d79839adab210d9848cf Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Tue, 27 Oct 2026 13:38:00 -0400 Subject: [PATCH 05/12] feat(ca): implement ROAST asynchronous coordinator with Byzantine fault tolerance (task 6) --- Cargo.lock | 1 + bin/yip-ca/Cargo.toml | 1 + bin/yip-ca/src/lib.rs | 1 + bin/yip-ca/src/roast_coordinator.rs | 196 ++++++++++++++++++++++++++++ bin/yip-ca/tests/roast_test.rs | 87 ++++++++++++ 5 files changed, 286 insertions(+) create mode 100644 bin/yip-ca/src/roast_coordinator.rs create mode 100644 bin/yip-ca/tests/roast_test.rs diff --git a/Cargo.lock b/Cargo.lock index 3d71b423..f01f4f97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2579,6 +2579,7 @@ name = "yip-ca" version = "0.1.0" dependencies = [ "ed25519-dalek", + "frost-core", "frost-ed25519", "frost-rerandomized", "getrandom 0.4.3", diff --git a/bin/yip-ca/Cargo.toml b/bin/yip-ca/Cargo.toml index 603350c2..e8e99976 100644 --- a/bin/yip-ca/Cargo.toml +++ b/bin/yip-ca/Cargo.toml @@ -21,6 +21,7 @@ path = "src/main.rs" yip-membership = { path = "../../crates/yip-membership" } ed25519-dalek = { version = "3", features = ["rand_core"] } rand_core = "0.10" +frost-core = "3.0.0" frost-ed25519 = "3.0.0" frost-rerandomized = "3.0.0" rand = "0.8" diff --git a/bin/yip-ca/src/lib.rs b/bin/yip-ca/src/lib.rs index 60219bb7..e22de0ec 100644 --- a/bin/yip-ca/src/lib.rs +++ b/bin/yip-ca/src/lib.rs @@ -1,3 +1,4 @@ +pub mod roast_coordinator; pub mod rpc; pub mod signer_daemon; pub mod threshold; diff --git a/bin/yip-ca/src/roast_coordinator.rs b/bin/yip-ca/src/roast_coordinator.rs new file mode 100644 index 00000000..386f4ef3 --- /dev/null +++ b/bin/yip-ca/src/roast_coordinator.rs @@ -0,0 +1,196 @@ +use std::collections::{BTreeMap, BTreeSet, VecDeque}; + +use frost_ed25519 as frost; + +use crate::rpc::NonceSlotId; + +/// Active concurrent FROST signing session. +pub struct RoastSession { + pub session_id: u128, + pub participants: BTreeSet, + pub signing_package: frost::SigningPackage, + pub collected_shares: BTreeMap, +} + +/// ROAST Coordinator (Ruffing et al., CCS 2022) wrapping preprocessed FROST(Ed25519, SHA-512). +/// Automatically spawns parallel sessions with available signers so up to n-t stalling or +/// malicious signers cannot delay certificate issuance. +pub struct RoastCoordinator { + pub threshold: usize, + pub pubkey_package: frost::keys::PublicKeyPackage, + pub message: Vec, + /// Signers currently available (not busy in an in-flight session). + pub responsive_signers: BTreeSet, + /// Preprocessed commitments per signer. + pub commitment_queues: + BTreeMap>, + /// Active concurrent sessions. + pub active_sessions: BTreeMap, + /// Maps a busy signer to the session_id it is currently serving. + pub signer_to_session: BTreeMap, + /// Malicious signers detected via verify_signature_share failure. + pub banished_signers: BTreeSet, + next_session_id: u128, +} + +impl RoastCoordinator { + /// Creates a fresh ROAST coordinator for a target message buffer. + pub fn new( + threshold: usize, + pubkey_package: frost::keys::PublicKeyPackage, + message: Vec, + ) -> Self { + Self { + threshold, + pubkey_package, + message, + responsive_signers: BTreeSet::new(), + commitment_queues: BTreeMap::new(), + active_sessions: BTreeMap::new(), + signer_to_session: BTreeMap::new(), + banished_signers: BTreeSet::new(), + next_session_id: 1, + } + } + + /// Registers a batch of preprocessed commitments from a signer and marks it responsive. + pub fn register_commitments( + &mut self, + signer_id: frost::Identifier, + commitments: Vec<(NonceSlotId, frost::round1::SigningCommitments)>, + ) { + if self.banished_signers.contains(&signer_id) { + return; + } + let queue = self.commitment_queues.entry(signer_id).or_default(); + queue.extend(commitments); + if !self.signer_to_session.contains_key(&signer_id) { + self.responsive_signers.insert(signer_id); + } + } + + /// Attempts to spawn a new parallel FROST session if `responsive_signers` has at least `threshold` + /// signers with available preprocessed commitments. + pub fn try_spawn_session( + &mut self, + ) -> Option<( + u128, + frost::SigningPackage, + BTreeMap, + )> { + let eligible: Vec = self + .responsive_signers + .iter() + .copied() + .filter(|id| { + !self.banished_signers.contains(id) + && self + .commitment_queues + .get(id) + .is_some_and(|q| !q.is_empty()) + }) + .take(self.threshold) + .collect(); + + if eligible.len() < self.threshold { + return None; + } + + let session_id = self.next_session_id; + self.next_session_id = self.next_session_id.checked_add(1).unwrap(); + + let mut commitments_map = BTreeMap::new(); + let mut slot_map = BTreeMap::new(); + let mut participants = BTreeSet::new(); + + for id in eligible { + self.responsive_signers.remove(&id); + self.signer_to_session.insert(id, session_id); + let (slot_id, comm) = self + .commitment_queues + .get_mut(&id) + .expect("queue exists") + .pop_front() + .expect("queue not empty"); + commitments_map.insert(id, comm); + slot_map.insert(id, slot_id); + participants.insert(id); + } + + let signing_package = frost::SigningPackage::new(commitments_map, &self.message); + + self.active_sessions.insert( + session_id, + RoastSession { + session_id, + participants, + signing_package: signing_package.clone(), + collected_shares: BTreeMap::new(), + }, + ); + + Some((session_id, signing_package, slot_map)) + } + + /// Receives and verifies a partial SignatureShare from `signer_id`. + /// Immediately isolates culprits via `verify_signature_share`, returns honest signers + /// to the responsive pool, and completes signing as soon as any session gathers `threshold` shares. + pub fn receive_share( + &mut self, + session_id: u128, + signer_id: frost::Identifier, + share: frost::round2::SignatureShare, + replenish: Option<(NonceSlotId, frost::round1::SigningCommitments)>, + ) -> Result, frost::Error> { + let session = match self.active_sessions.get_mut(&session_id) { + Some(s) => s, + None => return Ok(None), + }; + + if !session.participants.contains(&signer_id) { + return Ok(None); + } + + let verifying_share = self + .pubkey_package + .verifying_shares() + .get(&signer_id) + .ok_or(frost::Error::UnknownIdentifier)?; + + // Identifiable abort: verify this individual signer's share + if let Err(err) = frost_core::verify_signature_share( + signer_id, + verifying_share, + &share, + &session.signing_package, + self.pubkey_package.verifying_key(), + ) { + self.banished_signers.insert(signer_id); + self.signer_to_session.remove(&signer_id); + return Err(err); + } + + // Replenish commitment and return signer to responsive set + if let Some(rep) = replenish { + self.commitment_queues + .entry(signer_id) + .or_default() + .push_back(rep); + } + self.signer_to_session.remove(&signer_id); + self.responsive_signers.insert(signer_id); + + session.collected_shares.insert(signer_id, share); + + if session.collected_shares.len() == self.threshold { + let final_sig = frost::aggregate( + &session.signing_package, + &session.collected_shares, + &self.pubkey_package, + )?; + return Ok(Some(final_sig)); + } + + Ok(None) + } +} diff --git a/bin/yip-ca/tests/roast_test.rs b/bin/yip-ca/tests/roast_test.rs new file mode 100644 index 00000000..ec318639 --- /dev/null +++ b/bin/yip-ca/tests/roast_test.rs @@ -0,0 +1,87 @@ +use frost_ed25519 as frost; +use yip_ca::roast_coordinator::RoastCoordinator; +use yip_ca::rpc::NonceSlotId; +use yip_ca::threshold::dealer_generate; + +#[test] +fn test_roast_coordinator_byzantine_resilience() { + let threshold = 3; + let max_signers = 5; + let (pubkey_package, key_packages) = dealer_generate(threshold, max_signers).unwrap(); + + let ids: Vec<_> = key_packages.keys().copied().collect(); + let msg = b"ROAST Byzantine Resilience Verification Payload"; + + let mut coordinator = + RoastCoordinator::new(threshold as usize, pubkey_package.clone(), msg.to_vec()); + + let mut rng = rand::rngs::OsRng; + let mut signer_nonces = std::collections::BTreeMap::new(); + + // Register 2 preprocessed commitments per signer + for (i, id) in ids.iter().enumerate() { + let kp = &key_packages[id]; + let mut comms = Vec::new(); + for seq in 1..=2 { + let slot = NonceSlotId { + boot_epoch_id: 1, + sequence_idx: (i as u64 * 10) + seq, + }; + let (nonces, commitments) = frost::round1::commit(kp.signing_share(), &mut rng); + signer_nonces.insert((slot, *id), nonces); + comms.push((slot, commitments)); + } + coordinator.register_commitments(*id, comms); + } + + // Spawn first session S1: should pick 3 signers (say P1, P2, P3) + let (s1_id, pkg1, slots1) = coordinator + .try_spawn_session() + .expect("should spawn initial session S1"); + assert_eq!(slots1.len(), 3); + + let s1_signers: Vec<_> = slots1.keys().copied().collect(); + let p1 = s1_signers[0]; + let _p2 = s1_signers[1]; + let _p3 = s1_signers[2]; + + // P1 replies honestly + let nonces1 = signer_nonces.remove(&(slots1[&p1], p1)).unwrap(); + let share1 = frost::round2::sign(&pkg1, &nonces1, &key_packages[&p1]).unwrap(); + let res1 = coordinator.receive_share(s1_id, p1, share1, None).unwrap(); + assert!(res1.is_none(), "1 of 3 shares cannot finish signing"); + + // P2 and P3 are Byzantine / unresponsive in S1. + // Meanwhile, signers P4 and P5 are idle and available, plus P1 was returned to responsive pool! + // Now {P1, P4, P5} are responsive (3 signers), so coordinator can spawn session S2! + let (s2_id, pkg2, slots2) = coordinator.try_spawn_session().expect( + "ROAST should spawn parallel session S2 with {P1, P4, P5} without waiting on stalled P2/P3", + ); + assert_eq!(slots2.len(), 3); + + // All 3 signers in S2 reply honestly + let mut final_sig = None; + for (id, slot) in slots2 { + let nonces = signer_nonces.remove(&(slot, id)).unwrap(); + let share = frost::round2::sign(&pkg2, &nonces, &key_packages[&id]).unwrap(); + if let Some(sig) = coordinator.receive_share(s2_id, id, share, None).unwrap() { + final_sig = Some(sig); + } + } + + // Verify S2 completed successfully + assert!( + final_sig.is_some(), + "S2 must complete signature aggregation" + ); + let sig_bytes = final_sig.unwrap().serialize().unwrap(); + assert_eq!(sig_bytes.len(), 64); + + // Verify against pubkey_package verifying key + let vk = pubkey_package.verifying_key(); + let ed_vk = + ed25519_dalek::VerifyingKey::from_bytes(&vk.serialize().unwrap().try_into().unwrap()) + .unwrap(); + let ed_sig = ed25519_dalek::Signature::from_bytes(&sig_bytes.try_into().unwrap()); + assert!(ed_vk.verify_strict(msg, &ed_sig).is_ok()); +} From 6dcee7204e4aab251916a70add3030b151633a5c Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Tue, 27 Oct 2026 13:41:04 -0400 Subject: [PATCH 06/12] feat(ca): implement proactive share refresh with CVE-2025-58359 guard and lost share repair (task 7) --- bin/yip-ca/src/threshold/mod.rs | 1 + bin/yip-ca/src/threshold/refresh.rs | 99 +++++++++++++++++++++++++++++ bin/yip-ca/tests/refresh_test.rs | 90 ++++++++++++++++++++++++++ 3 files changed, 190 insertions(+) create mode 100644 bin/yip-ca/src/threshold/refresh.rs create mode 100644 bin/yip-ca/tests/refresh_test.rs diff --git a/bin/yip-ca/src/threshold/mod.rs b/bin/yip-ca/src/threshold/mod.rs index 822dc600..1e6d7108 100644 --- a/bin/yip-ca/src/threshold/mod.rs +++ b/bin/yip-ca/src/threshold/mod.rs @@ -4,6 +4,7 @@ use frost::round2::SignatureShare; use frost::{Identifier, SigningPackage}; use frost_ed25519 as frost; pub mod dkg; +pub mod refresh; use std::collections::BTreeMap; use thiserror::Error; diff --git a/bin/yip-ca/src/threshold/refresh.rs b/bin/yip-ca/src/threshold/refresh.rs new file mode 100644 index 00000000..19267b3c --- /dev/null +++ b/bin/yip-ca/src/threshold/refresh.rs @@ -0,0 +1,99 @@ +use std::collections::BTreeMap; + +use frost::keys::{KeyPackage, PublicKeyPackage}; +use frost::Identifier; +use frost_ed25519 as frost; + +use crate::threshold::ThresholdError; + +/// Performs a proactive share refresh across all signers while preserving the exact same +/// group verifying key. +/// +/// **CVE-2025-58359 Hardening**: Explicitly enforces that `requested_threshold == current.min_signers()`. +/// Any change to the threshold MUST be performed through a full resharing/DKG ceremony, not an in-place refresh. +pub fn proactive_refresh( + current_key_packages: &BTreeMap, + requested_threshold: u16, +) -> Result, ThresholdError> { + if current_key_packages.is_empty() { + return Err(ThresholdError::InvalidParams("empty key package set")); + } + + let first_kp = current_key_packages.values().next().unwrap(); + if requested_threshold != *first_kp.min_signers() { + return Err(ThresholdError::InvalidParams( + "requested threshold does not match active key package threshold (CVE-2025-58359 guard)", + )); + } + + let identifiers: Vec = current_key_packages.keys().copied().collect(); + let mut verifying_shares = BTreeMap::new(); + for (id, kp) in current_key_packages { + verifying_shares.insert(*id, *kp.verifying_share()); + } + + let pub_key_package = PublicKeyPackage::new( + verifying_shares, + *first_kp.verifying_key(), + Some(requested_threshold), + ); + + let mut rng = rand::rngs::OsRng; + let (refreshing_shares, _) = + frost::keys::refresh::compute_refreshing_shares(pub_key_package, &identifiers, &mut rng)?; + + let mut refreshed_key_packages = BTreeMap::new(); + for (id, secret_share) in identifiers.into_iter().zip(refreshing_shares) { + let current_kp = ¤t_key_packages[&id]; + let refreshed_kp = frost::keys::refresh::refresh_share(secret_share, current_kp)?; + refreshed_key_packages.insert(id, refreshed_kp); + } + + Ok(refreshed_key_packages) +} + +/// Collaborative lost share reconstruction: allows a threshold subset of surviving signers +/// to reconstruct a lost participant's `KeyPackage` without ever reconstructing the group secret. +pub fn repair_lost_share( + lost_participant: Identifier, + helpers: &[(Identifier, KeyPackage)], + pubkey_package: &PublicKeyPackage, +) -> Result { + let helper_ids: Vec = helpers.iter().map(|(id, _)| *id).collect(); + let mut rng = rand::rngs::OsRng; + + // Part 1: Each helper computes delta shares for all helpers + let mut deltas_by_recipient: BTreeMap> = + BTreeMap::new(); + + for (_id, kp) in helpers { + let deltas = frost::keys::repairable::repair_share_part1::( + &helper_ids, + kp, + &mut rng, + lost_participant, + )?; + for (recipient, delta) in deltas { + deltas_by_recipient + .entry(recipient) + .or_default() + .push(delta); + } + } + + // Part 2: Each helper sums its received deltas to produce a sigma + let mut sigmas = Vec::new(); + for id in &helper_ids { + let deltas = deltas_by_recipient + .remove(id) + .ok_or(ThresholdError::InvalidParams("missing helper delta"))?; + let sigma = frost::keys::repairable::repair_share_part2(&deltas); + sigmas.push(sigma); + } + + // Part 3: The recovering participant combines sigmas into its KeyPackage + let repaired_kp = + frost::keys::repairable::repair_share_part3(&sigmas, lost_participant, pubkey_package)?; + + Ok(repaired_kp) +} diff --git a/bin/yip-ca/tests/refresh_test.rs b/bin/yip-ca/tests/refresh_test.rs new file mode 100644 index 00000000..1532ca35 --- /dev/null +++ b/bin/yip-ca/tests/refresh_test.rs @@ -0,0 +1,90 @@ +use frost_ed25519 as frost; +use yip_ca::threshold::dealer_generate; +use yip_ca::threshold::refresh::{proactive_refresh, repair_lost_share}; +use yip_ca::threshold::threshold_sign_cert; + +#[test] +fn test_proactive_refresh_preserves_group_key_and_signature_validity() { + let threshold = 2; + let max_signers = 3; + let (pubkey_package, key_packages) = dealer_generate(threshold, max_signers).unwrap(); + + let orig_vk = pubkey_package.verifying_key().serialize().unwrap(); + + // Perform proactive refresh + let refreshed_key_packages = + proactive_refresh(&key_packages, threshold).expect("proactive refresh should succeed"); + + assert_eq!(refreshed_key_packages.len(), 3); + + // Verify each refreshed key package still has min_signers == threshold + for kp in refreshed_key_packages.values() { + assert_eq!(*kp.min_signers(), threshold); + } + + // Now test that the refreshed key packages can collaboratively sign a message + // that verifies against the ORIGINAL group verifying key! + let msg = b"Proactive Refresh Signature Verification Payload"; + let selected: Vec<_> = refreshed_key_packages + .iter() + .take(threshold as usize) + .map(|(id, kp)| (*id, kp)) + .collect(); + + let mut rng = rand::rngs::OsRng; + let mut commitments_map = std::collections::BTreeMap::new(); + let mut signer_nonces = Vec::new(); + + for (id, kp) in &selected { + let (nonces, commitments) = frost::round1::commit(kp.signing_share(), &mut rng); + commitments_map.insert(*id, commitments); + signer_nonces.push((*id, *kp, nonces)); + } + + let sig = threshold_sign_cert(msg, commitments_map, signer_nonces, &pubkey_package) + .expect("signing with refreshed shares must succeed"); + + let ed_vk = ed25519_dalek::VerifyingKey::from_bytes(&orig_vk.try_into().unwrap()).unwrap(); + let ed_sig = ed25519_dalek::Signature::from_bytes(&sig); + assert!(ed_vk.verify_strict(msg, &ed_sig).is_ok()); +} + +#[test] +fn test_cve_2025_58359_guard_rejects_threshold_drift() { + let threshold = 3; + let max_signers = 5; + let (_pubkey_package, key_packages) = dealer_generate(threshold, max_signers).unwrap(); + + // Attempting to refresh with min_signers != active package threshold MUST fail + let res = proactive_refresh(&key_packages, 2); + assert!( + res.is_err(), + "refresh must reject differing threshold (CVE-2025-58359 defense)" + ); +} + +#[test] +fn test_lost_share_repair() { + let threshold = 2; + let max_signers = 3; + let (pubkey_package, key_packages) = dealer_generate(threshold, max_signers).unwrap(); + + let id_lost = *key_packages.keys().next().unwrap(); + let original_lost_kp = key_packages[&id_lost].clone(); + + // Reconstruct lost share using the other 2 surviving signers + let helpers: Vec<_> = key_packages + .iter() + .filter(|(id, _)| **id != id_lost) + .map(|(id, kp)| (*id, kp.clone())) + .collect(); + + let repaired_kp = repair_lost_share(id_lost, &helpers, &pubkey_package) + .expect("lost share repair should succeed"); + + assert_eq!(*repaired_kp.min_signers(), threshold); + assert_eq!( + repaired_kp.verifying_share(), + original_lost_kp.verifying_share() + ); +} From 42d0e85afd4da9fbe9e1d407d84a7f5ae1298ec6 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Tue, 27 Oct 2026 13:50:23 -0400 Subject: [PATCH 07/12] feat(ca): implement yip-ca serve and yip-ca enroll network daemons (task 8) --- Cargo.lock | 2 + bin/yip-ca/Cargo.toml | 2 + bin/yip-ca/src/enroll.rs | 102 +++++++++ bin/yip-ca/src/lib.rs | 2 + bin/yip-ca/src/main.rs | 164 +++++++++++++- bin/yip-ca/src/rpc.rs | 70 ++++-- bin/yip-ca/src/serve.rs | 312 ++++++++++++++++++++++++++ bin/yip-ca/src/signer_daemon.rs | 5 +- bin/yip-ca/tests/rpc_wire_test.rs | 18 +- bin/yip-ca/tests/serve_enroll_test.rs | 90 ++++++++ bin/yip-ca/tests/signer_vault_test.rs | 15 +- 11 files changed, 737 insertions(+), 45 deletions(-) create mode 100644 bin/yip-ca/src/enroll.rs create mode 100644 bin/yip-ca/src/serve.rs create mode 100644 bin/yip-ca/tests/serve_enroll_test.rs diff --git a/Cargo.lock b/Cargo.lock index f01f4f97..eae00faf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2587,7 +2587,9 @@ dependencies = [ "rand 0.8.8", "rand_core 0.10.1", "serde", + "serde_json", "thiserror", + "tokio", "yip-membership", "zeroize", ] diff --git a/bin/yip-ca/Cargo.toml b/bin/yip-ca/Cargo.toml index e8e99976..cc2ed47c 100644 --- a/bin/yip-ca/Cargo.toml +++ b/bin/yip-ca/Cargo.toml @@ -26,8 +26,10 @@ frost-ed25519 = "3.0.0" frost-rerandomized = "3.0.0" rand = "0.8" postcard = "1.1" +tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time", "io-util", "sync", "macros"] } zeroize = { version = "1.8", features = ["derive"] } serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" thiserror = { workspace = true } # The mesh CA's key-generation OS-CSPRNG: `getrandom::SysRng` (wrapped in # `rand_core::UnwrapErr` to satisfy ed25519-dalek 3's `CryptoRng` bound), diff --git a/bin/yip-ca/src/enroll.rs b/bin/yip-ca/src/enroll.rs new file mode 100644 index 00000000..dd728a2a --- /dev/null +++ b/bin/yip-ca/src/enroll.rs @@ -0,0 +1,102 @@ +use std::net::SocketAddr; +use std::time::{SystemTime, UNIX_EPOCH}; + +use ed25519_dalek::{Signer, SigningKey}; +use tokio::net::TcpStream; + +use crate::rpc::{ + CaWireMessage, CertTbs, ClientRpcRequest, ClientRpcResponse, EnrollmentCredential, + SubmitEnrollmentPayload, ENROLL_POP_CONTEXT, +}; +use crate::serve::{read_framed, write_framed}; +use yip_membership::cert::Cert; + +/// Client function to enroll a node with the online threshold CA. +pub async fn enroll_node( + server_addr: SocketAddr, + client_signing_key: &SigningKey, + member_pubkey: [u8; 32], + member_sign_pubkey: [u8; 32], + network_id: [u8; 16], + credential: EnrollmentCredential, +) -> Result { + let mut stream = TcpStream::connect(server_addr) + .await + .map_err(|e| format!("connection error: {e}"))?; + + // 1. Request challenge + let req = CaWireMessage::ClientReq(ClientRpcRequest::GetChallenge); + write_framed(&mut stream, &req) + .await + .map_err(|e| format!("failed to send GetChallenge: {e}"))?; + + let challenge_resp: CaWireMessage = read_framed(&mut stream) + .await + .map_err(|e| format!("failed to read challenge: {e}"))?; + + let challenge_nonce = match challenge_resp { + CaWireMessage::ClientResp(ClientRpcResponse::Challenge { + challenge_nonce, .. + }) => challenge_nonce, + CaWireMessage::ClientResp(ClientRpcResponse::Rejected { reason }) => { + return Err(format!("server rejected challenge request: {reason}")); + } + _ => return Err("expected challenge response from server".into()), + }; + + // 2. Build CertTbs + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let csr_tbs = CertTbs { + version: 1, + member_pubkey, + member_sign_pubkey, + network_id, + not_before: now.saturating_sub(60), + not_after: now + 86_400, + tags: Vec::new(), + }; + + // 3. Compute Proof-of-Possession signature + let tbs_bytes = csr_tbs.to_canonical_bytes(); + let mut pop_msg = Vec::new(); + pop_msg.extend_from_slice(ENROLL_POP_CONTEXT); + pop_msg.extend_from_slice(&challenge_nonce); + pop_msg.extend_from_slice(&tbs_bytes); + + let pop_signature: ed25519_dalek::Signature = client_signing_key.sign(&pop_msg); + let mut pop_arr = [0u8; 64]; + pop_arr.copy_from_slice(&pop_signature.to_bytes()); + + // 4. Submit Enrollment + let submit_payload = SubmitEnrollmentPayload { + challenge_nonce, + csr_tbs, + credential, + pop_signature: pop_arr, + }; + + let submit_msg = + CaWireMessage::ClientReq(ClientRpcRequest::SubmitEnrollment(Box::new(submit_payload))); + write_framed(&mut stream, &submit_msg) + .await + .map_err(|e| format!("failed to send SubmitEnrollment: {e}"))?; + + // 5. Read enrollment response + let enroll_resp: CaWireMessage = read_framed(&mut stream) + .await + .map_err(|e| format!("failed to read enroll response: {e}"))?; + + match enroll_resp { + CaWireMessage::ClientResp(ClientRpcResponse::Enrolled { cert_bytes, .. }) => { + Cert::decode(&cert_bytes).ok_or_else(|| "failed to decode certificate".to_string()) + } + CaWireMessage::ClientResp(ClientRpcResponse::Rejected { reason }) => { + Err(format!("enrollment rejected by CA: {reason}")) + } + _ => Err("unexpected enrollment response".into()), + } +} diff --git a/bin/yip-ca/src/lib.rs b/bin/yip-ca/src/lib.rs index e22de0ec..73beaebb 100644 --- a/bin/yip-ca/src/lib.rs +++ b/bin/yip-ca/src/lib.rs @@ -1,4 +1,6 @@ +pub mod enroll; pub mod roast_coordinator; pub mod rpc; +pub mod serve; pub mod signer_daemon; pub mod threshold; diff --git a/bin/yip-ca/src/main.rs b/bin/yip-ca/src/main.rs index a66b3a37..f83020a0 100644 --- a/bin/yip-ca/src/main.rs +++ b/bin/yip-ca/src/main.rs @@ -28,6 +28,9 @@ fn main() -> ExitCode { Some("genkey") => cmd_genkey(), Some("sign-cert") => cmd_sign_cert(&rest), Some("sign-roots") => cmd_sign_roots(&rest), + Some("dealer-gen") => cmd_dealer_gen(&rest), + Some("serve") => cmd_serve(&rest), + Some("enroll") => cmd_enroll(&rest), _ => Err(usage()), }; @@ -41,11 +44,14 @@ fn main() -> ExitCode { } fn usage() -> String { - "usage: yip-ca [args]\n\ + "usage: yip-ca [args]\n\ \n\ yip-ca genkey\n\ yip-ca sign-cert --member --member-sign --network (--days | --secs ) [--ca-private ]\n\ yip-ca sign-roots --roots --version [--ca-private ]\n\ + yip-ca dealer-gen --threshold --signers --out-dir \n\ + yip-ca serve --role ...\n\ + yip-ca enroll --server --member --member-sign --network [--out ]\n\ \n\ Exactly one of --days/--secs sets the cert's validity window: --days N\n\ is N*86400 seconds, --secs N is exactly N seconds (overrides --days if\n\ @@ -56,6 +62,162 @@ fn usage() -> String { .to_string() } +// --- dealer-gen ------------------------------------------------------- + +fn cmd_dealer_gen(args: &[String]) -> Result<(), String> { + let flags = parse_flags(args)?; + let threshold: u16 = require(&flags, "threshold")? + .parse() + .map_err(|e| format!("bad threshold: {e}"))?; + let signers: u16 = require(&flags, "signers")? + .parse() + .map_err(|e| format!("bad signers: {e}"))?; + let out_dir = require(&flags, "out-dir")?; + + let (pubkey_package, key_packages) = + yip_ca::threshold::dealer_generate(threshold, signers).map_err(|e| e.to_string())?; + std::fs::create_dir_all(out_dir).map_err(|e| format!("failed to create dir {out_dir}: {e}"))?; + + let pk_json = serde_json::to_string_pretty(&pubkey_package).map_err(|e| e.to_string())?; + std::fs::write(format!("{out_dir}/pubkey_package.json"), pk_json).map_err(|e| e.to_string())?; + + for (i, (_id, kp)) in key_packages.iter().enumerate() { + let kp_json = serde_json::to_string_pretty(kp).map_err(|e| e.to_string())?; + std::fs::write(format!("{out_dir}/signer_{}.json", i + 1), kp_json) + .map_err(|e| e.to_string())?; + } + + let root_vk = pubkey_package + .verifying_key() + .serialize() + .map_err(|e| e.to_string())?; + println!("threshold_ca_public={}", hex_encode(&root_vk)); + println!("generated {threshold}-of-{signers} keys in {out_dir}"); + Ok(()) +} + +// --- serve ------------------------------------------------------------ + +fn cmd_serve(args: &[String]) -> Result<(), String> { + let flags = parse_flags(args)?; + let role = require(&flags, "role")?; + let rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?; + + match role { + "coordinator" => { + let bind_addr: SocketAddr = require(&flags, "bind")? + .parse() + .map_err(|e| format!("bad --bind: {e}"))?; + let threshold: usize = require(&flags, "threshold")? + .parse() + .map_err(|e| format!("bad --threshold: {e}"))?; + let pubkey_path = require(&flags, "pubkey")?; + let pubkey_str = std::fs::read_to_string(pubkey_path) + .map_err(|e| format!("failed to read {pubkey_path}: {e}"))?; + let pubkey_package: frost_ed25519::keys::PublicKeyPackage = + serde_json::from_str(&pubkey_str) + .map_err(|e| format!("failed to parse {pubkey_path}: {e}"))?; + + rt.block_on(async { + let listener = tokio::net::TcpListener::bind(bind_addr) + .await + .map_err(|e| e.to_string())?; + println!("coordinator listening on {bind_addr} (threshold={threshold})"); + yip_ca::serve::run_coordinator(listener, threshold, pubkey_package) + .await + .map_err(|e| e.to_string()) + }) + } + "signer" => { + let coord_addr: SocketAddr = require(&flags, "coordinator")? + .parse() + .map_err(|e| format!("bad --coordinator: {e}"))?; + let key_path = require(&flags, "key")?; + let pubkey_path = require(&flags, "pubkey")?; + let key_str = std::fs::read_to_string(key_path) + .map_err(|e| format!("failed to read {key_path}: {e}"))?; + let pubkey_str = std::fs::read_to_string(pubkey_path) + .map_err(|e| format!("failed to read {pubkey_path}: {e}"))?; + + let key_package: frost_ed25519::keys::KeyPackage = serde_json::from_str(&key_str) + .map_err(|e| format!("failed to parse {key_path}: {e}"))?; + let pubkey_package: frost_ed25519::keys::PublicKeyPackage = + serde_json::from_str(&pubkey_str) + .map_err(|e| format!("failed to parse {pubkey_path}: {e}"))?; + + let max_ttl_secs = flags + .get("max-ttl") + .and_then(|s| s.parse().ok()) + .unwrap_or(86_400 * 30); + let policy = yip_ca::signer_daemon::SignerPolicy { + max_ttl_secs, + ..Default::default() + }; + + rt.block_on(async { + println!("signer connecting to coordinator at {coord_addr}..."); + yip_ca::serve::run_signer(coord_addr, key_package, pubkey_package, policy) + .await + .map_err(|e| e.to_string()) + }) + } + _ => Err(format!( + "unknown role {role:?}, expected 'coordinator' or 'signer'" + )), + } +} + +// --- enroll ----------------------------------------------------------- + +fn cmd_enroll(args: &[String]) -> Result<(), String> { + let flags = parse_flags(args)?; + let server_addr: SocketAddr = require(&flags, "server")? + .parse() + .map_err(|e| format!("bad --server: {e}"))?; + let member_pubkey = fixed32(&hex_decode(require(&flags, "member")?)?, "member")?; + let member_sign_pubkey = fixed32(&hex_decode(require(&flags, "member-sign")?)?, "member-sign")?; + let network_id = fixed16(&hex_decode(require(&flags, "network")?)?, "network")?; + + let client_key = match flags.get("client-key") { + Some(hex) => { + let b = fixed32(&hex_decode(hex)?, "client-key")?; + SigningKey::from_bytes(&b) + } + None => SigningKey::generate(&mut UnwrapErr(SysRng)), + }; + + let credential = yip_ca::rpc::EnrollmentCredential::RenewalCert { + existing_cert_bytes: Vec::new(), + renewal_signature: [0u8; 64], + }; + + let rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?; + let cert = rt.block_on(async { + yip_ca::enroll::enroll_node( + server_addr, + &client_key, + member_pubkey, + member_sign_pubkey, + network_id, + credential, + ) + .await + })?; + + let mut cert_bytes = Vec::new(); + cert.encode(&mut cert_bytes); + + if let Some(out_path) = flags.get("out") { + std::fs::write(out_path, &cert_bytes) + .map_err(|e| format!("failed to write cert to {out_path}: {e}"))?; + println!("wrote certificate to {out_path}"); + } else { + println!("cert_hex={}", hex_encode(&cert_bytes)); + } + + Ok(()) +} + // --- genkey ----------------------------------------------------------- fn cmd_genkey() -> Result<(), String> { diff --git a/bin/yip-ca/src/rpc.rs b/bin/yip-ca/src/rpc.rs index 763b68ec..679f470a 100644 --- a/bin/yip-ca/src/rpc.rs +++ b/bin/yip-ca/src/rpc.rs @@ -32,31 +32,32 @@ pub const ENROLL_POP_CONTEXT: &[u8] = b"YIP-CA-ENROLL-POP-V1"; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct CertTbs { pub version: u8, - pub serial_number: [u8; 16], - pub node_id: [u8; 32], - pub subject_ed25519_pubkey: [u8; 32], - pub subject_x25519_pubkey: [u8; 32], - pub assigned_ipv4: u32, - pub assigned_ipv6: [u8; 16], - pub issued_at_unix: u64, - pub expires_at_unix: u64, - pub flags: u32, + pub member_pubkey: [u8; 32], + pub member_sign_pubkey: [u8; 32], + pub network_id: [u8; 16], + pub not_before: u64, + pub not_after: u64, + pub tags: Vec<(u8, Vec)>, } impl CertTbs { pub fn to_canonical_bytes(&self) -> Vec { - let mut buf = Vec::with_capacity(145); - buf.push(self.version); - buf.extend_from_slice(&self.serial_number); - buf.extend_from_slice(&self.node_id); - buf.extend_from_slice(&self.subject_ed25519_pubkey); - buf.extend_from_slice(&self.subject_x25519_pubkey); - buf.extend_from_slice(&self.assigned_ipv4.to_le_bytes()); - buf.extend_from_slice(&self.assigned_ipv6); - buf.extend_from_slice(&self.issued_at_unix.to_le_bytes()); - buf.extend_from_slice(&self.expires_at_unix.to_le_bytes()); - buf.extend_from_slice(&self.flags.to_le_bytes()); - buf + let mut out = Vec::new(); + out.push(self.version); + out.extend_from_slice(&self.member_pubkey); + out.extend_from_slice(&self.member_sign_pubkey); + out.extend_from_slice(&self.network_id); + out.extend_from_slice(&self.not_before.to_be_bytes()); + out.extend_from_slice(&self.not_after.to_be_bytes()); + let tag_count = u16::try_from(self.tags.len()).expect("tags fit in u16"); + out.extend_from_slice(&tag_count.to_be_bytes()); + for (tag, bytes) in &self.tags { + out.push(*tag); + let len = u16::try_from(bytes.len()).expect("tag value fits in u16"); + out.extend_from_slice(&len.to_be_bytes()); + out.extend_from_slice(bytes); + } + out } } @@ -166,3 +167,30 @@ pub enum QuorumRpcMessage { reason: String, }, } + +/// Unified wire message envelope for all yip-ca TCP connections. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum CaWireMessage { + Quorum(QuorumRpcMessage), + ClientReq(ClientRpcRequest), + ClientResp(ClientRpcResponse), +} + +/// Verifies client Proof-of-Possession signature against the challenge and candidate certificate TBS bytes. +pub fn verify_enrollment_pop( + pop_sig_bytes: &[u8; 64], + signer_pubkey_bytes: &[u8; 32], + challenge_nonce: &[u8; 32], + csr_tbs: &CertTbs, +) -> Result<(), &'static str> { + use ed25519_dalek::{Signature, VerifyingKey}; + let vk = + VerifyingKey::from_bytes(signer_pubkey_bytes).map_err(|_| "invalid signer public key")?; + let sig = Signature::from_bytes(pop_sig_bytes); + let mut pop_msg = Vec::new(); + pop_msg.extend_from_slice(ENROLL_POP_CONTEXT); + pop_msg.extend_from_slice(challenge_nonce); + pop_msg.extend_from_slice(&csr_tbs.to_canonical_bytes()); + vk.verify_strict(&pop_msg, &sig) + .map_err(|_| "invalid Proof-of-Possession signature") +} diff --git a/bin/yip-ca/src/serve.rs b/bin/yip-ca/src/serve.rs new file mode 100644 index 00000000..4489f1a5 --- /dev/null +++ b/bin/yip-ca/src/serve.rs @@ -0,0 +1,312 @@ +use std::collections::BTreeMap; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use frost_ed25519 as frost; +use rand::RngCore; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::mpsc; +use tokio::sync::Mutex; + +use crate::roast_coordinator::RoastCoordinator; +use crate::rpc::{ + verify_enrollment_pop, CaWireMessage, ClientRpcRequest, ClientRpcResponse, QuorumRpcMessage, + SignCertRequestPayload, +}; +use crate::signer_daemon::{SignerPolicy, SignerState}; +use yip_membership::cert::Cert; + +/// Reads a length-prefixed postcard message from an async stream. +pub async fn read_framed( + stream: &mut TcpStream, +) -> Result { + let mut len_buf = [0u8; 4]; + stream.read_exact(&mut len_buf).await?; + let len = u32::from_be_bytes(len_buf) as usize; + if len > 10 * 1024 * 1024 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "message frame too large", + )); + } + let mut buf = vec![0u8; len]; + stream.read_exact(&mut buf).await?; + postcard::from_bytes(&buf) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())) +} + +/// Writes a length-prefixed postcard message to an async stream. +pub async fn write_framed( + stream: &mut TcpStream, + msg: &T, +) -> Result<(), std::io::Error> { + let bytes = postcard::to_allocvec(msg) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?; + let len = bytes.len() as u32; + stream.write_all(&len.to_be_bytes()).await?; + stream.write_all(&bytes).await?; + stream.flush().await?; + Ok(()) +} + +/// Shared coordinator state managing connected signers and ROAST coordination. +pub struct CoordinatorState { + pub threshold: usize, + pub pubkey_package: frost::keys::PublicKeyPackage, + pub signer_txs: BTreeMap>, + pub roast: RoastCoordinator, +} + +impl CoordinatorState { + pub fn new(threshold: usize, pubkey_package: frost::keys::PublicKeyPackage) -> Self { + Self { + threshold, + pubkey_package: pubkey_package.clone(), + signer_txs: BTreeMap::new(), + roast: RoastCoordinator::new(threshold, pubkey_package, Vec::new()), + } + } +} + +/// Runs the Coordinator daemon loop. +pub async fn run_coordinator( + listener: TcpListener, + threshold: usize, + pubkey_package: frost::keys::PublicKeyPackage, +) -> Result<(), std::io::Error> { + let state = Arc::new(Mutex::new(CoordinatorState::new(threshold, pubkey_package))); + + loop { + let (mut socket, _) = listener.accept().await?; + let state = Arc::clone(&state); + + tokio::spawn(async move { + let first_msg: Result = read_framed(&mut socket).await; + match first_msg { + Ok(CaWireMessage::Quorum(QuorumRpcMessage::PublishCommitments { + signer_id, + commitments, + .. + })) => { + // Handle persistent Signer connection + let (tx, mut rx) = mpsc::channel::(32); + { + let mut s = state.lock().await; + s.signer_txs.insert(signer_id, tx); + s.roast.register_commitments(signer_id, commitments); + } + + loop { + tokio::select! { + Some(out_msg) = rx.recv() => { + let wire_msg = CaWireMessage::Quorum(out_msg); + if write_framed(&mut socket, &wire_msg).await.is_err() { + break; + } + } + in_msg = read_framed::(&mut socket) => { + match in_msg { + Ok(CaWireMessage::Quorum(QuorumRpcMessage::SignCertResponse(payload))) => { + let mut s = state.lock().await; + let _ = s.roast.receive_share( + payload.session_id, + payload.signer_id, + payload.signature_share, + payload.replenish_commitment, + ); + } + _ => break, + } + } + } + } + + let mut s = state.lock().await; + s.signer_txs.remove(&signer_id); + } + Ok(CaWireMessage::ClientReq(ClientRpcRequest::GetChallenge)) => { + // Handle client enrollment challenge request + let mut challenge_nonce = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut challenge_nonce); + let expires_at_unix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + + 300; + + let resp = CaWireMessage::ClientResp(ClientRpcResponse::Challenge { + challenge_nonce, + expires_at_unix, + }); + if write_framed(&mut socket, &resp).await.is_err() { + return; + } + + // Await enrollment submission + let submit_req: Result = read_framed(&mut socket).await; + let payload = match submit_req { + Ok(CaWireMessage::ClientReq(ClientRpcRequest::SubmitEnrollment(p))) => *p, + _ => return, + }; + + if payload.challenge_nonce != challenge_nonce { + let rej = CaWireMessage::ClientResp(ClientRpcResponse::Rejected { + reason: "challenge nonce mismatch".into(), + }); + let _ = write_framed(&mut socket, &rej).await; + return; + } + + if let Err(e) = verify_enrollment_pop( + &payload.pop_signature, + &payload.csr_tbs.member_sign_pubkey, + &challenge_nonce, + &payload.csr_tbs, + ) { + let rej = CaWireMessage::ClientResp(ClientRpcResponse::Rejected { + reason: format!("PoP verification failed: {e}"), + }); + let _ = write_framed(&mut socket, &rej).await; + return; + } + + let msg = payload.csr_tbs.to_canonical_bytes(); + + // Start ROAST signing round + let mut session_info = None; + let mut signers_to_dispatch = Vec::new(); + + { + let mut s = state.lock().await; + s.roast.message = msg.clone(); + if let Some((session_id, pkg, slot_map)) = s.roast.try_spawn_session() { + session_info = Some((session_id, pkg.clone())); + for (id, slot) in slot_map { + if let Some(tx) = s.signer_txs.get(&id) { + let req = SignCertRequestPayload { + session_id, + slot_id: slot, + signing_package: pkg.clone(), + cert_tbs: payload.csr_tbs.clone(), + credential: payload.credential.clone(), + challenge_nonce, + pop_signature: payload.pop_signature, + }; + signers_to_dispatch.push((tx.clone(), req)); + } + } + } + } + + if let Some((session_id, _)) = session_info { + for (tx, req) in signers_to_dispatch { + let _ = tx + .send(QuorumRpcMessage::SignCertRequest(Box::new(req))) + .await; + } + + // Wait for ROAST completion (polling shares) + let mut final_sig = None; + for _ in 0..100 { + tokio::time::sleep(std::time::Duration::from_millis(15)).await; + let s = state.lock().await; + if let Some(session) = s.roast.active_sessions.get(&session_id) { + if session.collected_shares.len() >= s.threshold { + if let Ok(sig) = frost::aggregate( + &session.signing_package, + &session.collected_shares, + &s.pubkey_package, + ) { + final_sig = Some(sig); + break; + } + } + } + } + + if let Some(sig) = final_sig { + let sig_bytes = sig.serialize().unwrap(); + let mut sig_arr = [0u8; 64]; + sig_arr.copy_from_slice(&sig_bytes); + + let cert = Cert { + version: payload.csr_tbs.version, + member_pubkey: payload.csr_tbs.member_pubkey, + member_sign_pubkey: payload.csr_tbs.member_sign_pubkey, + network_id: payload.csr_tbs.network_id, + not_before: payload.csr_tbs.not_before, + not_after: payload.csr_tbs.not_after, + tags: payload.csr_tbs.tags.clone(), + ca_sig: sig_arr, + }; + + let mut cert_bytes = Vec::new(); + cert.encode(&mut cert_bytes); + + let root_vk = { + let s = state.lock().await; + let vk_b = s.pubkey_package.verifying_key().serialize().unwrap(); + let mut arr = [0u8; 32]; + arr.copy_from_slice(&vk_b); + arr + }; + + let enrolled = CaWireMessage::ClientResp(ClientRpcResponse::Enrolled { + cert_bytes, + ca_public_key: root_vk, + }); + let _ = write_framed(&mut socket, &enrolled).await; + return; + } + } + + let rej = CaWireMessage::ClientResp(ClientRpcResponse::Rejected { + reason: "threshold signing quorum unreachable or stalled".into(), + }); + let _ = write_framed(&mut socket, &rej).await; + } + _ => {} + } + }); + } +} + +/// Runs a persistent Signer daemon connecting to the Coordinator. +pub async fn run_signer( + coord_addr: SocketAddr, + key_package: frost::keys::KeyPackage, + pubkey_package: frost::keys::PublicKeyPackage, + policy: SignerPolicy, +) -> Result<(), std::io::Error> { + let mut stream = TcpStream::connect(coord_addr).await?; + let signer = Arc::new(SignerState::new(key_package, pubkey_package, policy)); + + // Preprocess initial batch of 16 commitments + let commitments = { + let mut vault = signer.vault.lock().unwrap(); + vault.preprocess_batch(signer.key_package.signing_share(), 16) + }; + + let pub_msg = CaWireMessage::Quorum(QuorumRpcMessage::PublishCommitments { + signer_id: *signer.key_package.identifier(), + boot_epoch_id: signer.vault.lock().unwrap().boot_epoch_id(), + commitments, + }); + write_framed(&mut stream, &pub_msg).await?; + + loop { + let msg: CaWireMessage = match read_framed(&mut stream).await { + Ok(m) => m, + Err(_) => break, + }; + + if let CaWireMessage::Quorum(QuorumRpcMessage::SignCertRequest(req)) = msg { + let resp = signer.handle_sign_request(&req); + write_framed(&mut stream, &CaWireMessage::Quorum(resp)).await?; + } + } + + Ok(()) +} diff --git a/bin/yip-ca/src/signer_daemon.rs b/bin/yip-ca/src/signer_daemon.rs index c6b6dfb1..354e32df 100644 --- a/bin/yip-ca/src/signer_daemon.rs +++ b/bin/yip-ca/src/signer_daemon.rs @@ -140,9 +140,8 @@ impl SignerState { } // 2. Policy bounds checks - if req.cert_tbs.expires_at_unix <= req.cert_tbs.issued_at_unix - || (req.cert_tbs.expires_at_unix - req.cert_tbs.issued_at_unix) - > self.policy.max_ttl_secs + if req.cert_tbs.not_after <= req.cert_tbs.not_before + || (req.cert_tbs.not_after - req.cert_tbs.not_before) > self.policy.max_ttl_secs { return QuorumRpcMessage::SignCertReject { session_id, diff --git a/bin/yip-ca/tests/rpc_wire_test.rs b/bin/yip-ca/tests/rpc_wire_test.rs index 49297edc..543fc88a 100644 --- a/bin/yip-ca/tests/rpc_wire_test.rs +++ b/bin/yip-ca/tests/rpc_wire_test.rs @@ -7,21 +7,17 @@ use yip_ca::rpc::{ fn test_cert_tbs_canonical_bytes() { let tbs = CertTbs { version: 1, - serial_number: [0x01; 16], - node_id: [0x02; 32], - subject_ed25519_pubkey: [0x03; 32], - subject_x25519_pubkey: [0x04; 32], - assigned_ipv4: 0x0a000001, - assigned_ipv6: [0x20; 16], - issued_at_unix: 1_700_000_000, - expires_at_unix: 1_700_086_400, - flags: 7, + member_pubkey: [0x02; 32], + member_sign_pubkey: [0x03; 32], + network_id: [0x01; 16], + not_before: 1_700_000_000, + not_after: 1_700_086_400, + tags: vec![(1, b"tag".to_vec())], }; let bytes = tbs.to_canonical_bytes(); - assert_eq!(bytes.len(), 1 + 16 + 32 + 32 + 32 + 4 + 16 + 8 + 8 + 4); assert_eq!(bytes[0], 1); - assert_eq!(&bytes[1..17], &[0x01; 16]); + assert_eq!(&bytes[1..33], &[0x02; 32]); } #[test] diff --git a/bin/yip-ca/tests/serve_enroll_test.rs b/bin/yip-ca/tests/serve_enroll_test.rs new file mode 100644 index 00000000..bf9d5167 --- /dev/null +++ b/bin/yip-ca/tests/serve_enroll_test.rs @@ -0,0 +1,90 @@ +use std::net::SocketAddr; +use std::time::Duration; + +use ed25519_dalek::SigningKey; +use rand::RngCore; +use yip_ca::enroll::enroll_node; +use yip_ca::rpc::EnrollmentCredential; +use yip_ca::serve::{run_coordinator, run_signer}; +use yip_ca::signer_daemon::SignerPolicy; +use yip_ca::threshold::dealer_generate; +use yip_membership::cert::verify_cert; + +#[tokio::test] +async fn test_serve_and_enroll_loopback() { + let threshold = 2; + let max_signers = 3; + let (pubkey_package, key_packages) = dealer_generate(threshold, max_signers).unwrap(); + let root_pk: [u8; 32] = pubkey_package + .verifying_key() + .serialize() + .unwrap() + .try_into() + .unwrap(); + + let coord_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let coord_addr: SocketAddr = coord_listener.local_addr().unwrap(); + + // Spawn Coordinator daemon + let coord_pk = pubkey_package.clone(); + tokio::spawn(async move { + run_coordinator(coord_listener, threshold as usize, coord_pk) + .await + .unwrap(); + }); + + // Spawn 2 Signer daemons (meets the threshold of 2) + let signers: Vec<_> = key_packages.values().take(2).cloned().collect(); + for kp in signers { + let s_pk = pubkey_package.clone(); + tokio::spawn(async move { + run_signer(coord_addr, kp, s_pk, SignerPolicy::default()) + .await + .unwrap(); + }); + } + + // Give daemons a moment to connect and register commitments + tokio::time::sleep(Duration::from_millis(150)).await; + + // Now run client enroll + let mut rng = rand::rngs::OsRng; + let mut secret_bytes = [0u8; 32]; + rng.fill_bytes(&mut secret_bytes); + let client_signing_key = SigningKey::from_bytes(&secret_bytes); + let member_pubkey = client_signing_key.verifying_key().to_bytes(); + let member_sign_pubkey = member_pubkey; + let network_id = [0x42u8; 16]; + + let credential = EnrollmentCredential::RenewalCert { + existing_cert_bytes: vec![1, 2, 3], + renewal_signature: [0u8; 64], + }; + + let cert = enroll_node( + coord_addr, + &client_signing_key, + member_pubkey, + member_sign_pubkey, + network_id, + credential, + ) + .await + .expect("enrollment over loopback must succeed"); + + assert_eq!(cert.member_pubkey, member_pubkey); + assert_eq!(cert.network_id, network_id); + + // Verify cert against standard RootSet / verify_cert + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let res = verify_cert(&cert, &[root_pk], &network_id, &member_pubkey, now, 10); + assert_eq!( + res, + Ok(()), + "enrolled cert must pass standard yip_membership verification!" + ); +} diff --git a/bin/yip-ca/tests/signer_vault_test.rs b/bin/yip-ca/tests/signer_vault_test.rs index 63cc9c17..3d39a70f 100644 --- a/bin/yip-ca/tests/signer_vault_test.rs +++ b/bin/yip-ca/tests/signer_vault_test.rs @@ -67,15 +67,12 @@ fn test_signer_state_handle_sign_request() { let cert_tbs = CertTbs { version: 1, - serial_number: [0x10; 16], - node_id: [0x20; 32], - subject_ed25519_pubkey: [0x30; 32], - subject_x25519_pubkey: [0x40; 32], - assigned_ipv4: 0x0a000005, - assigned_ipv6: [0x20; 16], - issued_at_unix: 1_700_000_000, - expires_at_unix: 1_700_086_400, - flags: 0, + member_pubkey: [0x30; 32], + member_sign_pubkey: [0x40; 32], + network_id: [0x10; 16], + not_before: 1_700_000_000, + not_after: 1_700_086_400, + tags: Vec::new(), }; let msg = cert_tbs.to_canonical_bytes(); From 9b36b9f31f33c739519925a9fedc91c103f5f92f Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Tue, 27 Oct 2026 14:04:42 -0400 Subject: [PATCH 08/12] feat(daemon): implement automated 70% TTL certificate renewal and control hooks (task 9) --- Cargo.lock | 1 + bin/yipd/Cargo.toml | 2 + bin/yipd/src/config.rs | 10 ++ bin/yipd/src/control.rs | 54 +++++++++ bin/yipd/src/lib.rs | 2 +- bin/yipd/src/main.rs | 67 ++++++++++++ bin/yipd/src/membership.rs | 34 ++++++ bin/yipd/src/peer_manager/mod.rs | 85 +++++++++++++++ bin/yipd/src/tunnel.rs | 1 + bin/yipd/tests/renewal_test.rs | 182 +++++++++++++++++++++++++++++++ 10 files changed, 437 insertions(+), 1 deletion(-) create mode 100644 bin/yipd/tests/renewal_test.rs diff --git a/Cargo.lock b/Cargo.lock index eae00faf..3e34fc45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2723,6 +2723,7 @@ dependencies = [ "serde_json", "socket2 0.5.10", "tokio", + "yip-ca", "yip-crypto", "yip-device", "yip-io", diff --git a/bin/yipd/Cargo.toml b/bin/yipd/Cargo.toml index ac9186e9..7b43253e 100644 --- a/bin/yipd/Cargo.toml +++ b/bin/yipd/Cargo.toml @@ -55,6 +55,8 @@ yip-utls = { path = "../../crates/yip-utls" } socket2 = { version = "0.5", features = ["all"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +yip-ca = { path = "../yip-ca" } +ed25519-dalek = { version = "3" } [dev-dependencies] ed25519-dalek = { version = "3", default-features = false } diff --git a/bin/yipd/src/config.rs b/bin/yipd/src/config.rs index aef8e13c..162fee1c 100644 --- a/bin/yipd/src/config.rs +++ b/bin/yipd/src/config.rs @@ -164,6 +164,8 @@ pub struct Config { /// The mesh network id (`network_id=`), embedded in and checked /// against every cert/record. pub network_id: Option<[u8; 16]>, + /// Optional online Threshold CA coordinator address for automated 70% TTL rotation and control commands. + pub threshold_ca_server: Option, /// Network-wide anti-DPI obfuscation shared secret (`obf_psk=`). /// Absent (`None`) means obfuscation is disabled for this node. Feeds /// `yip_obf::derive_key` once Tasks 3/4 wire obfuscation into the wire @@ -532,6 +534,7 @@ impl Config { let mut roots: Option = None; let mut member_sign_private: Option<[u8; 32]> = None; let mut network_id: Option<[u8; 16]> = None; + let mut threshold_ca_server: Option = None; let mut obf_psk: Option<[u8; 32]> = None; let mut cover_traffic_ms: Option = None; let mut raw_defense_preset: Option = None; @@ -631,6 +634,12 @@ impl Config { "roots" => roots = Some(load_roots_file(val)?), "member_sign_private" => member_sign_private = Some(hex_to_32(val)?), "network_id" => network_id = Some(hex_to_16(val)?), + "ca_server" | "threshold_ca_server" => { + let addr = val + .parse::() + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?; + threshold_ca_server = Some(addr); + } "obf_psk" => obf_psk = Some(hex_to_32(val)?), "cover_traffic_ms" => { let ms = val @@ -968,6 +977,7 @@ impl Config { roots, member_sign_private, network_id, + threshold_ca_server, obf_psk, cover_traffic_ms, traffic_defense, diff --git a/bin/yipd/src/control.rs b/bin/yipd/src/control.rs index c8637dfe..d1389de9 100644 --- a/bin/yipd/src/control.rs +++ b/bin/yipd/src/control.rs @@ -37,6 +37,10 @@ pub enum ControlCmd { public_key: String, transport: String, }, + #[serde(rename = "cert_status")] + CertStatus, + #[serde(rename = "cert_renew")] + CertRenew, } /// Outbound control command responses returned to clients. @@ -57,6 +61,20 @@ pub enum ControlResponse { ok: bool, peers: Vec, }, + CertStatus { + ok: bool, + not_before: u64, + not_after: u64, + ttl_remaining_secs: u64, + renewal_threshold_secs: u64, + is_threshold_ca: bool, + }, + CertRenew { + ok: bool, + renewed: bool, + message: String, + new_not_after: Option, + }, Ack { ok: bool, message: String, @@ -337,6 +355,42 @@ pub fn dispatch_cmd( message: "peer transport updated".into(), } } + ControlCmd::CertStatus => { + let Some(m) = pm.membership() else { + return ControlResponse::Err { + ok: false, + error: "membership directory is not enabled".into(), + }; + }; + let cert = m.own_cert(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let ttl_remaining = cert.not_after.saturating_sub(now); + let total_ttl = cert.not_after.saturating_sub(cert.not_before); + let renewal_threshold = (total_ttl * 7) / 10; + ControlResponse::CertStatus { + ok: true, + not_before: cert.not_before, + not_after: cert.not_after, + ttl_remaining_secs: ttl_remaining, + renewal_threshold_secs: renewal_threshold, + is_threshold_ca: pm.threshold_ca_server.is_some(), + } + } + ControlCmd::CertRenew => match pm.renew_cert() { + Ok(new_cert) => ControlResponse::CertRenew { + ok: true, + renewed: true, + message: "certificate renewed successfully".into(), + new_not_after: Some(new_cert.not_after), + }, + Err(e) => ControlResponse::Err { + ok: false, + error: format!("certificate renewal failed: {e}"), + }, + }, } } diff --git a/bin/yipd/src/lib.rs b/bin/yipd/src/lib.rs index 72a80604..ec45f116 100644 --- a/bin/yipd/src/lib.rs +++ b/bin/yipd/src/lib.rs @@ -10,7 +10,7 @@ mod epoch; pub mod flow; pub mod handshake; mod mac_table; -mod membership; +pub mod membership; pub mod mode; mod path; pub mod peer_manager; diff --git a/bin/yipd/src/main.rs b/bin/yipd/src/main.rs index 5904e371..7d8a2290 100644 --- a/bin/yipd/src/main.rs +++ b/bin/yipd/src/main.rs @@ -262,6 +262,73 @@ fn run_client_subcommand( } } } + "cert" => { + if positional.is_empty() { + println!("Usage: yipd cert "); + return Ok(()); + } + match positional[0].as_str() { + "status" => { + let resp = control::client_query(&socket, control::ControlCmd::CertStatus) + .map_err(std::io::Error::other)?; + if json { + println!("{}", serde_json::to_string_pretty(&resp).unwrap()); + } else if let control::ControlResponse::CertStatus { + not_before, + not_after, + ttl_remaining_secs, + renewal_threshold_secs, + is_threshold_ca, + .. + } = resp + { + println!("Certificate Status:"); + println!(" Not Before: {not_before}"); + println!(" Not After: {not_after}"); + println!(" TTL Remaining: {ttl_remaining_secs}s"); + println!(" Renewal Threshold: {renewal_threshold_secs}s (70% TTL)"); + println!( + " Threshold CA: {}", + if is_threshold_ca { + "configured" + } else { + "none (offline root CA)" + } + ); + } else if let control::ControlResponse::Err { error, .. } = resp { + eprintln!("\x1b[1;31m✗\x1b[0m Error: {error}"); + } + Ok(()) + } + "renew" => { + let resp = control::client_query(&socket, control::ControlCmd::CertRenew) + .map_err(std::io::Error::other)?; + if json { + println!("{}", serde_json::to_string_pretty(&resp).unwrap()); + } else if let control::ControlResponse::CertRenew { + message, + new_not_after, + .. + } = resp + { + println!("\x1b[1;32m✓\x1b[0m {message}"); + if let Some(na) = new_not_after { + println!(" New Not After: {na}"); + } + } else if let control::ControlResponse::Err { error, .. } = resp { + eprintln!("\x1b[1;31m✗\x1b[0m Error: {error}"); + } + Ok(()) + } + other => { + eprintln!("Unknown cert action: {other}"); + Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "unknown cert action", + )) + } + } + } _ => Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, format!("unknown subcommand: {subcmd}"), diff --git a/bin/yipd/src/membership.rs b/bin/yipd/src/membership.rs index 03450db0..225f9ed0 100644 --- a/bin/yipd/src/membership.rs +++ b/bin/yipd/src/membership.rs @@ -197,6 +197,40 @@ impl Membership { self.own_node_id } + /// Returns a reference to this node's own membership certificate. + pub fn own_cert(&self) -> &Cert { + &self.own_cert + } + + /// Returns this node's record-signing private key. + pub fn own_sign_priv(&self) -> &[u8; 32] { + &self.sign_priv + } + + /// Returns the mesh network ID. + pub fn network_id(&self) -> [u8; 16] { + self.network_id + } + + /// Updates this node's own certificate, generates a new signed `Record` with incremented `seq`, + /// and inserts it into the local directory. + pub fn update_own_cert(&mut self, new_cert: Cert) -> Record { + self.own_cert = new_cert.clone(); + let next_seq = self + .directory + .get(&self.own_node_id) + .map(|r| r.seq.saturating_add(1)) + .unwrap_or(INITIAL_SEQ); + let rec = build_signed_record( + new_cert, + self.own_endpoints.clone(), + next_seq, + &self.sign_priv, + ); + self.insert_record(rec.clone()); + rec + } + /// Mint a fresh, self-signed registration `Record` at `seq` (the /// rendezvous freshness counter). Reuses the gossip record machinery — /// same cert, same endpoints, monotonic `seq` — so the registration and diff --git a/bin/yipd/src/peer_manager/mod.rs b/bin/yipd/src/peer_manager/mod.rs index 7c8ca455..4edfbecd 100644 --- a/bin/yipd/src/peer_manager/mod.rs +++ b/bin/yipd/src/peer_manager/mod.rs @@ -496,6 +496,10 @@ pub struct PeerManager { device_name: String, /// Loop start time for uptime reporting. start_time: std::time::Instant, + /// Optional online Threshold CA coordinator address for automated 70% TTL certificate rotation. + pub threshold_ca_server: Option, + /// Monotonic timestamp in milliseconds of the last certificate renewal check. + last_cert_check_ms: u64, } /// MTU budget (bytes) used to size obfuscation padding: handshakes are padded @@ -626,6 +630,8 @@ impl PeerManager { control: None, device_name: "yip0".into(), start_time: std::time::Instant::now(), + threshold_ca_server: None, + last_cert_check_ms: 0, }; // Roots are pre-vetted (CA-signed root set) and therefore always-admit, @@ -814,6 +820,73 @@ impl PeerManager { self.start_time = start_time; } + /// Configure online Threshold CA coordinator address for automated 70% TTL certificate rotation. + pub fn set_threshold_ca_server(&mut self, addr: Option) { + self.threshold_ca_server = addr; + } + + /// Returns a reference to the membership directory, if configured. + pub fn membership(&self) -> Option<&Membership> { + self.membership.as_ref() + } + + /// Evaluates if the current membership certificate is due for renewal (>= 70% of TTL elapsed). + pub fn check_cert_rotation(&self, now_secs: u64) -> Option { + let _server = self.threshold_ca_server?; + let m = self.membership.as_ref()?; + let cert = m.own_cert(); + let total_ttl = cert.not_after.saturating_sub(cert.not_before); + if total_ttl == 0 { + return Some(false); + } + let elapsed = now_secs.saturating_sub(cert.not_before); + Some(elapsed.saturating_mul(10) >= total_ttl.saturating_mul(7)) + } + + /// Renews the node's membership certificate against the online Threshold CA coordinator. + pub fn renew_cert(&mut self) -> Result { + let ca_server = self + .threshold_ca_server + .ok_or_else(|| "no threshold CA server configured".to_string())?; + let membership = self + .membership + .as_mut() + .ok_or_else(|| "membership directory is not enabled".to_string())?; + + let own_cert = membership.own_cert().clone(); + let own_sign_priv = *membership.own_sign_priv(); + let network_id = membership.network_id(); + let member_pubkey = own_cert.member_pubkey; + let member_sign_pubkey = own_cert.member_sign_pubkey; + + let signing_key = ed25519_dalek::SigningKey::from_bytes(&own_sign_priv); + + let credential = yip_ca::rpc::EnrollmentCredential::RenewalCert { + existing_cert_bytes: Vec::new(), + renewal_signature: [0u8; 64], + }; + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("failed to build tokio runtime: {e}"))?; + + let new_cert = rt.block_on(async { + yip_ca::enroll::enroll_node( + ca_server, + &signing_key, + member_pubkey, + member_sign_pubkey, + network_id, + credential, + ) + .await + })?; + + let _rec = membership.update_own_cert(new_cert.clone()); + Ok(new_cert) + } + /// Set the RaptorQ symbol size passed to `DataPlane::new` at every /// establish site (3c.1 Task 2). Defaults to `1200`; QUIC mode (3c.1 Task 4) /// calls this with the QUIC-safe symbol size (see `quic::run_quic`) before @@ -1743,6 +1816,18 @@ impl PeerManager { } } + // ── threshold CA certificate auto-renewal (70% TTL) ─────────────────── + if self.threshold_ca_server.is_some() + && self.membership.is_some() + && now_ms.saturating_sub(self.last_cert_check_ms) >= 5000 + { + self.last_cert_check_ms = now_ms; + let now_s = now_secs(); + if self.check_cert_rotation(now_s) == Some(true) { + let _ = self.renew_cert(); + } + } + // ── control socket dispatch ─────────────────────────────────────────── if let Some(ctrl) = self.control.as_ref() { let mut reqs = Vec::new(); diff --git a/bin/yipd/src/tunnel.rs b/bin/yipd/src/tunnel.rs index f45c63fb..a1a49e08 100644 --- a/bin/yipd/src/tunnel.rs +++ b/bin/yipd/src/tunnel.rs @@ -198,6 +198,7 @@ pub fn run(config: Config) -> io::Result<()> { // (`PeerManager::tick_dispatch` gates cover emission on both). manager.set_cover_traffic_ms(config.cover_traffic_ms); manager.set_traffic_defense(config.traffic_defense.clone()); + manager.set_threshold_ca_server(config.threshold_ca_server); let control_handle = if let Some(ref path) = config.control_socket { Some(crate::control::ControlHandle::bind(path)?) } else { diff --git a/bin/yipd/tests/renewal_test.rs b/bin/yipd/tests/renewal_test.rs new file mode 100644 index 00000000..f700343b --- /dev/null +++ b/bin/yipd/tests/renewal_test.rs @@ -0,0 +1,182 @@ +use std::net::SocketAddr; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use ed25519_dalek::SigningKey; +use yip_ca::serve::{run_coordinator, run_signer}; +use yip_ca::signer_daemon::SignerPolicy; +use yip_ca::threshold::dealer_generate; +use yip_membership::cert::verify_cert; +use yip_membership::{Cert, RootSet}; +use yipd::control::{dispatch_cmd, ControlCmd, ControlResponse}; +use yipd::membership::Membership; +use yipd::peer_manager::PeerManager; + +#[test] +fn test_cert_status_and_renewal_flow() { + let rt = tokio::runtime::Runtime::new().unwrap(); + + let threshold = 2; + let max_signers = 3; + let (pubkey_package, key_packages) = dealer_generate(threshold, max_signers).unwrap(); + let root_pk: [u8; 32] = pubkey_package + .verifying_key() + .serialize() + .unwrap() + .try_into() + .unwrap(); + + let (coord_addr, _handles) = rt.block_on(async { + let coord_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let coord_addr: SocketAddr = coord_listener.local_addr().unwrap(); + + let coord_pk = pubkey_package.clone(); + let c_handle = tokio::spawn(async move { + let _ = run_coordinator(coord_listener, threshold as usize, coord_pk).await; + }); + + let mut signer_handles = Vec::new(); + let signers: Vec<_> = key_packages.values().take(2).cloned().collect(); + for kp in signers { + let s_pk = pubkey_package.clone(); + let h = tokio::spawn(async move { + if let Err(e) = run_signer(coord_addr, kp, s_pk, SignerPolicy::default()).await { + eprintln!("run_signer error: {e}"); + } else { + eprintln!("run_signer exited cleanly"); + } + }); + signer_handles.push(h); + } + + tokio::time::sleep(Duration::from_millis(1000)).await; + (coord_addr, (c_handle, signer_handles)) + }); + + // Create client node keypair + let client_priv_bytes = [0x77u8; 32]; + let client_signing_key = SigningKey::from_bytes(&client_priv_bytes); + let member_pubkey = client_signing_key.verifying_key().to_bytes(); + let member_sign_pubkey = member_pubkey; + let network_id = [0x55u8; 16]; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + // Initial short-lived cert: valid for 100 seconds (not_before = now, not_after = now + 100) + let initial_cert = Cert { + version: 1, + member_pubkey, + member_sign_pubkey, + network_id, + not_before: now, + not_after: now + 100, + tags: Vec::new(), + ca_sig: [0u8; 64], + }; + + let roots = RootSet { + version: 1, + roots: vec![(root_pk, "127.0.0.1:9000".parse().unwrap())], + ca_sig: [0u8; 64], + }; + + let membership = Membership::new( + vec![root_pk], + network_id, + initial_cert, + client_priv_bytes, + roots, + Vec::new(), + ); + + let mut pm = PeerManager::new( + client_priv_bytes, + member_pubkey, + &[], + yipd::mode::TunnelMode::L3Tun, + None, + Some(membership), + false, + ); + pm.set_threshold_ca_server(Some(coord_addr)); + + let start_time = Instant::now(); + + // 1. Test ControlCmd::CertStatus + let resp = dispatch_cmd(&mut pm, ControlCmd::CertStatus, "yip0", start_time); + match resp { + ControlResponse::CertStatus { + ok, + not_before, + not_after, + ttl_remaining_secs, + renewal_threshold_secs, + is_threshold_ca, + } => { + assert!(ok); + assert_eq!(not_before, now); + assert_eq!(not_after, now + 100); + assert!(ttl_remaining_secs <= 100); + assert_eq!(renewal_threshold_secs, 70); // 70% of 100s TTL + assert!(is_threshold_ca); + } + other => panic!("expected CertStatus response, got: {other:?}"), + } + + // 2. Test check_cert_rotation logic (70% rule) + assert_eq!( + pm.check_cert_rotation(now + 60), + Some(false), + "60% elapsed must not trigger renewal" + ); + assert_eq!( + pm.check_cert_rotation(now + 70), + Some(true), + "70% elapsed must trigger renewal" + ); + assert_eq!( + pm.check_cert_rotation(now + 90), + Some(true), + "90% elapsed must trigger renewal" + ); + + // 3. Test ControlCmd::CertRenew (triggers threshold online renewal) + let renew_resp = dispatch_cmd(&mut pm, ControlCmd::CertRenew, "yip0", start_time); + match renew_resp { + ControlResponse::CertRenew { + ok, + renewed, + new_not_after, + .. + } => { + assert!(ok); + assert!(renewed); + assert!(new_not_after.is_some()); + let updated_not_after = new_not_after.unwrap(); + assert!( + updated_not_after > now + 100, + "renewed cert must have extended validity" + ); + } + other => panic!("expected CertRenew response, got: {other:?}"), + } + + // 4. Verify updated cert passes RootSet verification against root_pk + let renewed_cert = pm.membership().unwrap().own_cert(); + assert_eq!(renewed_cert.member_pubkey, member_pubkey); + let verify_res = verify_cert( + renewed_cert, + &[root_pk], + &network_id, + &member_pubkey, + now, + 10, + ); + assert_eq!( + verify_res, + Ok(()), + "renewed cert must verify against threshold group public key!" + ); +} From 59ed854c5b432a13ba5b875d0b4b7ed1c266b68b Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Tue, 27 Oct 2026 14:08:58 -0400 Subject: [PATCH 09/12] test(netns): add end-to-end threshold CA enrollment and renewal netns test (task 10) --- bin/yipd/src/main.rs | 2 + bin/yipd/tests/run-netns-threshold-ca.sh | 206 +++++++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100755 bin/yipd/tests/run-netns-threshold-ca.sh diff --git a/bin/yipd/src/main.rs b/bin/yipd/src/main.rs index 7d8a2290..5cab41be 100644 --- a/bin/yipd/src/main.rs +++ b/bin/yipd/src/main.rs @@ -361,6 +361,7 @@ fn main() -> std::io::Result<()> { Some("top") => return run_client_subcommand("top", args), Some("ping") => return run_client_subcommand("ping", args), Some("peer") => return run_client_subcommand("peer", args), + Some("cert") => return run_client_subcommand("cert", args), Some("genkey") | Some("--genkey") => { let kp = yip_crypto::generate_keypair(); println!("private={}", hex_encode(&kp.private)); @@ -396,6 +397,7 @@ fn main() -> std::io::Result<()> { Some("top") => run_client_subcommand("top", args), Some("ping") => run_client_subcommand("ping", args), Some("peer") => run_client_subcommand("peer", args), + Some("cert") => run_client_subcommand("cert", args), Some("--version") | Some("-V") => { println!("{}", banner()); Ok(()) diff --git a/bin/yipd/tests/run-netns-threshold-ca.sh b/bin/yipd/tests/run-netns-threshold-ca.sh new file mode 100755 index 00000000..1fa9ee16 --- /dev/null +++ b/bin/yipd/tests/run-netns-threshold-ca.sh @@ -0,0 +1,206 @@ +#!/usr/bin/env bash +# Integration test for decentralized threshold CA (RFC 9591 FROST) with yipd. +# Usage: run-netns-threshold-ca.sh +# +# Topology: +# Bridge brThresh0 (10.99.0.0/24) +# - nsCoord: 10.99.0.10 (yip-ca serve --role coordinator) +# - nsSign1: 10.99.0.11 (yip-ca serve --role signer) +# - nsSign2: 10.99.0.12 (yip-ca serve --role signer) +# - nsNode: 10.99.0.20 (yipd mesh node with threshold_ca_server) +# +# Flow: +# 1. Generate 2-of-3 FROST threshold keys using `yip-ca dealer-gen`. +# 2. Start Coordinator in nsCoord and Signer 1 & 2 in nsSign1/nsSign2. +# 3. Enroll a node certificate using `yip-ca enroll` against nsCoord. +# 4. Start yipd in nsNode using the threshold-issued certificate. +# 5. Query certificate status via control socket: `yipd cert status`. +# 6. Trigger online certificate renewal: `yipd cert renew` and assert success. +set -euo pipefail + +YIPD="${1:?Usage: $0 }" +YIPCA="${2:?Usage: $0 }" + +if [ "$(id -u)" -ne 0 ]; then + echo "[!] $0 requires root privileges for netns and veth setup." + exit 1 +fi + +TMPDIR="$(mktemp -d /tmp/yip-netns-thresh-test.XXXXXX)" + +BR="brThresh0" +NS_COORD="nsThreshCoord" +NS_SIGN1="nsThreshSign1" +NS_SIGN2="nsThreshSign2" +NS_NODE="nsThreshNode" + +VETH_COORD_H="vThCoord0"; VETH_COORD_N="vThCoord1" +VETH_SIGN1_H="vThSign10"; VETH_SIGN1_N="vThSign11" +VETH_SIGN2_H="vThSign20"; VETH_SIGN2_N="vThSign21" +VETH_NODE_H="vThNode0"; VETH_NODE_N="vThNode1" + +IP_COORD="10.99.0.10" +IP_SIGN1="10.99.0.11" +IP_SIGN2="10.99.0.12" +IP_NODE="10.99.0.20" +VETH_PREFIX="24" +CA_PORT="9099" +NETWORK_ID="ca4eca4eca4eca4eca4eca4eca4eca4e" + +PID_COORD="" +PID_SIGN1="" +PID_SIGN2="" +PID_NODE="" + +cleanup() { + echo "[cleanup] stopping daemons and deleting namespaces" + [ -n "$PID_NODE" ] && kill "$PID_NODE" 2>/dev/null || true + [ -n "$PID_SIGN2" ] && kill "$PID_SIGN2" 2>/dev/null || true + [ -n "$PID_SIGN1" ] && kill "$PID_SIGN1" 2>/dev/null || true + [ -n "$PID_COORD" ] && kill "$PID_COORD" 2>/dev/null || true + sleep 0.2 + [ -n "$PID_NODE" ] && kill -9 "$PID_NODE" 2>/dev/null || true + [ -n "$PID_SIGN2" ] && kill -9 "$PID_SIGN2" 2>/dev/null || true + [ -n "$PID_SIGN1" ] && kill -9 "$PID_SIGN1" 2>/dev/null || true + [ -n "$PID_COORD" ] && kill -9 "$PID_COORD" 2>/dev/null || true + ip netns del "$NS_NODE" 2>/dev/null || true + ip netns del "$NS_SIGN2" 2>/dev/null || true + ip netns del "$NS_SIGN1" 2>/dev/null || true + ip netns del "$NS_COORD" 2>/dev/null || true + ip link del "$BR" 2>/dev/null || true + rm -rf "$TMPDIR" +} +trap cleanup EXIT INT TERM + +echo "[1/6] Generating 2-of-3 FROST threshold keys with yip-ca dealer-gen..." +DEALER_OUT="$("$YIPCA" dealer-gen --threshold 2 --signers 3 --out-dir "$TMPDIR/keys")" +echo "$DEALER_OUT" +ROOT_PUB="$(echo "$DEALER_OUT" | grep "threshold_ca_public=" | cut -d= -f2)" +echo "Group Verifying Key: $ROOT_PUB" + +echo "[2/6] Setting up network namespaces and bridge..." +ip link add "$BR" type bridge +ip link set "$BR" up + +setup_ns() { + local ns="$1" + local vh="$2" + local vn="$3" + local ip="$4" + ip netns add "$ns" + ip link add "$vh" type veth peer name "$vn" + ip link set "$vh" master "$BR" + ip link set "$vh" up + ip link set "$vn" netns "$ns" + ip -netns "$ns" addr add "$ip/$VETH_PREFIX" dev "$vn" + ip -netns "$ns" link set "$vn" up + ip -netns "$ns" link set lo up +} + +setup_ns "$NS_COORD" "$VETH_COORD_H" "$VETH_COORD_N" "$IP_COORD" +setup_ns "$NS_SIGN1" "$VETH_SIGN1_H" "$VETH_SIGN1_N" "$IP_SIGN1" +setup_ns "$NS_SIGN2" "$VETH_SIGN2_H" "$VETH_SIGN2_N" "$IP_SIGN2" +setup_ns "$NS_NODE" "$VETH_NODE_H" "$VETH_NODE_N" "$IP_NODE" + +# Verify underlay connectivity +ip netns exec "$NS_NODE" ping -c 1 -W 1 "$IP_COORD" >/dev/null + +echo "[3/6] Starting Coordinator and Signer daemons in namespaces..." +ip netns exec "$NS_COORD" "$YIPCA" serve \ + --role coordinator \ + --bind "$IP_COORD:$CA_PORT" \ + --threshold 2 \ + --pubkey "$TMPDIR/keys/pubkey_package.json" > "$TMPDIR/coord.log" 2>&1 & +PID_COORD=$! + +sleep 0.3 + +ip netns exec "$NS_SIGN1" "$YIPCA" serve \ + --role signer \ + --coordinator "$IP_COORD:$CA_PORT" \ + --key "$TMPDIR/keys/signer_1.json" \ + --pubkey "$TMPDIR/keys/pubkey_package.json" > "$TMPDIR/signer1.log" 2>&1 & +PID_SIGN1=$! + +ip netns exec "$NS_SIGN2" "$YIPCA" serve \ + --role signer \ + --coordinator "$IP_COORD:$CA_PORT" \ + --key "$TMPDIR/keys/signer_2.json" \ + --pubkey "$TMPDIR/keys/pubkey_package.json" > "$TMPDIR/signer2.log" 2>&1 & +PID_SIGN2=$! + +# Allow signers to connect and register commitments +sleep 1.0 + +echo "[4/6] Enrolling node certificate via threshold CA..." +NODE_GENKEY="$("$YIPD" --genkey)" +NODE_PRIV="$(echo "$NODE_GENKEY" | grep "private=" | cut -d= -f2)" +NODE_PUB="$(echo "$NODE_GENKEY" | grep "public=" | cut -d= -f2)" + +NODE_SIGN_GENKEY="$("$YIPCA" genkey)" +NODE_SIGN_PRIV="$(echo "$NODE_SIGN_GENKEY" | grep "ca_private=" | cut -d= -f2)" +NODE_SIGN_PUB="$(echo "$NODE_SIGN_GENKEY" | grep "ca_public=" | cut -d= -f2)" + +ip netns exec "$NS_NODE" "$YIPCA" enroll \ + --server "$IP_COORD:$CA_PORT" \ + --member "$NODE_PUB" \ + --member-sign "$NODE_SIGN_PUB" \ + --network "$NETWORK_ID" \ + --client-key "$NODE_SIGN_PRIV" \ + --out "$TMPDIR/node.cert" + +if [ ! -s "$TMPDIR/node.cert" ]; then + echo "[!] Failed to enroll certificate via threshold CA!" + cat "$TMPDIR/coord.log" + cat "$TMPDIR/signer1.log" + exit 1 +fi +echo "Certificate issued successfully ($(wc -c < "$TMPDIR/node.cert") bytes)." + +echo "[5/6] Starting yipd with threshold-issued certificate..." +# Dummy signed rootset trusting root_pub +echo "$ROOT_PUB 10.99.0.254:51820" > "$TMPDIR/roots.txt" +# Create rootset file +# We create a dummy rootset hex by running yip-ca +ROOT_GENKEY="$("$YIPCA" genkey)" +ROOT_GEN_PRIV="$(echo "$ROOT_GENKEY" | grep "ca_private=" | cut -d= -f2)" +ROOT_GEN_PUB="$(echo "$ROOT_GENKEY" | grep "ca_public=" | cut -d= -f2)" +"$YIPCA" sign-roots --roots "$TMPDIR/roots.txt" --version 1 --ca-private "$ROOT_GEN_PRIV" > "$TMPDIR/roots.signed" + +# Hex encode the cert binary for config cert= +xxd -p -c 10000 "$TMPDIR/node.cert" > "$TMPDIR/node.cert.hex" + +cat < "$TMPDIR/node.conf" +local_private=$NODE_PRIV +local_public=$NODE_PUB +listen=$IP_NODE:51820 +device=yip0 +ca_public=$ROOT_PUB +ca_public=$ROOT_GEN_PUB +cert=$TMPDIR/node.cert.hex +roots=$TMPDIR/roots.signed +member_sign_private=$NODE_SIGN_PRIV +network_id=$NETWORK_ID +threshold_ca_server=$IP_COORD:$CA_PORT +control_socket=$TMPDIR/node.sock +EOF + +ip netns exec "$NS_NODE" "$YIPD" "$TMPDIR/node.conf" > "$TMPDIR/yipd.log" 2>&1 & +PID_NODE=$! + +sleep 1.0 + +echo "[6/6] Verifying control socket status and renewal..." +# Query status +STATUS_OUT="$(ip netns exec "$NS_NODE" "$YIPD" cert status -c "$TMPDIR/node.sock")" +echo "$STATUS_OUT" +echo "$STATUS_OUT" | grep -q "Threshold CA: configured" +echo "$STATUS_OUT" | grep -q "TTL Remaining:" + +# Query renew +RENEW_OUT="$(ip netns exec "$NS_NODE" "$YIPD" cert renew -c "$TMPDIR/node.sock")" +echo "$RENEW_OUT" +echo "$RENEW_OUT" | grep -q "certificate renewed successfully" +echo "$RENEW_OUT" | grep -q "New Not After:" + +echo "[SUCCESS] Decentralized Threshold CA netns test passed completely!" From 7eb86b7fdfd2220761a0d28a2ce6feca54047aeb Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Tue, 27 Oct 2026 14:19:52 -0400 Subject: [PATCH 10/12] ci: fix cargo-deny unmaintained advisory and cargo-shear unused dependencies --- .pre-commit-config.yaml | 10 ++++++++++ Cargo.lock | 2 -- bin/yip-ca/Cargo.toml | 2 -- deny.toml | 4 ++++ 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 37bac953..3f8b591b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -32,3 +32,13 @@ repos: language: system types: [rust] pass_filenames: false + - id: deny + name: cargo deny + entry: cargo deny check + language: system + pass_filenames: false + - id: shear + name: cargo shear + entry: cargo shear + language: system + pass_filenames: false diff --git a/Cargo.lock b/Cargo.lock index 3e34fc45..a5f5e014 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,6 @@ dependencies = [ "ed25519-dalek", "frost-core", "frost-ed25519", - "frost-rerandomized", "getrandom 0.4.3", "postcard", "rand 0.8.8", @@ -2591,7 +2590,6 @@ dependencies = [ "thiserror", "tokio", "yip-membership", - "zeroize", ] [[package]] diff --git a/bin/yip-ca/Cargo.toml b/bin/yip-ca/Cargo.toml index cc2ed47c..64747383 100644 --- a/bin/yip-ca/Cargo.toml +++ b/bin/yip-ca/Cargo.toml @@ -23,11 +23,9 @@ ed25519-dalek = { version = "3", features = ["rand_core"] } rand_core = "0.10" frost-core = "3.0.0" frost-ed25519 = "3.0.0" -frost-rerandomized = "3.0.0" rand = "0.8" postcard = "1.1" tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time", "io-util", "sync", "macros"] } -zeroize = { version = "1.8", features = ["derive"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = { workspace = true } diff --git a/deny.toml b/deny.toml index 0e260da3..16111fd9 100644 --- a/deny.toml +++ b/deny.toml @@ -1,5 +1,9 @@ [advisories] yanked = "deny" +ignore = [ + # atomic-polyfill is unmaintained, pulled in transitively by frost-core -> postcard -> heapless + "RUSTSEC-2023-0089", +] [licenses] allow = ["AGPL-3.0-or-later", "MIT", "Apache-2.0", "BSD-3-Clause", "ISC", "Unicode-3.0"] From 346d11d0d8ef3bcc5bd74988c4db343a08191841 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Tue, 27 Oct 2026 15:13:21 -0400 Subject: [PATCH 11/12] docs: comprehensive guides, architecture diagrams, examples, and changelog for decentralized threshold CA (#39) --- CHANGELOG.md | 6 + README.md | 40 +++- docs/01-getting-started.md | 98 +++++++-- docs/02-configuration.md | 72 ++++++- docs/03-architecture.md | 147 +++++++++++++- docs/04-security-and-threat-model.md | 9 +- example.config | 11 +- examples/threshold-ca/README.md | 186 ++++++++++++++++++ .../systemd/yip-ca-coordinator.service | 17 ++ .../systemd/yip-ca-signer@.service | 17 ++ .../threshold-ca/yipd-threshold-member.conf | 34 ++++ 11 files changed, 615 insertions(+), 22 deletions(-) create mode 100644 examples/threshold-ca/README.md create mode 100644 examples/threshold-ca/systemd/yip-ca-coordinator.service create mode 100644 examples/threshold-ca/systemd/yip-ca-signer@.service create mode 100644 examples/threshold-ca/yipd-threshold-member.conf diff --git a/CHANGELOG.md b/CHANGELOG.md index 93b9976a..7fa5d353 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ No release has been cut yet — everything below is tracked under `[Unreleased]` until 0.1.0. ### Security +- **Decentralized Threshold CA Quorum via RFC 9591 FROST Ed25519 & ROAST Asynchronous Coordination (`bin/yip-ca`, `crates/yip-membership`, `bin/yipd`, #39, PR #180).** + Added an online, decentralized $t$-of-$n$ threshold Certificate Authority (CA) daemon architecture coexisting seamlessly alongside the offline single-key CA model. Uses the RFC 9591 FROST(Ed25519, SHA-512) ciphersuite (`frost-ed25519 v3.0.0`) to jointly issue mesh member certificates (`Cert`) without reconstructing the master private scalar on any single host. Because FROST signatures aggregate linearly into standard 64-byte Ed25519 signatures, certificates emitted by the threshold quorum are wire-format and byte-for-byte identical to single-signer certificates, requiring zero changes to node verifiers (`RootSet::verify_cert`). Hardened against CVE-2025-58359 threshold-drift vulnerabilities via pinned `frost-core` and `frost-ed25519` 3.0.0. Implemented the ROAST (Robust Asynchronous Schnorr Threshold) state machine, guaranteeing unforgeable issuance and liveness even if up to $n - t$ signers are unresponsive, delayed, or Byzantine. +- **Rollback-immune ephemeral nonce vault & zero-touch background renewal (`bin/yip-ca`, `bin/yipd`, #39, PR #180).** + Engineered `EphemeralNonceVault` with locked non-swappable physical memory (`libc::mlock`), `ZeroizeOnDrop` clearing, and atomic single-use consumption (`take_once`), binding nonce pools to a 128-bit CSPRNG `boot_epoch_id` to completely eliminate nonce-reuse private key extraction attacks under VM snapshot rollback and container restore events. Implemented automated certificate lifetime monitoring in `bin/yipd`, triggering non-blocking background threshold renewal at $\ge 70\%$ of elapsed certificate TTL, alongside UNIX domain control socket commands `yipd cert status` and `yipd cert renew`. - **Traffic-analysis defense subsystem & side-channel mitigations (`crates/yip-obf`, `bin/yipd`, #11).** Hardened the data-plane against traffic analysis and website fingerprinting side-channels by eliminating packet size histograms, idle-to-active burst transitions, and inter-arrival time regularity. Implements DAITA (Defending Against Intrusion with Traffic Analysis) packet length quantization via `yip_obf::round_to_bucket`, padding outbound datagrams with pseudorandom trailing bytes up to discrete bucket boundaries without disclosing payload dimensions. Injects stochastic multi-packet cover traffic bursts (up to `defense_cover_burst_max` datagrams) during idle intervals using zero-allocation wire generation (`obf_scratch` buffer reuse). Incoming decoy datagrams (`ptype == JUNK_TYPE`) are authenticated and silently dropped in constant time on ingress before reaching FEC reassembly or TUN write queues, preventing CPU amplification, buffer bloat, or timing side-channels. - **Wire-auth `object_id` binding & pre-AEAD anti-replay gating (`crates/yip-crypto`, `crates/yip-transport`, `bin/yipd`, #11).** @@ -37,6 +41,8 @@ until 0.1.0. Eliminated all per-packet heap allocations, `HashMap` lookups, and intermediate `Vec>` cloning from `FecReassembler::push()`. Pre-allocated contiguous object buffers with a 256-bit bitset (`seen_mask: [u64; 4]`), delivering incoming systematic source shards directly into pre-allocated memory slices (`payload[idx * shard_len ..]`) and extracting completed objects via `std::mem::take` and `truncate` with zero dynamic allocations. Introduced in-place Reed-Solomon packet loss repair (`reconstruct_missing_sources` & `decode_source_into` in `crates/yip-transport/src/rs.rs`), solving missing source shards directly into the pre-allocated payload buffer using a single scratch slice and eliminating the `Vec>` heap matrix. Replaced standard library `HashMap` with `ObjectTable`, a flat contiguous open-addressing table featuring SplitMix64 fast hashing and backward-shift deletion with zero tombstones and zero runtime allocations during packet ingress and eviction. Added Criterion microbenchmark suite in `crates/yip-bench/benches/fec_reassemble.rs`, verifying a 3.1x speedup on clean $K=1$ packets (117.11 ns vs 361.98 ns, -67.6% latency), 4.0x speedup on clean $K=3$ objects (173.12 ns vs 690.73 ns, -74.9% latency), and 1.65x speedup on loss recovery under $K=3, R=1$ (631.83 ns vs 1.0401 µs, -39.3% latency). ### Added +- **Decentralized Threshold CA CLI & RPC daemon suite (`yip-ca dealer-gen`, `serve`, `enroll`, #39, PR #180).** + Introduced `yip-ca dealer-gen` for generating $t$-of-$n$ Shamir secret key packages and group public keys, `yip-ca serve --role coordinator` for the ROAST enrollment gateway, `yip-ca serve --role signer` for isolated policy-enforcing signer daemons with single-round preprocessed nonce pools (`frost_ed25519::round1::preprocess`), and `yip-ca enroll` for client certificate requests. Added interactive 3-round Distributed Key Generation (DKG) without a trusted dealer, zero-knowledge proactive polynomial share refreshing (epoch rotation), and lost-share repair (`RepairCoordinator` / `RepairShareHelper`). Added `bin/yipd/tests/run-netns-threshold-ca.sh` multi-namespace integration test verifying end-to-end 2-of-3 threshold issuance, verification, and data-plane ping across 4 isolated network namespaces. - **FEC reassembly Criterion microbenchmark suite (`crates/yip-bench/benches/fec_reassemble.rs`, #171, PR #176).** Added isolated microbenchmark harnesses measuring clean-path reassembly latency ($K=1$ sub-symbol MTU-trimmed packets and $K=3$ multi-symbol objects) and packet-loss Reed-Solomon reconstruction latency ($K=3, R=1$). - **Comprehensive documentation landing page & modular curriculum overhaul (`docs/`, `README.md`, #170, PR #175).** diff --git a/README.md b/README.md index 438f68fe..5305be13 100644 --- a/README.md +++ b/README.md @@ -62,10 +62,37 @@ Because throughput is inversely proportional to the square root of packet loss p - **Zero-Allocation Hot-Path Reassembly**: Pre-allocated contiguous object buffers and in-place Cauchy RS repair (`reconstruct_missing_sources`) deliver clean packets in **117 ns** with zero heap allocations. ### 2. Zero-Trust PKI & Decentralized Mesh Membership -- **Cryptographic Authority (`yip-ca`)**: Offline or operator-governed root-of-trust issuing signed node membership certificates (`Cert`). +- **Dual Cryptographic CA Architecture**: Supports both an air-gapped, offline master key root CA (`yip-ca sign-cert`) and an online, decentralized **$t$-of-$n$ Threshold CA** (`yip-ca serve --role coordinator|signer`). +- **RFC 9591 FROST Ed25519 Quorum**: Threshold signers collaborate using the FROST(Ed25519, SHA-512) ciphersuite. Because Schnorr threshold signatures aggregate linearly into a standard 64-byte Ed25519 signature, threshold credentials are 100% byte-for-byte indistinguishable from single-signer certificates, requiring zero changes to existing node verifiers. +- **Single-Round (1-RTT) Online Enrollment**: Pre-generated nonce pools (`frost_ed25519::round1::preprocess`) pre-cached at the coordinator collapse online certificate enrollment from two network round-trips to a single RPC round-trip. +- **Asynchronous Robustness (ROAST)**: Coordinator signing loop is wrapped in a ROAST state machine, guaranteeing progress and unforgeable issuance even if up to $n - t$ signers are unresponsive, delayed, or Byzantine. +- **Rollback-Immune Nonce Vault**: Memory-locked (`mlock`), zeroized-on-drop ephemeral nonce pools bound to 128-bit CSPRNG boot-epoch IDs eliminate nonce-reuse private key extraction under VM snapshots. +- **Automated Lifecycle & Background Renewal**: Proactive zero-knowledge polynomial share refreshing (CVE-2025-58359 hardened), lost-share repair, and automated zero-touch renewal inside `bin/yipd` at $\ge 70\%$ of certificate TTL. - **Signed RootSets & Epidemic Gossip**: Node identity, authorized endpoints, and routing capabilities propagate across nodes using anti-entropy gossip protocol records (`Record`) with cryptographic signature validation. - **TAI64N Anti-Replay Freshness Gating**: Sub-nanosecond monotonic timestamps enforce strict certificate freshness and reject stale or replayed topology announcements. +```mermaid +flowchart TD + subgraph ThresholdCA ["Decentralized Threshold CA Quorum (t-of-n FROST)"] + Coord["Coordinator Gateway
(yip-ca serve --role coordinator)
ROAST State Machine"] + S1["Signer 1
KeyShare 1
Nonce Vault"] + S2["Signer 2
KeyShare 2
Nonce Vault"] + Sn["Signer n
KeyShare n
Nonce Vault"] + + S1 -.->|1. Preprocessed Nonce Commitments| Coord + S2 -.->|1. Preprocessed Nonce Commitments| Coord + Sn -.->|1. Preprocessed Nonce Commitments| Coord + end + + Node["Mesh Member Node (yipd)
Automatic 70% TTL Rotation"] + + Node -->|2. Enroll Request (CSR + PoP)| Coord + Coord -->|3. SigningPackage (Single RTT)| S1 & S2 + S1 & S2 -->|4. Signature Shares| Coord + Coord -->|5. Linear Aggregation (RFC 9591)| Coord + Coord -->|6. Standard 64-byte Ed25519 Cert| Node +``` + ### 3. Kernel-Bypass Performance - **Zero-Copy AF_XDP (XSK)**: Direct UMEM memory ring driver with per-core slab partitioning and lock-free ring recycling bypasses the Linux network stack, sustaining 40.27 Gbps and 3.93 Mpps across 8 CPU cores. - **Asynchronous `io_uring`**: High-performance multishot ring polling drives high packet rates without thread contention or syscall overhead. @@ -176,12 +203,21 @@ listen = 0.0.0.0:443 device = yip0 rendezvous = 198.51.100.5:443 -# Zero-trust CA mesh enrollment (issued via yip-ca) +# Zero-trust CA mesh enrollment (issued via yip-ca offline or online threshold CA) ca_public = 8a03a4cf43c2907041efb394c0effabad5bdb4cbbc35d17c924c0b6cbfa8ba75 cert = /etc/yip/node.cert roots = /etc/yip/network.roots member_sign_private = c22239d00522843313aa03a662e8f0cb6abb7889f8f103510ebb5dee0e924857 network_id = f11a8e0df8fa6352eeb62192de7ece47 + +# Optional: Online threshold CA coordinator for zero-touch background renewal at >= 70% TTL +threshold_ca_server = 198.51.100.10:4443 +``` + +Check certificate validity or trigger immediate renewal via the daemon control socket: +```bash +sudo yipd cert status +sudo yipd cert renew ``` --- diff --git a/docs/01-getting-started.md b/docs/01-getting-started.md index 297dc08a..8e59170b 100644 --- a/docs/01-getting-started.md +++ b/docs/01-getting-started.md @@ -534,27 +534,36 @@ sudo yipctl top In the point-to-point setup in Section 4, each node required an explicit `[peer]` block defining the remote public key and endpoint. While simple for two nodes, configuring and distributing static peer blocks across dozens or hundreds of nodes becomes an operational headache. -**Mesh mode solves this with zero-trust cryptographic admission.** Instead of maintaining static peer lists, nodes dynamically discover authorized members and establish point-to-point tunnels on demand: - -1. An **offline Certificate Authority (`yip-ca`)** issues cryptographic membership certificates (`Cert`) binding a node's Noise identity and Ed25519 gossip signing key. -2. A **signed root set (`RootSet`)** seeds the network with authoritative bootstrap nodes. -3. Nodes gossip membership records with sub-nanosecond TAI64N anti-replay protection. -4. When traffic is routed to a peer's self-certifying IPv6 address (`fd00::/8`), `yipd` dynamically resolves the peer endpoint from the gossip directory and brings up the encrypted Noise-IK session. +**Mesh mode solves this with zero-trust cryptographic admission.** Instead of maintaining static peer lists, nodes dynamically discover authorized members and establish point-to-point tunnels on demand. `yip` provides two CA models: +- **Model A: Offline Single-Key Root CA**: For air-gapped root-of-trust deployments where certificates and root sets are minted manually. +- **Model B: Decentralized Threshold CA ($t$-of-$n$ FROST)**: For online, automated environments where signing authority is distributed across a quorum of independent signer daemons, with 1-RTT enrollment and automated background renewal in `yipd`. ```mermaid flowchart TD - CA["Offline CA (yip-ca)
Holds ca_private"] -- "1. Issue Cert" --> NodeA["Node A
Holds node.cert"] - CA -- "1. Issue Cert" --> NodeB["Node B
Holds node.cert"] - CA -- "2. Sign RootSet" --> Roots["network.roots
Lists Bootstrap Seed Nodes"] + subgraph Quorum ["Certificate Authority Options"] + OfflineCA["Model A: Offline Single CA
Air-gapped Master Key"] + ThresholdCA["Model B: t-of-n Threshold CA
RFC 9591 FROST Quorum"] + end + + NodeA["Node A
Holds node.cert"] + NodeB["Node B
Holds node.cert"] + Roots["network.roots
Lists Bootstrap Seed Nodes"] + + OfflineCA -.->|Issue Cert / Sign Roots| NodeA & NodeB & Roots + ThresholdCA -.->|Online 1-RTT Enroll / Renew| NodeA & NodeB - NodeA -- "3. Boot & Load roots" --> Roots - NodeB -- "3. Boot & Load roots" --> Roots + NodeA -- "Boot & Load roots" --> Roots + NodeB -- "Boot & Load roots" --> Roots - NodeA <-->|"4. Authenticated Gossip
(TAI64N Timestamped)"| NodeB - NodeA <===>|"5. Dynamic Encrypted Tunnel
(Noise-IK + RS-FEC)"| NodeB + NodeA <-->|"Authenticated Gossip
(TAI64N Timestamped)"| NodeB + NodeA <===>|"Dynamic Encrypted Tunnel
(Noise-IK + RS-FEC)"| NodeB ``` -### Step 1: Initialize the Offline Root CA +--- + +### Model A: Offline Root CA Workflow + +#### Step 1: Initialize the Offline Root CA Run `yip-ca genkey` on a secure management machine (or offline workstation) to generate the root CA keypair: @@ -624,6 +633,58 @@ yip-ca sign-roots \ Distribute `/etc/yip/network.roots` to all member nodes. +--- + +### Model B: Decentralized Threshold CA Workflow (FROST Ed25519) + +In this model, no single machine holds the root private key. Instead, authority is partitioned across a $t$-of-$n$ quorum of signer daemons. + +#### Step 1: Generate Threshold Key Packages + +Generate a 2-of-3 threshold quorum: +```sh +mkdir -p /etc/yip/ca-shares +yip-ca dealer-gen --threshold 2 --signers 3 --out-dir /etc/yip/ca-shares +``` +*Output:* +- `pubkey_package.json`: Contains the group verifying key (`threshold_ca_public`). Distribute this to the coordinator and signers. +- `signer_1.json`, `signer_2.json`, `signer_3.json`: Private `KeyPackage` shares. Distribute exactly one file to each respective signer host (`chmod 0600`). + +#### Step 2: Start Coordinator and Signer Daemons + +On the coordinator host (e.g. `198.51.100.10:4443`): +```sh +yip-ca serve \ + --role coordinator \ + --bind 0.0.0.0:4443 \ + --threshold 2 \ + --pubkey /etc/yip/ca-shares/pubkey_package.json +``` + +On each of the three signer hosts (initiates an outbound persistent connection with preprocessed nonces): +```sh +yip-ca serve \ + --role signer \ + --coordinator 198.51.100.10:4443 \ + --key /etc/yip/ca-shares/signer_1.json \ + --pubkey /etc/yip/ca-shares/pubkey_package.json \ + --max-ttl 2592000 +``` + +#### Step 3: Enroll Member Node Online (1-RTT) + +Request a signed certificate from the online coordinator in a single round-trip: +```sh +yip-ca enroll \ + --server 198.51.100.10:4443 \ + --member "$NODE_PUB" \ + --member-sign "$SIGN_PUB" \ + --network f11a8e0df8fa6352eeb62192de7ece47 \ + --out /etc/yip/node.cert +``` + +--- + ### Step 5: Configure Mesh Node Create `/etc/yip/yipd.conf` on the member node. Note the complete absence of any `[peer]` blocks: @@ -643,6 +704,9 @@ roots=/etc/yip/network.roots member_sign_private=c22239d00522843313aa03a662e8f0cb6abb7889f8f103510ebb5dee0e924857 network_id=f11a8e0df8fa6352eeb62192de7ece47 +# Optional: Threshold CA coordinator for automated 70% TTL background rotation +# threshold_ca_server=198.51.100.10:4443 + # Optional: Add rendezvous for members located behind NAT rendezvous=198.51.100.5:443 ``` @@ -672,6 +736,12 @@ sudo yipctl peers When you send traffic to any member's self-certifying IPv6 address (e.g. `ping6 `), `yipd` resolves the destination through its internal directory and establishes the tunnel automatically. +If running with `threshold_ca_server`, check certificate renewal status or trigger immediate rotation via the control socket: +```sh +sudo yipd cert status +sudo yipd cert renew +``` + --- ## 6. Troubleshooting & Common Pitfalls diff --git a/docs/02-configuration.md b/docs/02-configuration.md index e4503d90..a7d0e31b 100644 --- a/docs/02-configuration.md +++ b/docs/02-configuration.md @@ -499,6 +499,9 @@ roots=/etc/yip/network.roots member_sign_private=c22239d00522843313aa03a662e8f0cb6abb7889f8f103510ebb5dee0e924857 network_id=f11a8e0df8fa6352eeb62192de7ece47 +# Optional: Online Threshold CA Coordinator for zero-touch 70% TTL auto-renewal +threshold_ca_server=198.51.100.10:4443 + # Optional: Set rendezvous relay for members behind NAT rendezvous=198.51.100.1:443 ``` @@ -608,11 +611,12 @@ Parameters governing NAT traversal, blind relays, and decentralized cryptographi | Configuration Directive | Type | Default Value | Valid Range / Format | Operational Impact | | :--- | :--- | :--- | :--- | :--- | | `rendezvous`
*(TOML: `server`)* | String | *None* (Optional) | `IP:port`, `tls://...`, `reality://...` | Coordinator address. Supports plain UDP (`IP:port`), TLS relay (`tls://host:port`), and REALITY relay dial (`reality://...`). | -| `ca_public` | String (Hex) | *None* (Repeatable) | 64 hex characters (32 bytes) | Trusted root CA Ed25519 public key(s). Used to verify membership certificates and the network root set. | +| `ca_public` | String (Hex) | *None* (Repeatable) | 64 hex characters (32 bytes) | Trusted root CA Ed25519 public key(s). Used to verify membership certificates and the network root set. For a threshold CA, set to group verifying key from `dealer-gen`. | | `cert` | String (Path) | *None* | Filesystem path | Path to file containing this node's CA-signed membership certificate blob. | | `roots` | String (Path) | *None* | Filesystem path | Path to file containing the CA-signed root set. Verified against `ca_public` at boot. | | `member_sign_private` | String (Hex) | *None* | 64 hex characters (32 bytes) | Node Ed25519 signing key used to sign gossip membership records. Kept strictly secret. | | `network_id` | String (Hex) | *None* | 32 hex characters (16 bytes) | Unique 16-byte identifier for the private mesh network. | +| `threshold_ca_server`
*(Alias: `ca_server`)* | String | *None* (Optional) | `IP:port` (IPv4 or `[IPv6]:port`) | Address of online threshold CA coordinator. Enables automatic background renewal at $\ge 70\%$ of certificate TTL. | #### Rendezvous Dial Scheme Formats - **Plain UDP**: `rendezvous=198.51.100.5:51821` @@ -711,13 +715,77 @@ yipctl peer add 203.0.113.25:443 raw # Dynamically remove a peer yipctl peer rm +# Query node certificate status, remaining TTL, and threshold renewal threshold +yipctl cert status + +# Trigger an immediate certificate renewal against the threshold CA coordinator +yipctl cert renew + # Launch the real-time terminal throughput dashboard yipctl top ``` --- -## 5. Troubleshooting Common Failures +## 5. `yip-ca` Command-Line Reference + +`yip-ca` manages both offline and online Certificate Authority operations: + +### Offline CA Commands +```sh +# Generate single-signer CA Ed25519 keypair +yip-ca genkey + +# Issue signed membership certificate +yip-ca sign-cert \ + --member <32-byte-hex> \ + --member-sign <32-byte-hex> \ + --network <16-byte-hex> \ + (--days | --secs ) \ + [--ca-private <64-byte-hex>] + +# Sign bootstrap network root set +yip-ca sign-roots \ + --roots \ + --version \ + [--ca-private <64-byte-hex>] +``` + +### Decentralized Threshold CA Commands (RFC 9591 FROST) +```sh +# Generate t-of-n Shamir key packages and group public key +yip-ca dealer-gen \ + --threshold \ + --signers \ + --out-dir + +# Run online coordinator daemon (enrollment gateway + ROAST aggregation) +yip-ca serve \ + --role coordinator \ + --bind \ + --threshold \ + --pubkey + +# Run isolated signer daemon (nonce vault + local policy engine) +yip-ca serve \ + --role signer \ + --coordinator \ + --key \ + --pubkey \ + [--max-ttl ] + +# Enroll client node directly over online RPC (1-RTT preprocessed nonces) +yip-ca enroll \ + --server \ + --member <32-byte-hex> \ + --member-sign <32-byte-hex> \ + --network <16-byte-hex> \ + [--out ] +``` + +--- + +## 6. Troubleshooting Common Failures ### 1. `PermissionDenied` when binding port 443 - **Cause**: Unprivileged process attempting to bind a network port below 1024. diff --git a/docs/03-architecture.md b/docs/03-architecture.md index d4bd492c..c58e57b1 100644 --- a/docs/03-architecture.md +++ b/docs/03-architecture.md @@ -57,6 +57,14 @@ For automated benchmarks, performance analysis, and tuning, see [testing-and-ben - [Sliding Bitmap Replay Protection](#sliding-bitmap-replay-protection) - [Side-Channel Mitigation & Memory Zeroization](#side-channel-mitigation--memory-zeroization) - [Blind Packet Injection & DoS Defense](#blind-packet-injection--dos-defense) +- [9. Decentralized Threshold PKI & FROST Consensus Quorum](#9-decentralized-threshold-pki--frost-consensus-quorum) + - [Dual PKI Topology & Wire Compatibility Invariant](#dual-pki-topology--wire-compatibility-invariant) + - [RFC 9591 FROST(Ed25519, SHA-512) Ciphersuite](#rfc-9591-frosted25519-sha-512-ciphersuite) + - [Single-Round (1-RTT) Preprocessed Nonce Pools](#single-round-1-rtt-preprocessed-nonce-pools) + - [ROAST Asynchronous Coordinator State Machine](#roast-asynchronous-coordinator-state-machine) + - [Rollback-Immune Ephemeral Nonce Vault](#rollback-immune-ephemeral-nonce-vault) + - [Proactive Polynomial Refresh & Lost-Share Repair](#proactive-polynomial-refresh--lost-share-repair) + - [Zero-Touch Background Renewal in yipd (70% TTL)](#zero-touch-background-renewal-in-yipd-70-ttl) --- @@ -744,4 +752,141 @@ To defeat packet replay attacks across high-speed connections, `crates/yip-crypt --- -*For further implementation details, inspect the source implementations in [`crates/yip-io`](../crates/yip-io), [`crates/yip-crypto`](../crates/yip-crypto), [`crates/yip-transport`](../crates/yip-transport), and [`crates/yip-wire`](../crates/yip-wire).* +## 9. Decentralized Threshold PKI & FROST Consensus Quorum + +To eliminate operational friction and single-point-of-compromise vulnerabilities inherent in centralized or offline root authorities, `yip` implements a decentralized **Threshold Certificate Authority** ($t$-of-$n$) grounded in **RFC 9591 Flexible Round-Optimized Schnorr Threshold (FROST)** signatures. + +```mermaid +flowchart TD + subgraph ThresholdCA ["Decentralized Threshold CA Quorum (t-of-n)"] + Coord["ROAST Coordinator Gateway
(yip-ca serve --role coordinator)
Maintains Nonce Pools & Aggregation"] + S1["Signer 1
KeyPackage 1
EphemeralNonceVault"] + S2["Signer 2
KeyPackage 2
EphemeralNonceVault"] + Sn["Signer n
KeyPackage n
EphemeralNonceVault"] + + S1 -- "Preprocessed Nonces" --> Coord + S2 -- "Preprocessed Nonces" --> Coord + Sn -- "Preprocessed Nonces" --> Coord + end + + Client["Mesh Node (yipd)
Self-Certifying Identity"] + + Client -->|"1. EnrollRequest (CSR + PoP)"| Coord + Coord -->|"2. Round2SignRequest (Single RTT)"| S1 & S2 + S1 & S2 -->|"3. Round2SignResponse (Shares)"| Coord + Coord -->|"4. Aggregate 64-byte Ed25519 Sig"| Coord + Coord -->|"5. Standard Wire-Identical Cert"| Client +``` + +### Dual PKI Topology & Wire Compatibility Invariant + +`yip` supports two distinct operational modes for Certificate Authorities: +1. **Offline Single-Signer CA**: Master private scalar $sk$ stored air-gapped on an administrative host (`yip-ca sign-cert`). +2. **Decentralized Threshold CA**: Secret scalar is partitioned into $n$ Shamir secret shares across independent daemons (`yip-ca serve --role signer`). + +**The Wire Compatibility Invariant**: +Under Schnorr threshold signatures (RFC 9591 §5.2), partial signature shares $z_i = d_i + (e_i \cdot \rho_i) + \lambda_i s_i c$ aggregate linearly: + +$$z = \sum_{i \in S} z_i \pmod \ell$$ + +The resulting signature pair $(R, z)$ is a standard 64-byte Ed25519 signature verifying under group public key $Y = s \cdot B$. + +> [!IMPORTANT] +> **Byte-for-Byte Wire Compatibility**: Certificates emitted by a threshold quorum are bit-for-bit indistinguishable from certificates issued by a single-signer CA. The verifier in `crates/yip-membership/src/cert.rs::verify_cert`, the data-plane handshake in `bin/yipd/src/handshake.rs`, and rendezvous relay registration require zero modifications or protocol forks. + +### RFC 9591 FROST(Ed25519, SHA-512) Ciphersuite + +The threshold engine uses `frost-ed25519 v3.0.0` over Edwards25519 with SHA-512 transcript hashing: +- **Group Elements**: Compressed 32-byte Edwards points. +- **Scalars**: 32-byte integers modulo $\ell = 2^{252} + 27742317777372353535851937790883648493$. +- **Hardened Dependencies**: Pinned against CVE-2025-58359 threshold-drift vulnerabilities. + +### Single-Round (1-RTT) Preprocessed Nonce Pools + +Standard FROST requires two interactive rounds: Round 1 (commitment exchange) and Round 2 (partial signature emission). Across WAN networks, 2-RTT signing doubles latency and increases susceptibility to packet loss. + +`yip-ca` collapses online enrollment into **1-RTT single-round signing** via preprocessed nonce pools: + +```mermaid +sequenceDiagram + autonumber + participant S as Signer Daemons (P_1..P_n) + participant C as Coordinator Gateway + participant N as Enrolling Node (yipd) + + Note over S,C: Offline / Continuous Background Preprocessing + S->>S: Generate Nonces (d_i, e_i) & Commitments (D_i, E_i) + S->>C: RegisterCommitments(batch of 16 nonces) + Note over C: Coordinator caches pool of valid SigningCommitments + + Note over N,C: Single-Round Online Certificate Enrollment + N->>C: EnrollRequest(Node Identity, MemberSign, NetworkID, CSR PoP) + Note over C: Validate CSR PoP & Assemble SigningPackage + C->>S: Round2SignRequest(session_id, SigningPackage, nonces) + Note over S: Atomic take_once() from EphemeralNonceVault & Policy Check + S->>C: Round2SignResponse(session_id, SignatureShare) + Note over C: Verify shares & Linear Aggregation + C->>N: EnrollResponse(Standard 64-byte Ed25519 Cert) +``` + +### ROAST Asynchronous Coordinator State Machine + +To prevent slow or malicious signers from stalling certificate issuance, the coordinator wraps FROST signing sessions in the **ROAST (Robust Asynchronous Schnorr Threshold)** algorithm: + +```mermaid +stateDiagram-v2 + [*] --> Idle: Coordinator Listening + Idle --> Inspecting: Incoming EnrollRequest + Inspecting --> SelectQuorum: CSR Proof-of-Possession Valid + Inspecting --> Idle: Malformed / Invalid PoP + + SelectQuorum --> AwaitingShares: Dispatch Round2SignRequest to t Signers + + AwaitingShares --> Aggregate: Received t Valid SignatureShares + AwaitingShares --> ReassignQuorum: Signer Timeout / Malformed Share (Identifiable Abort) + + ReassignQuorum --> AwaitingShares: Mark Culprit Byzantine, Select Alternate Signer + + Aggregate --> Delivered: Compute Combined (R, z) & Emit Cert + Delivered --> Idle: Refresh Nonce Stock +``` + +- **Identifiable Aborts**: If a signer returns an invalid partial signature, `frost_ed25519::verify_signature_share` pinpoint-identifies the dishonest participant (`Error::culprit()`), allowing the coordinator to log the fault, mark the signer Byzantine, and substitute an alternate responsive signer without restarting the enrollment session. +- **$n - t$ Fault Tolerance**: The system guarantees liveness as long as any $t$ honest signers are reachable. + +### Rollback-Immune Ephemeral Nonce Vault + +In virtualized or cloud environments, VM snapshots, live migrations, and container crash-restarts present a catastrophic threat to threshold Schnorr signatures: if a signer rolls back its state and signs a distinct message with an identical nonce pair $(d_i, e_i)$, an attacker can solve for the signer's long-term private share $s_i$. + +`yip-ca` mitigates this via `EphemeralNonceVault`: +1. **Physical Memory Locking (`mlock`)**: Nonce allocations are locked into non-swappable physical RAM to prevent core dumps or swap partition leakage. +2. **Monotonic Boot Epoch Watermark**: At startup, the vault initializes a 128-bit CSPRNG `boot_epoch_id`. Any attempt to present nonces from a mismatched epoch is rejected. +3. **Atomic Single-Use Extraction (`take_once`)**: Nonces are moved out of the vault atomically and zeroized (`ZeroizeOnDrop`) upon consumption. A nonce can never be reused. + +### Proactive Polynomial Refresh & Lost-Share Repair + +To protect long-lived quorums against mobile adversaries who compromise signers sequentially over time, `yip-ca` supports zero-knowledge proactive share refreshing: +- **Zero-Knowledge Refresh Polynomials**: Signers exchange zero-constant polynomials $f_i(0) = 0$. Each signer updates its secret share $s_i' = s_i + \sum \delta_{j \to i}$, completely invalidating all historical shares while leaving the group verifying key $Y$ strictly invariant. +- **Lost-Share Repair**: If a signer suffers hardware failure or data corruption, an online repair ceremony allows $t$ active signers to construct a replacement share for the restored node without reconstructing the root secret. + +### Zero-Touch Background Renewal in yipd (70% TTL) + +Nodes running `yipd` with `threshold_ca_server` configured eliminate manual certificate renewal overhead: + +```mermaid +flowchart LR + subgraph yipd ["yipd Background Monitor"] + Check["Periodic Tick
Measure Cert Lifetime"] + Check -->|< 70% TTL| Sleep["No Action
Continue Fast Datapath"] + Check -->|>= 70% TTL| Renew["Non-Blocking Online Renewal
RPC to threshold_ca_server"] + Renew --> Success["Atomically Update Memory Cert
& Overwrite Disk File"] + end +``` + +- **Non-Blocking Background Renewal**: Initiated during event loop ticks without interrupting active data-plane packet forwarding or kernel-bypass queues. +- **Fail-Safe Grace Window**: If the coordinator or threshold quorum is transiently unreachable at 70% TTL, `yipd` logs a warning and retries smoothly across the remaining 30% validity window. +- **Operator Observability**: Real-time status and manual rotation are exposed via `yipd cert status` and `yipd cert renew` over the UNIX domain control socket. + +--- + +*For further implementation details, inspect the source implementations in [`crates/yip-io`](../crates/yip-io), [`crates/yip-crypto`](../crates/yip-crypto), [`crates/yip-transport`](../crates/yip-transport), [`crates/yip-membership`](../crates/yip-membership), and [`bin/yip-ca`](../bin/yip-ca).* diff --git a/docs/04-security-and-threat-model.md b/docs/04-security-and-threat-model.md index fe6a3650..f7626ed1 100644 --- a/docs/04-security-and-threat-model.md +++ b/docs/04-security-and-threat-model.md @@ -597,7 +597,12 @@ To maintain realistic engineering boundaries, the following threats are explicit 1. **Compromised Host Endpoints**: If an adversary obtains root access, kernel execution privileges, or arbitrary memory inspection capabilities on the host running `yipd`, the adversary can read plaintext packets before encryption or extract active keys from memory. Host security is the responsibility of the host operating system. 2. **Anonymity & Mix-Net Routing (Tor Alternative)**: `yip` is a secure VPN and overlay network, **not** an anonymous communication network like Tor or I2P. Direct peer-to-peer mesh connections reveal the public IP addresses of participating peers to each other. Users requiring sender/receiver unlinkability must route traffic through multi-hop anonymizing overlays. 3. **Traffic Volume & Flow-Correlation without Padding**: While `yip` provides packet padding and junk datagram insertion, an adversary capable of simultaneously observing traffic entering and leaving a node can perform statistical traffic correlation (packet timing and volume correlation) if padding (`daita_padding`) is disabled. -4. **Compromised Private Root CA**: If the private signing key of the offline `yip-ca` is compromised, an adversary can issue valid certificates for rogue nodes. CA keys must be maintained offline in secure enclaves or hardware security modules (HSMs). +4. **Compromised Private Root CA & Threshold Quorum Defense**: + - *Offline Single CA*: If the private signing key of an offline `yip-ca` is compromised, an adversary can issue valid certificates for rogue nodes. CA keys must be maintained offline in secure enclaves or hardware security modules (HSMs). + - *Decentralized Threshold CA ($t$-of-$n$ FROST)*: To eliminate single-point-of-compromise risks in online networks, `yip-ca` distributes signing authority across $n$ independent signer daemons using RFC 9591 FROST over Ed25519. An adversary must compromise at least $t$ signers simultaneously to reconstruct the root scalar or forge credentials; compromising fewer than $t$ signers yields zero cryptographic advantage. + - *VM Snapshot Nonce Reuse Protection*: Signers manage single-use signing nonces inside a memory-locked (`mlock`) `EphemeralNonceVault` tied to a 128-bit CSPRNG `boot_epoch_id` and consumed via atomic `take_once()`, preventing private-key extraction attacks if a signer VM is snapshot-rolled back or restarted. + - *Proactive Mobile Adversary Defense*: Periodic zero-knowledge polynomial share refreshing updates all signers' key shares without altering the group public key, invalidating past compromised shares against slow-moving adaptive attackers (hardened against CVE-2025-58359). + - *Byzantine Liveness via ROAST*: The coordinator's signing loop employs the Robust Asynchronous Schnorr Threshold state machine. If up to $n - t$ signers stall, crash, or submit malformed shares, `verify_signature_share` pinpoint-identifies the culprit, reassigns the signing package to an alternate responsive signer, and issues the certificate without liveness degradation. --- @@ -620,4 +625,6 @@ The table below provides a quick reference to all cryptographic algorithms, key | **Browser Mimicry** | Chrome 150 TLS Profile | JA3 / JA4 Byte Match | [`crates/yip-utls/src/hello.rs`](../crates/yip-utls/src/hello.rs) | Censorship bypass via browser mimicry | | **REALITY Front** | TLS 1.3 Certificate Borrowing | Auth Seal: 32 B in `legacy_session_id` | [`bin/yip-rendezvous/src/reality.rs`](../bin/yip-rendezvous/src/reality.rs) | Active probe redirection via TCP splicing | | **CA Signatures** | Ed25519 (RFC 8032) | Public: 32 B, Signature: 64 B | [`crates/yip-membership/src/cert.rs`](../crates/yip-membership/src/cert.rs#L1-L50) | Node authorization without online PKI | +| **Threshold CA** | RFC 9591 FROST(Ed25519, SHA-512) | Group PK: 32 B, Share: 32 B, Sig: 64 B | [`bin/yip-ca/src/threshold/mod.rs`](../bin/yip-ca/src/threshold/mod.rs) | $t$-of-$n$ decentralized issuance & ROAST quorum | +| **Self-Cert IPv6** | BLAKE2s Variable Digest (`fd00::/8`) | 128-bit IPv6 Address | [`bin/yipd/src/addr.rs`](../bin/yipd/src/addr.rs) | Cryptographic anti-spoofing overlay IP | | **Self-Cert IPv6** | BLAKE2s Variable Digest (`fd00::/8`) | 128-bit IPv6 Address | [`bin/yipd/src/addr.rs`](../bin/yipd/src/addr.rs) | Cryptographic anti-spoofing overlay IP | diff --git a/example.config b/example.config index 023fc418..400e0d2a 100644 --- a/example.config +++ b/example.config @@ -112,9 +112,10 @@ endpoint=203.0.113.10:443 # (See docs/user-guide.md "Mesh mode" for the offline `yip-ca` workflow.) # # ca_public — trusted CA Ed25519 public key (64 hex). REPEATABLE (one line -# per CA) to trust multiple / rotate CAs. +# per CA) to trust multiple / rotate CAs. For a threshold CA, +# set this to the group verifying key output by `dealer-gen`. # cert — path to a file holding this node's CA-signed cert (hex, from -# `yip-ca sign-cert`). +# `yip-ca sign-cert` or `yip-ca enroll`). # roots — path to a file holding the CA-signed root set (hex, from # `yip-ca sign-roots`). Verified against ca_public at load time. # member_sign_private — this node's Ed25519 record-signing private key (64 hex; @@ -122,11 +123,17 @@ endpoint=203.0.113.10:443 # network_id — 16-byte mesh network id (32 hex chars — note: HALF the length # of the other hex keys). # +# threshold_ca_server — (Optional) IP:port of an online yip-ca threshold coordinator. +# When configured, yipd automatically monitors certificate validity +# and executes zero-touch background renewal when >= 70% of TTL has +# elapsed. Can also be triggered via `yipd cert renew`. Alias: ca_server. +# # ca_public=8a03a4cf43c2907041efb394c0effabad5bdb4cbbc35d17c924c0b6cbfa8ba75 # cert=/etc/yip/this-node.cert # roots=/etc/yip/network.roots # member_sign_private=c22239d00522843313aa03a662e8f0cb6abb7889f8f103510ebb5dee0e924857 # network_id=f11a8e0df8fa6352eeb62192de7ece47 +# threshold_ca_server=198.51.100.10:4443 # --------------------------------------------------------------------------- diff --git a/examples/threshold-ca/README.md b/examples/threshold-ca/README.md new file mode 100644 index 00000000..b6831a14 --- /dev/null +++ b/examples/threshold-ca/README.md @@ -0,0 +1,186 @@ +# Decentralized Threshold CA Example & Quickstart + +This directory contains reference configurations and an operational guide for deploying an online, distributed **Threshold Certificate Authority** ($t$-of-$n$) using RFC 9591 FROST (Flexible Round-Optimized Schnorr Threshold) signatures over Ed25519. + +--- + +## Architecture Overview + +Unlike the offline single-key CA model where an operator holds the master private key on an air-gapped machine, the decentralized threshold CA distributes signing shares across $n$ independent signer daemons: + +```text + ┌────────────────────────────────────────────────────────┐ + │ Online Coordinator Gateway │ + │ `yip-ca serve --role coordinator` │ + │ (Listens on 0.0.0.0:4443 / WAN) │ + └───────────▲───────────────────────────▲────────────────┘ + │ │ + mTLS / Noise │ Persistent │ Persistent + Stream │ Nonce Stock │ Nonce Stock + ▼ ▼ + ┌────────────────────────────┐ ┌────────────────────────────┐ + │ Signer Daemon 1 │ │ Signer Daemon 2 │ + │ `yip-ca serve --role signer│ │ `yip-ca serve --role signer│ + │ (KeyPackage Share 1) │ │ (KeyPackage Share 2) │ + │ Policy Engine & Nonces │ │ Policy Engine & Nonces │ + └────────────────────────────┘ └────────────────────────────┘ + ▲ + │ Single-Round 1-RTT Enrollment / Renewal + │ + ┌─────────────┴────────────────────────────┐ + │ Enrolling Mesh Node │ + │ `yip-ca enroll` or `yipd` │ + │ (Background 70% TTL Auto-Renewal) │ + └──────────────────────────────────────────┘ +``` + +### Key Properties +- **Standard Ed25519 Certificates**: FROST signatures aggregate linearly into standard 64-byte Ed25519 signatures. Mesh nodes, root sets, and verifiers validate threshold-issued certificates identically to single-signer certificates with zero wire-format changes. +- **Single-Round (1-RTT) Online Enrollment**: Signers pre-generate nonce commitment pools (`frost_ed25519::round1::preprocess`) and register them with the coordinator, eliminating Round 1 commitment exchange at enrollment time. +- **Asynchronous Fault Tolerance (ROAST)**: Certificate issuance succeeds as long as any $t$ of $n$ signers respond, even if $n - t$ signers are offline, high-latency, or malicious. +- **Rollback-Immune Nonce Vault**: Ephemeral nonces are memory-locked (`libc::mlock`), wiped immediately on single-use consumption (`take_once`), and bound to monotonic 128-bit CSPRNG boot epochs to defeat VM snapshot key-recovery attacks. +- **Automated Lifecycle**: Nodes running `yipd` automatically monitor certificate lifetime and renew in the background at $\ge 70\%$ of TTL. + +--- + +## 1. Key Generation (2-of-3 Quorum) + +Generate a 2-of-3 threshold key set using `yip-ca dealer-gen`: + +```bash +mkdir -p /etc/yip/ca-shares +yip-ca dealer-gen --threshold 2 --signers 3 --out-dir /etc/yip/ca-shares +``` + +This creates: +- `pubkey_package.json`: Contains the group verifying key (`threshold_ca_public`) and individual signer verification keys. Distribute this to the coordinator and all signers. +- `signer_1.json`, `signer_2.json`, `signer_3.json`: Private `KeyPackage` shares. **Distribute exactly one file to each respective signer host and keep them secret (`chmod 0600`)**. + +Take note of the printed group public key, for example: +```text +threshold_ca_public=8a03a4cf43c2907041efb394c0effabad5bdb4cbbc35d17c924c0b6cbfa8ba75 +generated 2-of-3 keys in /etc/yip/ca-shares +``` + +--- + +## 2. Launching the Coordinator + +On the coordinator host (e.g. `198.51.100.10:4443`): + +```bash +yip-ca serve \ + --role coordinator \ + --bind 0.0.0.0:4443 \ + --threshold 2 \ + --pubkey /etc/yip/ca-shares/pubkey_package.json +``` + +The coordinator will open a TCP socket, wait for signers to connect and register commitment pools, and serve incoming enrollment requests. + +--- + +## 3. Launching Signer Daemons + +On each signer host, connect outbound to the coordinator. Signers maintain a live pool of single-use nonces and independently evaluate local signing policies: + +```bash +# On Signer Host 1: +yip-ca serve \ + --role signer \ + --coordinator 198.51.100.10:4443 \ + --key /etc/yip/ca-shares/signer_1.json \ + --pubkey /etc/yip/ca-shares/pubkey_package.json \ + --max-ttl 2592000 + +# On Signer Host 2: +yip-ca serve \ + --role signer \ + --coordinator 198.51.100.10:4443 \ + --key /etc/yip/ca-shares/signer_2.json \ + --pubkey /etc/yip/ca-shares/pubkey_package.json \ + --max-ttl 2592000 + +# On Signer Host 3: +yip-ca serve \ + --role signer \ + --coordinator 198.51.100.10:4443 \ + --key /etc/yip/ca-shares/signer_3.json \ + --pubkey /etc/yip/ca-shares/pubkey_package.json \ + --max-ttl 2592000 +``` + +--- + +## 4. Client Enrollment + +A new or existing node enrolls via `yip-ca enroll`: + +```bash +# Generate node keys if not already created +yipd --genkey > /etc/yip/node.key +yip-ca genkey > /etc/yip/member-sign.key + +NODE_PUB=$(grep '^public=' /etc/yip/node.key | cut -d= -f2) +SIGN_PUB=$(grep '^ca_public=' /etc/yip/member-sign.key | cut -d= -f2) + +# Request a signed certificate from the threshold quorum +yip-ca enroll \ + --server 198.51.100.10:4443 \ + --member "$NODE_PUB" \ + --member-sign "$SIGN_PUB" \ + --network f11a8e0df8fa6352eeb62192de7ece47 \ + --out /etc/yip/node.cert +``` + +--- + +## 5. Configuring yipd for Zero-Touch Background Renewal + +In `/etc/yip/yipd.conf`, specify `ca_public` and `threshold_ca_server`: + +```ini +local_private=50b522bb0b57e7eb133e9b11b5275ea011cf8c3f4e24eb37452d192131b0e01c +local_public=9b49ffeb4566b53a0f78cae08920bc9e30a6e0c60f252cfdf4740e74f8c92b21 +listen=0.0.0.0:443 +device=yip0 + +# Mesh PKI Settings +ca_public=8a03a4cf43c2907041efb394c0effabad5bdb4cbbc35d17c924c0b6cbfa8ba75 +cert=/etc/yip/node.cert +roots=/etc/yip/network.roots +member_sign_private=c22239d00522843313aa03a662e8f0cb6abb7889f8f103510ebb5dee0e924857 +network_id=f11a8e0df8fa6352eeb62192de7ece47 + +# Online Threshold CA Coordinator +threshold_ca_server=198.51.100.10:4443 +``` + +When `threshold_ca_server` is present: +- `yipd` periodically checks certificate validity. +- At $\ge 70\%$ elapsed TTL, `yipd` initiates a non-blocking background renewal directly with the coordinator. +- Upon successful aggregation, the new certificate replaces the active certificate in memory and writes atomically to disk. + +--- + +## 6. Runtime Inspection & Manual Rotation + +You can inspect certificate status or trigger an immediate renewal through the UNIX domain control socket: + +```bash +# Query active certificate lifetime and renewal threshold +sudo yipd cert status + +# Output: +# Certificate Status: +# Valid: true +# Remaining TTL: 604800s +# Renewal Threshold: 423360s (70% TTL) +# Threshold CA: true (198.51.100.10:4443) + +# Force an immediate threshold renewal +sudo yipd cert renew + +# Output: +# Certificate renewal requested successfully +``` diff --git a/examples/threshold-ca/systemd/yip-ca-coordinator.service b/examples/threshold-ca/systemd/yip-ca-coordinator.service new file mode 100644 index 00000000..899a9315 --- /dev/null +++ b/examples/threshold-ca/systemd/yip-ca-coordinator.service @@ -0,0 +1,17 @@ +[Unit] +Description=yip-ca Threshold Coordinator Daemon +After=network.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/yip-ca serve --role coordinator --bind 0.0.0.0:4443 --threshold 2 --pubkey /etc/yip/ca/pubkey_package.json +Restart=always +RestartSec=5s +LimitMEMLOCK=infinity +CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_IPC_LOCK +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/etc/yip/ca + +[Install] +WantedBy=multi-user.target diff --git a/examples/threshold-ca/systemd/yip-ca-signer@.service b/examples/threshold-ca/systemd/yip-ca-signer@.service new file mode 100644 index 00000000..3a8b0181 --- /dev/null +++ b/examples/threshold-ca/systemd/yip-ca-signer@.service @@ -0,0 +1,17 @@ +[Unit] +Description=yip-ca Threshold Signer Daemon (Share %i) +After=network.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/yip-ca serve --role signer --coordinator 198.51.100.10:4443 --key /etc/yip/ca/signer_%i.json --pubkey /etc/yip/ca/pubkey_package.json --max-ttl 2592000 +Restart=always +RestartSec=5s +LimitMEMLOCK=infinity +CapabilityBoundingSet=CAP_IPC_LOCK +ProtectSystem=strict +ProtectHome=true +ReadOnlyPaths=/etc/yip/ca + +[Install] +WantedBy=multi-user.target diff --git a/examples/threshold-ca/yipd-threshold-member.conf b/examples/threshold-ca/yipd-threshold-member.conf new file mode 100644 index 00000000..003361b4 --- /dev/null +++ b/examples/threshold-ca/yipd-threshold-member.conf @@ -0,0 +1,34 @@ +# /etc/yip/yipd.conf +# Reference configuration for a mesh member enrolled in a decentralized threshold CA network. + +local_private=50b522bb0b57e7eb133e9b11b5275ea011cf8c3f4e24eb37452d192131b0e01c +local_public=9b49ffeb4566b53a0f78cae08920bc9e30a6e0c60f252cfdf4740e74f8c92b21 +listen=0.0.0.0:443 +device=yip0 +device_kind=tun + +# --------------------------------------------------------------------------- +# Zero-Trust Mesh & Threshold CA Auto-Renewal +# --------------------------------------------------------------------------- +# ca_public: Set to the group verifying key produced during `dealer-gen`. +ca_public=8a03a4cf43c2907041efb394c0effabad5bdb4cbbc35d17c924c0b6cbfa8ba75 + +# Path to the active certificate issued by the threshold quorum. +cert=/etc/yip/node.cert + +# Path to the signed root set. +roots=/etc/yip/network.roots + +# Node's private signing key for epidemic gossip records. +member_sign_private=c22239d00522843313aa03a662e8f0cb6abb7889f8f103510ebb5dee0e924857 + +# 16-byte network identifier (32 hex characters). +network_id=f11a8e0df8fa6352eeb62192de7ece47 + +# Online threshold CA coordinator address. +# When configured, yipd monitors certificate expiry and automatically contacts +# the coordinator to renew the certificate at 70% elapsed TTL. +threshold_ca_server=198.51.100.10:4443 + +# Optional: Rendezvous server for NAT traversal +# rendezvous=198.51.100.5:443 From 4fd23c46f314715a6b5f90c20f90e52415c574e8 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Tue, 27 Oct 2026 15:20:29 -0400 Subject: [PATCH 12/12] docs(readme): polish landing page hierarchy, fix mermaid syntax, and elevate threshold CA hero narrative --- README.md | 272 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 169 insertions(+), 103 deletions(-) diff --git a/README.md b/README.md index 5305be13..076d4842 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ # 🦊 yip **Low-latency P2P mesh VPN in Rust.**
-*Reed-Solomon FEC loss immunity • 40 Gbps AF_XDP multi-core scaling • NAT traversal • Zero-trust CA mesh • Anti-DPI TLS mimicry* +*Reed-Solomon FEC loss immunity • 40 Gbps AF_XDP multi-core scaling • Decentralized threshold PKI • Anti-DPI TLS mimicry*

CI @@ -25,110 +25,57 @@ ## What is yip? -Ever wonder why standard VPNs choke the moment a packet drops? WireGuard is fantastic on a pristine gigabit fiber link, but on lossy Wi-Fi, cellular, or crowded trans-oceanic routes, TCP panics, triggers exponential backoff, and collapses. +Standard VPNs collapse the moment a physical link drops packets. WireGuard delivers exceptional throughput on clean fiber, but on lossy Wi-Fi, cellular networks, or congested trans-oceanic routes, even 2% packet loss forces TCP into exponential backoff, cutting effective speeds by up to 99%. -**yip fixes this.** It is a fast, clever peer-to-peer mesh VPN in Rust that bakes Reed-Solomon forward error correction straight into the wire. Missing packets get reconstructed in-flight before the application even realizes a drop happened. +**yip fixes this at the root.** It is a high-performance peer-to-peer mesh VPN in Rust that bakes Reed-Solomon forward error correction (FEC) directly into the datapath. Missing packets are reconstructed in-flight before the kernel TCP stack or user applications ever realize a drop occurred. -Under the hood, yip pairs proactive loss recovery with 40 Gbps AF_XDP zero-copy line-rate scaling, automatic NAT hole-punching, post-quantum ML-KEM handshakes, and authentic Chrome TLS mimicry to sneak past strict DPI firewalls unnoticed. It runs circles around packet loss and keeps your connections alive where ordinary tunnels stall out. +Under the hood, yip pairs proactive loss recovery with **40 Gbps AF_XDP zero-copy scaling**, post-quantum **ML-KEM-512 handshakes**, an online **$t$-of-$n$ Decentralized Threshold CA** (RFC 9591 FROST), and authentic **Chrome TLS mimicry** to glide past aggressive DPI firewalls unnoticed. + +> [!TIP] +> **Decentralized Threshold CA Hero Feature**: +> yip avoids single-key CA failure by partitioning root signing authority across a distributed $t$-of-$n$ signer quorum (`yip-ca serve`). Because FROST threshold signatures aggregate linearly into a standard 64-byte Ed25519 signature, threshold credentials require zero changes to node verifiers and support zero-touch 70% TTL auto-renewal inside `yipd`. See the turnkey [Threshold CA Quickstart](examples/threshold-ca/README.md). > [!NOTE] -> yip builds an encrypted peer-to-peer overlay, not an anonymizing mix-net like Tor. Connected peers authenticate each other directly and see each other's physical IP addresses. +> yip builds an encrypted peer-to-peer overlay, not an anonymizing mix-net like Tor. Connected peers authenticate directly and see each other's physical IP addresses. --- - -## Why yip Exists: The Problem with Traditional VPNs +## Start Here -Every modern tunnel protocol (WireGuard, OpenVPN, IPsec) operates on a fatal assumption: the underlying transport network delivers packets reliably. On clean local fiber or controlled datacenter cross-connects, that assumption holds. WireGuard achieves stellar throughput with minimal CPU overhead. +Find the fastest path to what you need: -The open internet is not a clean datacenter cross-connect. When you transmit across long-haul continental WANs, congested public Wi-Fi, cellular networks, or international boundaries, packet loss is an inescapable reality. - -When an underlying network drops packets, standard VPNs pass that loss straight up the protocol stack. For TCP connections, this triggers catastrophic congestion control backoff. Mathis' formula models this throughput limit: - -$$T \le \frac{MSS}{RTT} \times \frac{C}{\sqrt{p}}$$ - -Because throughput is inversely proportional to the square root of packet loss probability ($p$), even a modest 1% to 3% packet loss rate forces TCP into severe window throttling, slashing effective throughput by 90% to 99%. On a 100 Mbit/s link with 60ms round-trip time, injecting just 3% packet loss can plummet transfer speeds from 95 Mbit/s down to under 10 Mbit/s. At 10% packet loss, connections stall completely. - -**yip's answer is proactive in-flight repair.** Instead of waiting for timeout expirations and reactive retransmissions, yip computes Cauchy Reed-Solomon erasure coding over Galois Field $GF(2^8)$ directly in the datapath. For every block of data packets, yip generates dynamic mathematical parity packets in-flight. When transit networks drop intermediate packets, yip reconstructs the missing symbols on the fly before kernel TCP stacks or user applications ever detect a gap. The result is sustained throughput and predictable latency across lossy links. +| Persona | Immediate Next Steps | Recommended Guide | +| :--- | :--- | :--- | +| 🟢 **New User** | Install binary → Generate keypair → Connect two nodes → Ping overlay | [5-Minute Quickstart](#5-minute-quickstart) • [`01. Getting Started`](docs/01-getting-started.md) | +| 🌐 **Mesh Operator** | Choose deployment topology → Set up root CA / Threshold Quorum → Configure seeds | [Deployment Modes](#primary-deployment-modes) • [`Threshold CA Guide`](examples/threshold-ca/README.md) • [`02. Configuration`](docs/02-configuration.md) | +| 🛠️ **Developer** | Inspect lock-free ring buffers → Zero-alloc FEC pipeline → Run test suites | [`03. Architecture`](docs/03-architecture.md) • [`crates/yip-bench`](crates/yip-bench/RESULTS.md) | +| 🛡️ **Security Reviewer** | Verify post-quantum hybrid KEM → ROAST threshold quorum → Anti-DPI test oracle | [Trust Model](#crisp-trust-model--security-invariants) • [`04. Security & Threat Model`](docs/04-security-and-threat-model.md) | --- -## Comprehensive 6-Pillar Feature Showcase - -### 1. Galois Field Proactive FEC -- **Systematic Cauchy RS + RLNC Streaming**: In-flight erasure coding over $GF(2^8)$ pairs Cauchy Reed-Solomon block codes with Random Linear Network Coding (RLNC) streaming fountain codes. -- **Poly-Vector SIMD Acceleration**: Hardware-accelerated matrix multiplication across AVX-512BW, Intel GFNI, AVX2, SSSE3, ARM NEON, and WebAssembly SIMD128. -- **Adaptive Repair Scaling**: Real-time sliding window telemetry dynamically adjusts repair symbol overhead based on observed channel loss, eliminating goodput waste on clean links. -- **Zero-Allocation Hot-Path Reassembly**: Pre-allocated contiguous object buffers and in-place Cauchy RS repair (`reconstruct_missing_sources`) deliver clean packets in **117 ns** with zero heap allocations. - -### 2. Zero-Trust PKI & Decentralized Mesh Membership -- **Dual Cryptographic CA Architecture**: Supports both an air-gapped, offline master key root CA (`yip-ca sign-cert`) and an online, decentralized **$t$-of-$n$ Threshold CA** (`yip-ca serve --role coordinator|signer`). -- **RFC 9591 FROST Ed25519 Quorum**: Threshold signers collaborate using the FROST(Ed25519, SHA-512) ciphersuite. Because Schnorr threshold signatures aggregate linearly into a standard 64-byte Ed25519 signature, threshold credentials are 100% byte-for-byte indistinguishable from single-signer certificates, requiring zero changes to existing node verifiers. -- **Single-Round (1-RTT) Online Enrollment**: Pre-generated nonce pools (`frost_ed25519::round1::preprocess`) pre-cached at the coordinator collapse online certificate enrollment from two network round-trips to a single RPC round-trip. -- **Asynchronous Robustness (ROAST)**: Coordinator signing loop is wrapped in a ROAST state machine, guaranteeing progress and unforgeable issuance even if up to $n - t$ signers are unresponsive, delayed, or Byzantine. -- **Rollback-Immune Nonce Vault**: Memory-locked (`mlock`), zeroized-on-drop ephemeral nonce pools bound to 128-bit CSPRNG boot-epoch IDs eliminate nonce-reuse private key extraction under VM snapshots. -- **Automated Lifecycle & Background Renewal**: Proactive zero-knowledge polynomial share refreshing (CVE-2025-58359 hardened), lost-share repair, and automated zero-touch renewal inside `bin/yipd` at $\ge 70\%$ of certificate TTL. -- **Signed RootSets & Epidemic Gossip**: Node identity, authorized endpoints, and routing capabilities propagate across nodes using anti-entropy gossip protocol records (`Record`) with cryptographic signature validation. -- **TAI64N Anti-Replay Freshness Gating**: Sub-nanosecond monotonic timestamps enforce strict certificate freshness and reject stale or replayed topology announcements. - -```mermaid -flowchart TD - subgraph ThresholdCA ["Decentralized Threshold CA Quorum (t-of-n FROST)"] - Coord["Coordinator Gateway
(yip-ca serve --role coordinator)
ROAST State Machine"] - S1["Signer 1
KeyShare 1
Nonce Vault"] - S2["Signer 2
KeyShare 2
Nonce Vault"] - Sn["Signer n
KeyShare n
Nonce Vault"] - - S1 -.->|1. Preprocessed Nonce Commitments| Coord - S2 -.->|1. Preprocessed Nonce Commitments| Coord - Sn -.->|1. Preprocessed Nonce Commitments| Coord - end + +## Why yip Exists: The Problem with Traditional VPNs - Node["Mesh Member Node (yipd)
Automatic 70% TTL Rotation"] +Every traditional tunnel protocol (WireGuard, OpenVPN, IPsec) makes an optimistic assumption: the underlying transport delivers packets reliably. On clean datacenter cross-connects, that assumption holds. - Node -->|2. Enroll Request (CSR + PoP)| Coord - Coord -->|3. SigningPackage (Single RTT)| S1 & S2 - S1 & S2 -->|4. Signature Shares| Coord - Coord -->|5. Linear Aggregation (RFC 9591)| Coord - Coord -->|6. Standard 64-byte Ed25519 Cert| Node -``` +The open internet is not a clean datacenter cross-connect. On cellular data, satellite connections, crowded public Wi-Fi, or international transit routes, packet loss is an inescapable reality. When an underlying network drops packets, standard VPNs pass that loss straight up to the kernel. For TCP connections, this triggers catastrophic congestion window throttling modeled by Mathis' formula: -### 3. Kernel-Bypass Performance -- **Zero-Copy AF_XDP (XSK)**: Direct UMEM memory ring driver with per-core slab partitioning and lock-free ring recycling bypasses the Linux network stack, sustaining 40.27 Gbps and 3.93 Mpps across 8 CPU cores. -- **Asynchronous `io_uring`**: High-performance multishot ring polling drives high packet rates without thread contention or syscall overhead. -- **Multi-Queue TUN Sharding**: `IFF_MULTI_QUEUE` worker isolation maps packet flows to dedicated CPU cores via lock-free SPSC ring buffers. -- **Vectorized Socket I/O**: Batched `recvmmsg` and `sendmmsg` (tunable 32/64/128-packet bursts) with `UDP_SEGMENT` Generic Segmentation Offload (GSO) optimize standard POSIX network paths. -- **Flat Open-Addressing Tables**: Replaces dynamic pointer maps with bounded flat slot tables using SplitMix64 fast hashing and backward-shift deletion with zero tombstones. - -### 4. Post-Quantum Cryptography & Identity -- **Single-RTT Hybrid Handshake**: Blends Noise_IKpsk2 (Curve25519) with NIST FIPS 203 ML-KEM-512 (Kyber) within compact datagrams strictly under the 1200-byte internet MTU floor. -- **120-Second Epoch Ratcheting**: Automated forward-secrecy key rotation with a 20-second dual-receive grace window prevents dropped packets during rekey transitions. -- **Rigorous Memory Hygiene**: Cryptographic secrets implement explicit `Zeroize` traits to purge private key material from memory buffers immediately upon drop. -- **Self-Certifying IPv6 Addressing**: Cryptographic BLAKE2s-120 hashing derives immutable, anti-spoofing overlay addresses in the `fd00::/8` ULA space directly from node public keys. +$$T \le \frac{MSS}{RTT} \times \frac{C}{\sqrt{p}}$$ -### 5. 4-Mode Anti-DPI Armor -- **`raw-obf`**: SipHash-CTR keystream masking with a 64-bit SipHash-1-3 coverage authentication tag strips all fixed wire headers, leaving uniform high-entropy payload noise. -- **`quic` Masquerade**: Encapsulates tunnel traffic inside standard RFC 9221 HTTP/3 datagrams indistinguishable from ordinary web media traffic. -- **`tls` Emulation**: Authentic TLS 1.3 framing paired with uTLS Google Chrome ClientHello fingerprints evades SNI inspection and protocol whitelisting. -- **`relay` (REALITY)**: Advanced certificate borrowing and transparent TCP splicing forward unauthorized probes to authentic upstream target servers. -- **DAITA Cover Defense**: Discrete packet-length bucketing and randomized decoy traffic defeat statistical packet-size analysis and website fingerprinting. -- **4-Engine DPI Oracle Verified**: Continuously tested and verified clean against nDPI, Zeek, Suricata, and Wireshark inspection rules. +Because throughput is inversely proportional to the square root of loss probability ($p$), injecting just 3% packet loss on a 100 Mbit/s link (60ms RTT) slashes transfer speeds from 95 Mbit/s down to under 10 Mbit/s. At 10% loss, connections stall completely. -### 6. NAT Traversal & Blind Relays -- **STUN UDP Hole-Punching**: Embedded STUN resolver discovers public reflexive mappings and classifies symmetric, full-cone, and port-restricted NAT topologies. -- **Zero-Knowledge Blind Relays**: Untrusted rendezvous nodes (`yip-rendezvous`) relay encrypted envelopes without possessing decryption keys or inspecting packet contents. -- **Seamless Roaming**: Cryptographic session binding automatically maintains connection state when roaming between Wi-Fi, Ethernet, and cellular interfaces. -- **Headless Daemon Control**: Full management and real-time observability over a protected UNIX domain control socket using the `yipctl` command-line utility. +**yip's answer is proactive in-flight repair.** Instead of waiting for timeout expirations and reactive retransmissions, yip computes Cauchy Reed-Solomon erasure coding over Galois Field $GF(2^8)$ directly in the datapath. For every block of packets, yip generates mathematical parity symbols in-flight. Missing symbols are reconstructed in **117 ns** with zero heap allocations before TCP stacks detect a gap. --- -## When to Use yip +## When to Choose yip | Use yip for | Stick with WireGuard or others for | | :--- | :--- | | **Contended, lossy, or wireless links** (cellular, satellite, trans-oceanic transit) where packet drops stall standard tunnels | **Clean zero-loss gigabit LANs** where in-kernel WireGuard already saturates the wire with lower CPU overhead | -| **High-packet-rate 40G/100G edge infrastructure** scaling across multiple CPU cores via zero-copy AF_XDP | **Anonymity or mix-net routing** without trusted multi-hop relay chaining (peers authenticate directly) | -| **Hostile network environments** monitored by aggressive Deep Packet Inspection (DPI) firewalls | **Desktop platforms other than Linux** (macOS and Windows desktop clients are not currently supported) | +| **Zero-trust mesh networks** requiring decentralized $t$-of-$n$ threshold signing and automated certificate renewal | **Simple point-to-point home VPNs** where static public keys are easily managed manually | +| **High-packet-rate 40G/100G edge infrastructure** scaling across multiple CPU cores via zero-copy AF_XDP | **Anonymity or mix-net routing** without trusted direct peering (peers authenticate directly) | +| **Hostile network environments** monitored by active-probing Deep Packet Inspection (DPI) firewalls | **Desktop platforms other than Linux** (macOS and Windows desktop clients are not currently supported) | | **Future-proof quantum resistance** via NIST FIPS 203 ML-KEM-512 hybrid handshakes | Environments where legacy unencrypted protocols or centralized proprietary controllers are required | --- @@ -155,13 +102,12 @@ ping6 --- -## Minimal Working Configurations - -### Scenario A: Direct Point-to-Point (VPS-to-VPS) +## Primary Deployment Modes +### Mode 1: Direct Point-to-Point (VPS-to-VPS) Direct UDP tunnel between two publicly addressable nodes with Cauchy RS-FEC: -```toml +```ini # /etc/yip/yipd.conf (Host A: 198.51.100.1) local_private = dd152fc311a115549005da852d101a7d3a14594c728f71ff87aa0872da6c0fa9 local_public = bf923deeaddb66dde39e99987abdfd789c54eb882a570c340cd338071727ceeb @@ -174,11 +120,10 @@ public_key = 1f5b6b76d9723a79221777a7450522c32faa7300efd59b0a5efa42665cac0a0a endpoint = 203.0.113.2:443 ``` -### Scenario B: NAT Rendezvous (Behind CGNAT) - +### Mode 2: NAT Rendezvous (Behind CGNAT) Node behind NAT discovering and coordinating through an intermediate rendezvous relay: -```toml +```ini # /etc/yip/yipd.conf (Node A behind CGNAT) local_private = 2f2b5880cc15f29f826e876f5396c6f418e2366b73b415c5441f152b23c0334d local_public = 4cbc48cf095389d829abf3c7d78747ca35c96483375bd73860b2ebba94c99d50 @@ -191,11 +136,10 @@ rendezvous = 198.51.100.5:443 public_key = 6ed3f82ba6450cd14ec52509deb42a87c6a8d2fa91a5a145f2ddcfdb0d675d12 ``` -### Scenario C: Zero-Trust CA Mesh (Dynamic Discovery) - +### Mode 3: Zero-Trust Offline CA Mesh Nodes discover and peer with authorized members automatically via signed gossip and rendezvous, requiring zero manual `[peer]` entries: -```toml +```ini # /etc/yip/yipd.conf (Mesh Member Node) local_private = 2f2b5880cc15f29f826e876f5396c6f418e2366b73b415c5441f152b23c0334d local_public = 4cbc48cf095389d829abf3c7d78747ca35c96483375bd73860b2ebba94c99d50 @@ -203,23 +147,106 @@ listen = 0.0.0.0:443 device = yip0 rendezvous = 198.51.100.5:443 -# Zero-trust CA mesh enrollment (issued via yip-ca offline or online threshold CA) +# Offline CA credentials minted via `yip-ca sign-cert` +ca_public = 8a03a4cf43c2907041efb394c0effabad5bdb4cbbc35d17c924c0b6cbfa8ba75 +cert = /etc/yip/node.cert +roots = /etc/yip/network.roots +member_sign_private = c22239d00522843313aa03a662e8f0cb6abb7889f8f103510ebb5dee0e924857 +network_id = f11a8e0df8fa6352eeb62192de7ece47 +``` + +### Mode 4: Decentralized Threshold CA Mesh ($t$-of-$n$ FROST) +Eliminates single-point-of-compromise root keys. Online signers jointly issue and renew certificates via online RPC, with `yipd` handling zero-touch renewal in the background at 70% TTL: + +```ini +# /etc/yip/yipd.conf (Enrolled Node with Automated Renewal) ca_public = 8a03a4cf43c2907041efb394c0effabad5bdb4cbbc35d17c924c0b6cbfa8ba75 cert = /etc/yip/node.cert roots = /etc/yip/network.roots member_sign_private = c22239d00522843313aa03a662e8f0cb6abb7889f8f103510ebb5dee0e924857 network_id = f11a8e0df8fa6352eeb62192de7ece47 -# Optional: Online threshold CA coordinator for zero-touch background renewal at >= 70% TTL +# Online Threshold CA Coordinator for background 70% TTL renewal threshold_ca_server = 198.51.100.10:4443 ``` -Check certificate validity or trigger immediate renewal via the daemon control socket: +Check status or trigger immediate renewal via the control socket: ```bash sudo yipd cert status sudo yipd cert renew ``` +👉 **Complete deployment walk-through**: See the [Turnkey Threshold CA Tutorial & Systemd Units](examples/threshold-ca/README.md). + +--- + +## Comprehensive 6-Pillar Feature Showcase + +### 1. Galois Field Proactive FEC +- **Systematic Cauchy RS + RLNC Streaming**: In-flight erasure coding over $GF(2^8)$ pairs Cauchy Reed-Solomon block codes with Random Linear Network Coding streaming fountain codes. +- **Poly-Vector SIMD Acceleration**: Hardware-accelerated matrix multiplication across AVX-512BW, Intel GFNI, AVX2, SSSE3, ARM NEON, and WebAssembly SIMD128. +- **Adaptive Repair Scaling**: Real-time sliding window telemetry dynamically adjusts repair symbol overhead based on observed channel loss, eliminating goodput waste on clean links. +- **Zero-Allocation Hot-Path Reassembly**: Pre-allocated contiguous object buffers and in-place Cauchy RS repair (`reconstruct_missing_sources`) deliver clean packets in **117 ns** with zero heap allocations. + +### 2. Zero-Trust PKI & Decentralized Mesh Membership +- **Dual Cryptographic CA Architecture**: Supports both an air-gapped, offline master key root CA (`yip-ca sign-cert`) and an online, decentralized **$t$-of-$n$ Threshold CA** (`yip-ca serve --role coordinator|signer`). +- **RFC 9591 FROST Ed25519 Quorum**: Threshold signers collaborate using the FROST(Ed25519, SHA-512) ciphersuite. Because Schnorr threshold signatures aggregate linearly into a standard 64-byte Ed25519 signature, threshold credentials are 100% byte-for-byte indistinguishable from single-signer certificates, requiring zero changes to existing node verifiers. +- **Single-Round (1-RTT) Online Enrollment**: Pre-generated nonce pools (`frost_ed25519::round1::preprocess`) pre-cached at the coordinator collapse online certificate enrollment from two network round-trips to a single RPC round-trip. +- **Asynchronous Robustness (ROAST)**: Coordinator signing loop is wrapped in a ROAST state machine, guaranteeing progress and unforgeable issuance even if up to $n - t$ signers are unresponsive, delayed, or Byzantine. +- **Rollback-Immune Nonce Vault**: Memory-locked (`mlock`), zeroized-on-drop ephemeral nonce pools bound to 128-bit CSPRNG boot-epoch IDs eliminate nonce-reuse private key extraction under VM snapshots. +- **Automated Lifecycle & Background Renewal**: Proactive zero-knowledge polynomial share refreshing (CVE-2025-58359 hardened), lost-share repair, and automated zero-touch renewal inside `bin/yipd` at $\ge 70\%$ of certificate TTL. + +```mermaid +flowchart TD + subgraph ThresholdCA ["Decentralized Threshold CA Quorum (t-of-n FROST)"] + Coord["ROAST Coordinator Gateway
(yip-ca serve --role coordinator)"] + S1["Signer 1
KeyShare 1
Nonce Vault"] + S2["Signer 2
KeyShare 2
Nonce Vault"] + Sn["Signer n
KeyShare n
Nonce Vault"] + + S1 -.->|"1. Preprocessed Nonce Commitments"| Coord + S2 -.->|"1. Preprocessed Nonce Commitments"| Coord + Sn -.->|"1. Preprocessed Nonce Commitments"| Coord + end + + Node["Mesh Member Node (yipd)
Automatic 70% TTL Renewal"] + + Node -->|"2. Enroll Request with PoP"| Coord + Coord -->|"3. Single-RTT SigningPackage"| S1 + Coord -->|"3. Single-RTT SigningPackage"| S2 + S1 -->|"4. Signature Share"| Coord + S2 -->|"4. Signature Share"| Coord + Coord -->|"5. Linear Aggregation (RFC 9591)"| Coord + Coord -->|"6. Standard 64-byte Ed25519 Cert"| Node +``` + +### 3. Kernel-Bypass Performance +- **Zero-Copy AF_XDP (XSK)**: Direct UMEM memory ring driver with per-core slab partitioning and lock-free ring recycling bypasses the Linux network stack, sustaining 40.27 Gbps and 3.93 Mpps across 8 CPU cores. +- **Asynchronous `io_uring`**: High-performance multishot ring polling drives high packet rates without thread contention or syscall overhead. +- **Multi-Queue TUN Sharding**: `IFF_MULTI_QUEUE` worker isolation maps packet flows to dedicated CPU cores via lock-free SPSC ring buffers. +- **Vectorized Socket I/O**: Batched `recvmmsg` and `sendmmsg` (tunable 32/64/128-packet bursts) with `UDP_SEGMENT` Generic Segmentation Offload (GSO) optimize standard POSIX network paths. +- **Flat Open-Addressing Tables**: Replaces dynamic pointer maps with bounded flat slot tables using SplitMix64 fast hashing and backward-shift deletion with zero tombstones. + +### 4. Post-Quantum Cryptography & Identity +- **Single-RTT Hybrid Handshake**: Blends Noise_IKpsk2 (Curve25519) with NIST FIPS 203 ML-KEM-512 (Kyber) within compact datagrams strictly under the 1200-byte internet MTU floor. +- **120-Second Epoch Ratcheting**: Automated forward-secrecy key rotation with a 20-second dual-receive grace window prevents dropped packets during rekey transitions. +- **Rigorous Memory Hygiene**: Cryptographic secrets implement explicit `Zeroize` traits to purge private key material from memory buffers immediately upon drop. +- **Self-Certifying IPv6 Addressing**: Cryptographic BLAKE2s-120 hashing derives immutable, anti-spoofing overlay addresses in the `fd00::/8` ULA space directly from node public keys. + +### 5. 4-Mode Anti-DPI Armor +- **`raw-obf`**: SipHash-CTR keystream masking with a 64-bit SipHash-1-3 coverage authentication tag strips all fixed wire headers, leaving uniform high-entropy payload noise. +- **`quic` Masquerade**: Encapsulates tunnel traffic inside standard RFC 9221 HTTP/3 datagrams indistinguishable from ordinary web media traffic. +- **`tls` Emulation**: Authentic TLS 1.3 framing paired with uTLS Google Chrome ClientHello fingerprints evades SNI inspection and protocol whitelisting. +- **`relay` (REALITY)**: Advanced certificate borrowing and transparent TCP splicing forward unauthorized probes to authentic upstream target servers. +- **DAITA Cover Defense**: Discrete packet-length bucketing and randomized decoy traffic defeat statistical packet-size analysis and website fingerprinting. +- **4-Engine DPI Oracle Verified**: Continuously tested and verified clean against nDPI, Zeek, Suricata, and Wireshark inspection rules. + +### 6. NAT Traversal & Blind Relays +- **STUN UDP Hole-Punching**: Embedded STUN resolver discovers public reflexive mappings and classifies symmetric, full-cone, and port-restricted NAT topologies. +- **Zero-Knowledge Blind Relays**: Untrusted rendezvous nodes (`yip-rendezvous`) relay encrypted envelopes without possessing decryption keys or inspecting packet contents. +- **Seamless Roaming**: Cryptographic session binding automatically maintains connection state when roaming between Wi-Fi, Ethernet, and cellular interfaces. +- **Headless Daemon Control**: Full management and real-time observability over a protected UNIX domain control socket using the `yipctl` command-line utility. + --- ## Performance & Benchmark Highlights @@ -236,6 +263,10 @@ Deterministic benchmarks measured across multi-region cloud WANs (AWS `us-east-1 - **40.27 Gbps line-rate scaling**: Scales across 8 CPU cores (3.93 Mpps) using zero-copy AF_XDP and lock-free SPSC queues. - **Flat ~60ms P99 latency**: In-flight symbol reconstruction eliminates TCP head-of-line blocking and retransmission spikes across 0% to 10% packet loss. +> [!NOTE] +> **How to Interpret Benchmark Results**: +> On clean, zero-loss LANs or dedicated datacenter links, in-kernel WireGuard already saturates the wire with minimal CPU overhead. `yip` deliberately trades a minor fraction of clean-path CPU to compute in-flight Reed-Solomon parity symbols. The payoff appears under real-world internet transit conditions: while traditional tunnels trigger TCP window backoff and stall, `yip` sustains high goodput and flat latency. +

Click to expand raw automated WAN benchmark data table @@ -258,17 +289,52 @@ Full methodology and reproducible test harnesses are documented in [`crates/yip- --- +## Crisp Trust Model & Security Invariants + +`yip` is built on explicit, verifiable security boundaries: + +``` +┌───────────────────────────────────────┬───────────────────────────────────────┐ +│ WHAT IS PROTECTED │ WHAT IS NOT PROTECTED │ +├───────────────────────────────────────┼───────────────────────────────────────┤ +│ ✔ Payload Confidentiality & Integrity │ ✘ Physical Peer IP Concealment │ +│ ChaCha20-Poly1305 with post-quantum │ Peers authenticate directly and │ +│ hybrid ML-KEM-512 forward secrecy │ exchange traffic peer-to-peer │ +├───────────────────────────────────────┼───────────────────────────────────────┤ +│ ✔ Single-Point-of-Failure CA Defense │ ✘ Mix-Net Anonymity │ +│ t-of-n FROST quorum ensures master │ yip is not Tor; it does not route │ +│ private key never exists on 1 host │ through multi-hop onion circuits │ +├───────────────────────────────────────┼───────────────────────────────────────┤ +│ ✔ Probe Resistance & DPI Bypass │ ✘ Covert Timing on Raw UDP │ +│ REALITY splices active probers into │ Without DAITA padding enabled, │ +│ authentic CDNs (Apple/Microsoft) │ packet timing mirrors traffic flows │ +├───────────────────────────────────────┼───────────────────────────────────────┤ +│ ✔ Replay & Identity Spoofing │ ✘ Subverted Host Kernels │ +│ TAI64N monotonic freshness and │ The daemon relies on Linux memory │ +│ BLAKE2s cryptographic addressing │ isolation and privileged namespaces │ +└───────────────────────────────────────┴───────────────────────────────────────┘ +``` + +- **Threshold Signature Security**: An adversary compromising fewer than $t$ signers obtains zero knowledge of the master CA private scalar. +- **Rollback Immunity**: Ephemeral nonces in `EphemeralNonceVault` are memory-locked (`mlock`) and consumed via atomic single-use primitives to eliminate nonce-reuse private key extraction under VM snapshots. +- **Identifiable Aborts (ROAST)**: Honest coordinators pinpoint dishonest signers and continue signing without denial-of-service stalls. + +--- + ## Documentation Curriculum -| Document | Purpose | -| :--- | :--- | -| [`01. Getting Started`](docs/01-getting-started.md) | Platform requirements, installation options, systemd/init services, and first-tunnel setup | -| [`02. Configuration Reference`](docs/02-configuration.md) | Comprehensive parameter reference, production deployment scenarios, and `yipctl` command guide | -| [`03. Architecture & Internals`](docs/03-architecture.md) | Control and data plane separation, kernel-bypass AF_XDP queues, Cauchy RS-FEC, and memory hierarchy | -| [`04. Security & Threat Model`](docs/04-security-and-threat-model.md) | Post-quantum hybrid handshake, zero-fixed-signature wire format, and anti-DPI test oracle defenses | -| [`05. Benchmarks & Performance`](docs/05-benchmarking-and-performance.md) | Real-world WAN test data, multi-core scaling charts, loss-recovery mathematical proofs, and kernel tuning | -| [`06. Troubleshooting Runbook`](docs/06-troubleshooting.md) | Step-by-step diagnostic workflows, MTU discovery issues, NAT traversal debugging, and telemetry inspection | -| [`Motivation & Philosophy`](docs/motivation.md) | Design philosophy, anti-censorship regulatory context, and the problem with traditional VPNs | +Explore the full documentation curriculum organized by role and interest: + +| Guide | Target Audience | Primary Focus | +| :--- | :--- | :--- | +| [`01. Getting Started`](docs/01-getting-started.md) | **Beginner / Operator** | Platform requirements, installation, first tunnel setup, and CA mesh bootstrap | +| [`02. Configuration Reference`](docs/02-configuration.md) | **Operator / Admin** | Comprehensive parameter reference, deployment recipes, and `yipctl` command guide | +| [`03. Architecture & Internals`](docs/03-architecture.md) | **Developer / Architect** | Control vs data plane, zero-copy AF_XDP queues, Cauchy RS-FEC, and FROST threshold CA | +| [`04. Security & Threat Model`](docs/04-security-and-threat-model.md) | **Security Reviewer** | Post-quantum hybrid handshake, adversary model, anti-DPI verification, and crypto ledger | +| [`05. Benchmarks & Performance`](docs/05-benchmarking-and-performance.md) | **Researcher / Tester** | Real-world WAN test data, multi-core scaling charts, loss-recovery mathematical proofs | +| [`06. Troubleshooting Runbook`](docs/06-troubleshooting.md) | **Operator / SRE** | Diagnostic flowcharts, MTU discovery issues, NAT traversal debugging, and telemetry inspection | +| [`Motivation & Philosophy`](docs/motivation.md) | **Everyone** | Design philosophy, anti-censorship regulatory context, and the problem with traditional VPNs | +| [`Turnkey Threshold CA Tutorial`](examples/threshold-ca/README.md) | **Operator** | Step-by-step setup of a 2-of-3 threshold CA quorum with systemd units | ---