diff --git a/.gitignore b/.gitignore index e89d4af..eae617d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,13 @@ /target/ .DS_Store + +# Key shares and secrets - NEVER commit these +*.json.enc +*.key +*.pem +*.p12 +.env +.env.* + +# SAW data directories +.saw/ diff --git a/Cargo.lock b/Cargo.lock index d0ef880..704fcb4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,18 +2,73 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "arrayvec" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "az" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be5eb007b7cacc6c660343e96f650fedf4b5a77512399eb952ca6642cf8d13f7" + [[package]] name = "base16ct" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.22.1" @@ -26,6 +81,12 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + [[package]] name = "bitvec" version = "1.0.1" @@ -38,6 +99,15 @@ dependencies = [ "wyz", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -62,6 +132,26 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "byteorder" version = "1.5.0" @@ -90,6 +180,98 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cggmp21" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3309aeafc6311b8823faa1ee73e2e776b7fd5a0b3531ee72ec7f1acf40136ffc" +dependencies = [ + "cggmp21-keygen", + "digest", + "futures", + "generic-ec", + "generic-ec-zkp", + "hd-wallet", + "hex", + "key-share", + "paillier-zk", + "rand_core", + "rand_hash", + "round-based 0.4.1", + "serde", + "serde_with 2.3.3", + "sha2", + "thiserror 1.0.69", + "udigest", +] + +[[package]] +name = "cggmp21-keygen" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaa8c850290c494f951abe0350e56c31e4f5664863490197490ff48cb825447d" +dependencies = [ + "digest", + "displaydoc", + "generic-ec", + "generic-ec-zkp", + "hd-wallet", + "hex", + "key-share", + "rand_core", + "round-based 0.4.1", + "serde", + "serde_with 2.3.3", + "sha2", + "thiserror 1.0.69", + "udigest", +] + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "num-traits", + "serde", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -116,6 +298,22 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -150,6 +348,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core", "typenum", ] @@ -177,9 +376,85 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.114", ] +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + [[package]] name = "der" version = "0.7.10" @@ -190,6 +465,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" +dependencies = [ + "powerfmt", +] + [[package]] name = "digest" version = "0.10.7" @@ -202,6 +486,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "ecdsa" version = "0.16.9" @@ -241,6 +536,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "educe" +version = "0.4.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f0042ff8246a363dbe77d2ceedb073339e85a804b9a47636c6e016a9a32c05f" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "elliptic-curve" version = "0.13.8" @@ -260,6 +567,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "enum-ordinalize" +version = "3.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf1fa3f06bbff1ea5b1a9c7b14aa992a39657db60a2759457328d7e058f49ee" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -273,7 +593,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -303,6 +623,25 @@ dependencies = [ "uint", ] +[[package]] +name = "fast-paillier" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1108d991b54d8e3aa3eb155c07863306cbceafb713ab1ebcef085e19f3cb84c" +dependencies = [ + "bytemuck", + "rand_core", + "rug", + "serde", + "thiserror 1.0.69", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "ff" version = "0.13.1" @@ -338,111 +677,385 @@ dependencies = [ ] [[package]] -name = "funty" -version = "2.0.0" +name = "fnv" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] -name = "generic-array" -version = "0.14.7" +name = "foldhash" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", - "zeroize", -] +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] -name = "getrandom" -version = "0.2.17" +name = "foreign-types" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" dependencies = [ - "cfg-if", - "libc", - "wasi", + "foreign-types-shared", ] [[package]] -name = "group" -version = "0.13.0" +name = "foreign-types-shared" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" -dependencies = [ - "ff", - "rand_core", - "subtle", -] +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] -name = "hashbrown" -version = "0.16.1" +name = "funty" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] -name = "hex" -version = "0.4.3" +name = "futures" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] [[package]] -name = "hmac" -version = "0.12.1" +name = "futures-channel" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ - "digest", + "futures-core", + "futures-sink", ] [[package]] -name = "impl-codec" -version = "0.6.0" +name = "futures-core" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba6a270039626615617f3f36d15fc827041df3b78c439da2cadfa47455a77f2f" -dependencies = [ - "parity-scale-codec", -] +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] -name = "impl-rlp" -version = "0.3.0" +name = "futures-executor" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28220f89297a075ddc7245cd538076ee98b01f2a9c23a53a4f1105d5a322808" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" dependencies = [ - "rlp", + "futures-core", + "futures-task", + "futures-util", ] [[package]] -name = "impl-serde" -version = "0.4.0" +name = "futures-io" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc88fc67028ae3db0c853baa36269d398d5f45b6982f95549ff5def78c935cd" -dependencies = [ - "serde", -] +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" [[package]] -name = "impl-trait-for-tuples" -version = "0.2.3" +name = "futures-macro" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] -name = "indexmap" -version = "2.13.0" +name = "futures-sink" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "serde", + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "generic-ec" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de1099ac0b4d87261d67ff5d4ed400af617a1da40b58908d759b9cf5fd8ed27" +dependencies = [ + "curve25519-dalek", + "digest", + "generic-ec-core", + "generic-ec-curves", + "hex", + "phantom-type 0.4.2", + "rand_core", + "rand_hash", + "serde", + "serde_with 2.3.3", + "subtle", + "udigest", + "zeroize", +] + +[[package]] +name = "generic-ec-core" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcba5fdf70cc3ce5805c487f8523b4ceeb32e8ec5237c71ffd93c1ca47a97fee" +dependencies = [ + "generic-array", + "rand_core", + "serde", + "subtle", + "zeroize", +] + +[[package]] +name = "generic-ec-curves" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7c6d23001a5eb60eec2b785a63d2ca965fdfbaf3314b3b46df047398369e28" +dependencies = [ + "elliptic-curve", + "generic-ec-core", + "k256", + "rand_core", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "generic-ec-zkp" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd3945c585fdddba3f86bda4e4cfba22d5e255001b3e145c9db305ad096c6d88" +dependencies = [ + "digest", + "generic-array", + "generic-ec", + "rand_core", + "serde", + "subtle", + "udigest", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "gmp-mpfr-sys" +version = "1.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60f8970a75c006bb2f8ae79c6768a116dd215fa8346a87aed99bf9d82ca43394" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hd-wallet" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6522551bb35937363845f39a6d4c49e60bdb35a8f7154ebdd078cab50be97992" +dependencies = [ + "generic-array", + "generic-ec", + "hmac", + "sha2", + "subtle", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +dependencies = [ + "serde", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "impl-codec" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba6a270039626615617f3f36d15fc827041df3b78c439da2cadfa47455a77f2f" +dependencies = [ + "parity-scale-codec", +] + +[[package]] +name = "impl-rlp" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28220f89297a075ddc7245cd538076ee98b01f2a9c23a53a4f1105d5a322808" +dependencies = [ + "rlp", +] + +[[package]] +name = "impl-serde" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc88fc67028ae3db0c853baa36269d398d5f45b6982f95549ff5def78c935cd" +dependencies = [ + "serde", +] + +[[package]] +name = "impl-trait-for-tuples" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", ] [[package]] @@ -474,24 +1087,227 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "key-share" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "206b4474f861dedc6fc38e06f7c52c52f1e01180d5284aa62b58844a044fad7d" +dependencies = [ + "displaydoc", + "generic-ec", + "generic-ec-zkp", + "hd-wallet", + "hex", + "serde", + "serde_with 2.3.3", + "thiserror 1.0.69", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "memchr" version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d5d26952a508f321b4d3d2e80e78fc2603eaefcdf0c30783867f19586518bdc" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "paillier-zk" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9963224009a2fd339cffd8f6c5c35fc9a91732b89acd4b0ed34c30afe70a193" +dependencies = [ + "digest", + "fast-paillier", + "generic-ec", + "rand_core", + "rand_hash", + "rug", + "serde", + "serde_with 3.16.1", + "thiserror 1.0.69", + "udigest", +] + [[package]] name = "parity-scale-codec" version = "3.7.5" @@ -517,19 +1333,106 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.114", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + +[[package]] +name = "phantom-type" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f710afd11c9711b04f97ab61bb9747d5a04562fdf0f9f44abc3de92490084982" +dependencies = [ + "educe", ] [[package]] -name = "pkcs8" -version = "0.10.2" +name = "phantom-type" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e68f5dc797c2a743e024e1c53215474598faf0408826a90249569ad7f47adeaa" +dependencies = [ + "educe", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "poly1305" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "der", - "spki", + "cpufeatures", + "opaque-debug", + "universal-hash", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -539,6 +1442,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.114", +] + [[package]] name = "primitive-types" version = "0.12.2" @@ -579,6 +1492,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "radium" version = "0.7.0" @@ -612,9 +1531,46 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", +] + +[[package]] +name = "rand_hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16bc1dd921383c6564eb0b8252f5b3f6622b84d40c6e35f5e6790e1fd7abb7a9" +dependencies = [ + "digest", + "rand_core", + "udigest", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", ] +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + [[package]] name = "rfc6979" version = "0.4.0" @@ -635,6 +1591,56 @@ dependencies = [ "rustc-hex", ] +[[package]] +name = "round-based" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81564866f5617d497753563151d8beb80d61e925e904d94b7e8a202b721e931e" +dependencies = [ + "displaydoc", + "futures-util", + "phantom-type 0.3.1", + "thiserror 1.0.69", + "tracing", +] + +[[package]] +name = "round-based" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da76edf50de0a9d6911fc79261bb04cc9f3f3a375e0201799f5edf58499af341" +dependencies = [ + "futures-util", + "phantom-type 0.3.1", + "round-based-derive", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "round-based-derive" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4afa4d5b318bcafae8a7ebc57c1cb7d4b2db7358293e34d71bfd605fd327cc13" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rug" +version = "1.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de190ec858987c79cad4da30e19e546139b3339331282832af004d0ea7829639" +dependencies = [ + "az", + "gmp-mpfr-sys", + "libc", + "libm", + "serde", +] + [[package]] name = "rustc-hex" version = "2.1.0" @@ -650,6 +1656,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -667,35 +1686,119 @@ name = "saw" version = "0.1.0" dependencies = [ "bs58", + "cggmp21", "ed25519-dalek", "hex", "k256", "rand_core", + "saw-mpc", "serde", + "serde_json", "serde_yaml", "sha3", + "tokio", + "tokio-tungstenite", +] + +[[package]] +name = "saw-cosigner" +version = "0.1.0" +dependencies = [ + "saw-mpc", + "serde", + "serde_json", + "tokio", + "tokio-tungstenite", + "tracing", + "tracing-subscriber", ] [[package]] name = "saw-daemon" version = "0.1.0" dependencies = [ - "base64", + "base64 0.22.1", "bs58", + "cggmp21", "ed25519-dalek", "ethereum-types", + "futures", + "generic-ec", "hex", "k256", + "rand_core", "rlp", "saw", + "saw-mpc", "secp256k1", "serde", "serde_json", "serde_yaml", + "sha2", "sha3", "signal-hook", + "tokio", + "tokio-tungstenite", +] + +[[package]] +name = "saw-mpc" +version = "0.1.0" +dependencies = [ + "argon2", + "cggmp21", + "chacha20poly1305", + "futures", + "generic-ec", + "hex", + "k256", + "rand_core", + "round-based 0.3.2", + "serde", + "serde_json", + "sha2", + "sha3", + "thiserror 1.0.69", + "tokio", + "tokio-tungstenite", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "saw-policy" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "cggmp21", + "futures", + "hex", + "saw-mpc", + "serde", + "serde_json", + "serde_yaml", + "sha2", + "tokio", + "tokio-tungstenite", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "sec1" version = "0.7.3" @@ -729,6 +1832,29 @@ dependencies = [ "cc", ] +[[package]] +name = "security-framework" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321c8673b092a9a42605034a9879d73cb79101ed5fd117bc9a597b89b4e9e61a" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.27" @@ -762,7 +1888,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -778,6 +1904,55 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_with" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07ff71d2c147a7b57362cead5e22f772cd52f6ab31cfcd9edcd7f6aeb2a0afbe" +dependencies = [ + "base64 0.13.1", + "chrono", + "hex", + "serde", + "serde_json", + "serde_with_macros 2.3.3", + "time", +] + +[[package]] +name = "serde_with" +version = "3.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" +dependencies = [ + "serde_core", + "serde_with_macros 3.16.1", +] + +[[package]] +name = "serde_with_macros" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "881b6f881b17d13214e5d494c939ebab463d01264ce1811e9d4ac3a882e7695f" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "serde_with_macros" +version = "3.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" @@ -791,6 +1966,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -812,6 +1998,15 @@ dependencies = [ "keccak", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -829,63 +2024,183 @@ dependencies = [ ] [[package]] -name = "signal-hook-registry" -version = "1.4.8" +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +dependencies = [ + "fastrand", + "getrandom 0.4.1", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "errno", - "libc", + "thiserror-impl 1.0.69", ] [[package]] -name = "signature" -version = "2.2.0" +name = "thiserror" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "digest", - "rand_core", + "thiserror-impl 2.0.18", ] [[package]] -name = "spki" -version = "0.7.3" +name = "thiserror-impl" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ - "base64ct", - "der", + "proc-macro2", + "quote", + "syn 2.0.114", ] [[package]] -name = "static_assertions" -version = "1.1.0" +name = "thiserror-impl" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] [[package]] -name = "subtle" -version = "2.6.1" +name = "thread_local" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] [[package]] -name = "syn" -version = "2.0.114" +name = "time" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", ] [[package]] -name = "tap" -version = "1.0.1" +name = "time-core" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "tiny-keccak" @@ -911,6 +2226,58 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite", +] + [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -941,12 +2308,113 @@ dependencies = [ "winnow", ] +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + [[package]] name = "typenum" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "udigest" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ff079a60bd5dc98b364ce7b5a633a8937bf558f5d19c9a390f5ae1973cf07e" +dependencies = [ + "digest", + "udigest-derive", +] + +[[package]] +name = "udigest-derive" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25fd5248861b973cd5d1da5604b0ce22a35fa77f015d9f7ed9ab57078205bb86" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "uint" version = "0.9.5" @@ -971,12 +2439,40 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -989,12 +2485,73 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1004,6 +2561,71 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.7.14" @@ -1013,6 +2635,94 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.114", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.114", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "wyz" version = "0.5.1" @@ -1039,7 +2749,7 @@ checksum = "8a616990af1a287837c4fe6596ad77ef57948f787e46ce28e166facc0cc1cb75" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -1047,6 +2757,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] [[package]] name = "zmij" diff --git a/Cargo.toml b/Cargo.toml index 2e7aa63..1a04c48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,26 @@ [workspace] -members = ["crates/saw-cli", "crates/saw-daemon"] +members = [ + "crates/saw-cli", + "crates/saw-daemon", + "crates/saw-mpc", + "crates/saw-policy", + "crates/saw-cosigner", +] resolver = "2" + +# Release-optimized test profile for MPC tests (Paillier prime generation +# is ~100x slower in debug mode). Use: cargo test --release -p saw-mpc +[profile.release] +opt-level = 3 + +# Optimize heavy crypto dependencies even in debug builds +[profile.dev.package.rug] +opt-level = 3 +[profile.dev.package.gmp-mpfr-sys] +opt-level = 3 +[profile.dev.package.paillier-zk] +opt-level = 3 +[profile.dev.package.cggmp21] +opt-level = 3 +[profile.dev.package.generic-ec] +opt-level = 3 diff --git a/Dockerfile.policy b/Dockerfile.policy new file mode 100644 index 0000000..9988b94 --- /dev/null +++ b/Dockerfile.policy @@ -0,0 +1,37 @@ +# Multi-stage build for saw-policy +# Stage 1: Build +FROM rust:1.83 AS builder + +RUN apt-get update && apt-get install -y --no-install-recommends m4 pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* + +WORKDIR /build +COPY Cargo.toml Cargo.lock ./ +COPY crates/ crates/ + +# Build in release mode for fast Paillier/MPC ops +RUN cargo build --release -p saw-policy + +# Stage 2: Minimal runtime +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates libssl3 && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /build/target/release/saw-policy /usr/local/bin/saw-policy + +# Create data directory +RUN mkdir -p /data + +WORKDIR /data + +# Default env vars (override at deploy time) +ENV RUST_LOG=saw_policy=info +ENV SAW_ROOT=/data +ENV POLICY_PATH=/data/policy.yaml +ENV KEY_SHARE_PATH=/data/key_share.json + +RUN useradd -r -s /bin/false saw +USER saw + +EXPOSE 9443 + +CMD ["saw-policy"] diff --git a/crates/saw-cli/Cargo.toml b/crates/saw-cli/Cargo.toml index bc577e6..6760cab 100644 --- a/crates/saw-cli/Cargo.toml +++ b/crates/saw-cli/Cargo.toml @@ -5,13 +5,18 @@ edition = "2021" [dependencies] bs58 = "0.5" +cggmp21 = { version = "0.6", features = ["hd-wallet", "curve-secp256k1"] } ed25519-dalek = { version = "2", features = ["rand_core"] } hex = "0.4" k256 = { version = "0.13", features = ["ecdsa"] } rand_core = { version = "0.6", features = ["getrandom"] } +saw-mpc = { path = "../saw-mpc" } serde = { version = "1", features = ["derive"] } +serde_json = "1" serde_yaml = "0.9" sha3 = "0.10" +tokio = { version = "1", features = ["full"] } +tokio-tungstenite = { version = "0.24", features = ["native-tls"] } [[bin]] name = "saw" diff --git a/crates/saw-cli/src/keygen_local.rs b/crates/saw-cli/src/keygen_local.rs new file mode 100644 index 0000000..18c1604 --- /dev/null +++ b/crates/saw-cli/src/keygen_local.rs @@ -0,0 +1,179 @@ +//! Local threshold keygen: generates all party key shares in one process. +//! +//! Usage: +//! SAW_PASSPHRASE=secret saw keygen-local --wallet my-wallet [--root ~/.saw] +//! +//! Outputs: +//! keys/threshold/{wallet}_party0.json (saw-daemon) +//! keys/threshold/{wallet}_party1.json (saw-policy) +//! keys/threshold/{wallet}_party2.json (recovery / cosigner) +//! keys/threshold/{wallet}.meta.json (shared metadata) + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; + +use saw_mpc::keygen; +use saw_mpc::transport; +use saw_mpc::types::{Chain, KeyShareData, ThresholdConfig}; + +const NUM_PARTIES: u16 = 3; +const THRESHOLD: u16 = 2; + +pub async fn run(wallet: &str, root: &Path) -> Result { + let passphrase = std::env::var("SAW_PASSPHRASE").ok(); + + // Random nonce to avoid execution ID collisions when reusing wallet names + let nonce: u64 = { + use rand_core::{OsRng, RngCore}; + OsRng.next_u64() + }; + + eprintln!("=== SAW Local Keygen (2-of-3) ==="); + eprintln!("Wallet: {wallet}"); + eprintln!("Root: {}\n", root.display()); + + // Phase 1: Generate Paillier primes (CPU-intensive, sequential) + eprintln!("[1/4] Generating Paillier primes for {NUM_PARTIES} parties..."); + eprintln!(" (This takes ~1-3 minutes with optimized crypto)"); + let mut primes = Vec::with_capacity(NUM_PARTIES as usize); + for i in 0..NUM_PARTIES { + eprint!(" Party {i}: generating... "); + let p = keygen::pregenerate_primes(); + eprintln!("✓"); + primes.push(p); + } + eprintln!(); + + // Phase 2: Aux info generation (MPC, in-memory) + eprintln!("[2/4] Running aux info generation (MPC)..."); + let aux_deliveries = transport::in_memory_delivery(NUM_PARTIES); + + let mut aux_handles = Vec::new(); + for (i, (delivery, prime)) in aux_deliveries.into_iter().zip(primes).enumerate() { + let eid_bytes: Vec = format!("local-{wallet}-{nonce}-aux").into_bytes(); + aux_handles.push(tokio::spawn(async move { + let eid = cggmp21::ExecutionId::new(&eid_bytes); + keygen::generate_aux_info(eid, i as u16, NUM_PARTIES, prime, delivery).await + })); + } + + let mut aux_infos = Vec::new(); + for (i, handle) in aux_handles.into_iter().enumerate() { + let aux = handle + .await + .map_err(|e| format!("party {i} aux task panic: {e}"))? + .map_err(|e| format!("party {i} aux info gen failed: {e}"))?; + aux_infos.push(aux); + } + eprintln!(" ✓ Aux info complete\n"); + + // Phase 3: Key generation (MPC, in-memory) + eprintln!("[3/4] Running key generation (MPC)..."); + let keygen_deliveries = transport::in_memory_delivery(NUM_PARTIES); + + let mut keygen_handles = Vec::new(); + for (i, delivery) in keygen_deliveries.into_iter().enumerate() { + let eid_bytes: Vec = format!("local-{wallet}-{nonce}-dkg").into_bytes(); + keygen_handles.push(tokio::spawn(async move { + let eid = cggmp21::ExecutionId::new(&eid_bytes); + keygen::generate_key(eid, i as u16, NUM_PARTIES, THRESHOLD, delivery).await + })); + } + + let mut incomplete_shares = Vec::new(); + for (i, handle) in keygen_handles.into_iter().enumerate() { + let share = handle + .await + .map_err(|e| format!("party {i} keygen task panic: {e}"))? + .map_err(|e| format!("party {i} keygen failed: {e}"))?; + incomplete_shares.push(share); + } + eprintln!(" ✓ Key generation complete\n"); + + // Phase 4: Complete key shares and save + eprintln!("[4/4] Completing and saving key shares..."); + let share_dir = root.join("keys").join("threshold"); + fs::create_dir_all(&share_dir).map_err(|e| format!("create dir: {e}"))?; + + let mut address = String::new(); + let mut public_key = String::new(); + let party_names = ["daemon", "policy", "cosigner"]; + + for (i, (incomplete, aux)) in incomplete_shares + .into_iter() + .zip(aux_infos) + .enumerate() + { + let output = keygen::complete_key_share(incomplete, aux) + .map_err(|e| format!("party {i} complete failed: {e}"))?; + + if address.is_empty() { + address = output.address.clone(); + public_key = output.public_key.clone(); + } else { + if address != output.address { + return Err(format!( + "address mismatch between parties: expected {address}, got {}", + output.address + )); + } + } + + // Save key share (encrypted if passphrase set) + let share_path = share_dir.join(format!("{wallet}_party{i}.json")); + let share_data = match &passphrase { + Some(pp) if !pp.is_empty() => { + keygen::serialize_key_share_encrypted(&output.key_share, pp.as_bytes()) + .map_err(|e| format!("encrypt party {i}: {e}"))? + } + _ => { + keygen::serialize_key_share(&output.key_share) + .map_err(|e| format!("serialize party {i}: {e}"))? + } + }; + + fs::write(&share_path, &share_data).map_err(|e| format!("write party {i}: {e}"))?; + fs::set_permissions(&share_path, fs::Permissions::from_mode(0o600)) + .map_err(|e| format!("chmod party {i}: {e}"))?; + + eprintln!( + " Party {i} ({:>9}): {}", + party_names[i], + share_path.display() + ); + } + + // Save metadata + let meta = KeyShareData { + config: ThresholdConfig::new_2of3(0, wallet, Chain::Evm), + address: address.clone(), + public_key: public_key.clone(), + created_at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + }; + let meta_path = share_dir.join(format!("{wallet}.meta.json")); + let meta_json = + serde_json::to_string_pretty(&meta).map_err(|e| format!("serialize meta: {e}"))?; + fs::write(&meta_path, meta_json).map_err(|e| format!("write meta: {e}"))?; + eprintln!(" Metadata: {}", meta_path.display()); + + let encrypted_str = if passphrase.as_ref().map_or(false, |p| !p.is_empty()) { + "🔒 encrypted" + } else { + "⚠️ UNENCRYPTED (set SAW_PASSPHRASE to encrypt)" + }; + + eprintln!("\n=== Keygen Complete! ==="); + eprintln!("Address: {address}"); + eprintln!("Public key: {public_key}"); + eprintln!("Shares: {encrypted_str}"); + eprintln!("\nDistribute the key shares:"); + eprintln!(" _party0.json → saw-daemon (agent machine)"); + eprintln!(" _party1.json → saw-policy (policy server / Railway)"); + eprintln!(" _party2.json → cosigner (recovery / cold storage)"); + + Ok(format!("{address}\n")) +} diff --git a/crates/saw-cli/src/keygen_threshold.rs b/crates/saw-cli/src/keygen_threshold.rs new file mode 100644 index 0000000..315ca4f --- /dev/null +++ b/crates/saw-cli/src/keygen_threshold.rs @@ -0,0 +1,161 @@ +//! Threshold keygen ceremony CLI. +//! +//! Usage: +//! # Start relay (run first, on any machine): +//! saw keygen-threshold --relay --listen 0.0.0.0:9444 +//! +//! # Each party connects to relay: +//! saw keygen-threshold --party 0 --wallet my-wallet --connect ws://relay:9444 +//! saw keygen-threshold --party 1 --wallet my-wallet --connect ws://relay:9444 +//! saw keygen-threshold --party 2 --wallet my-wallet --connect ws://relay:9444 +//! +//! Party roles: +//! 0 = saw-daemon (agent machine) +//! 1 = saw-policy (policy machine) +//! 2 = saw-cosigner (human device, recovery key) + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; + +use saw_mpc::keygen; +use saw_mpc::relay; +use saw_mpc::types::{KeyShareData, ThresholdConfig}; +use saw_mpc::types::Chain; + +const NUM_PARTIES: u16 = 3; +const THRESHOLD: u16 = 2; + +/// Run the relay server (no key material, just routes messages). +pub async fn run_relay(listen_addr: &str) -> Result { + eprintln!("=== SAW Keygen Relay Server ==="); + eprintln!("Listening on: {listen_addr}"); + eprintln!("Waiting for {NUM_PARTIES} parties to connect...\n"); + + relay::run_relay(listen_addr, NUM_PARTIES) + .await + .map_err(|e| format!("relay error: {e}"))?; + + Ok("Relay: all parties disconnected, ceremony complete.".into()) +} + +/// Run a keygen party (generates and saves a key share). +pub async fn run_party( + party_id: u16, + wallet: &str, + connect_url: &str, + root: &Path, +) -> Result { + if party_id >= NUM_PARTIES { + return Err(format!("party must be 0, 1, or 2 (got {party_id})")); + } + + let role = match party_id { + 0 => "saw-daemon", + 1 => "saw-policy", + 2 => "saw-cosigner", + _ => unreachable!(), + }; + + eprintln!("=== SAW Threshold Keygen (Party {party_id} / {role}) ==="); + eprintln!("Wallet: {wallet}"); + eprintln!("Connecting to relay: {connect_url}\n"); + + // Phase 1: Generate Paillier primes (local, CPU-intensive) + eprintln!("[1/4] Generating Paillier primes (this takes ~1 minute)..."); + let primes = keygen::pregenerate_primes(); + eprintln!(" ✓ Primes ready\n"); + + // Phase 2: Connect to relay and run aux info generation + eprintln!("[2/4] Connecting to relay for aux info generation..."); + let aux_delivery = relay::connect_to_relay(connect_url, party_id, "aux") + .await + .map_err(|e| format!("connect for aux: {e}"))?; + + eprintln!(" Connected! Running aux info generation (MPC)..."); + + let aux_eid_bytes = format!("keygen-{wallet}-aux"); + let aux_eid = cggmp21::ExecutionId::new(aux_eid_bytes.as_bytes()); + let aux_info = keygen::generate_aux_info(aux_eid, party_id, NUM_PARTIES, primes, aux_delivery) + .await + .map_err(|e| format!("aux info gen failed: {e}"))?; + + eprintln!(" ✓ Aux info complete\n"); + + // Phase 3: Key generation + eprintln!("[3/4] Running key generation (MPC)..."); + let keygen_delivery = relay::connect_to_relay(connect_url, party_id, "keygen") + .await + .map_err(|e| format!("connect for keygen: {e}"))?; + + let keygen_eid_bytes = format!("keygen-{wallet}-dkg"); + let keygen_eid = cggmp21::ExecutionId::new(keygen_eid_bytes.as_bytes()); + let incomplete = keygen::generate_key(keygen_eid, party_id, NUM_PARTIES, THRESHOLD, keygen_delivery) + .await + .map_err(|e| format!("keygen failed: {e}"))?; + + eprintln!(" ✓ Key generation complete\n"); + + // Phase 4: Complete key share and save + eprintln!("[4/4] Completing key share..."); + let output = keygen::complete_key_share(incomplete, aux_info) + .map_err(|e| format!("complete key share: {e}"))?; + + let address = &output.address; + let public_key = &output.public_key; + eprintln!(" Address: {address}"); + eprintln!(" Public key: {public_key}"); + + // Save key share (encrypted if passphrase provided via SAW_PASSPHRASE env var) + let share_dir = root.join("keys").join("threshold"); + fs::create_dir_all(&share_dir).map_err(|e| format!("create dir: {e}"))?; + + let share_path = share_dir.join(format!("{wallet}.json")); + let passphrase = std::env::var("SAW_PASSPHRASE").ok(); + let share_data = match &passphrase { + Some(pp) if !pp.is_empty() => { + eprintln!(" 🔒 Encrypting key share (Argon2id + ChaCha20-Poly1305)..."); + keygen::serialize_key_share_encrypted(&output.key_share, pp.as_bytes()) + .map_err(|e| format!("encrypt: {e}"))? + } + _ => { + eprintln!(" ⚠️ No SAW_PASSPHRASE set — saving key share UNENCRYPTED"); + keygen::serialize_key_share(&output.key_share) + .map_err(|e| format!("serialize: {e}"))? + } + }; + + fs::write(&share_path, &share_data).map_err(|e| format!("write: {e}"))?; + fs::set_permissions(&share_path, fs::Permissions::from_mode(0o600)) + .map_err(|e| format!("chmod: {e}"))?; + + // Save metadata + let meta = KeyShareData { + config: ThresholdConfig::new_2of3(party_id, wallet, Chain::Evm), + address: address.clone(), + public_key: public_key.clone(), + created_at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + }; + let meta_path = share_dir.join(format!("{wallet}.meta.json")); + let meta_json = serde_json::to_string_pretty(&meta) + .map_err(|e| format!("serialize meta: {e}"))?; + fs::write(&meta_path, meta_json).map_err(|e| format!("write meta: {e}"))?; + + eprintln!(" ✓ Key share saved to {}", share_path.display()); + eprintln!(" ✓ Metadata saved to {}\n", meta_path.display()); + + eprintln!("=== Keygen Complete! ==="); + eprintln!("Wallet address: {address}"); + eprintln!("Key share for party {party_id} ({role}) saved."); + + if party_id == 2 { + eprintln!("\n⚠️ IMPORTANT: This is the recovery key share."); + eprintln!(" Store it safely offline. You only need it if"); + eprintln!(" party 0 or party 1 is compromised/lost."); + } + + Ok(format!("{address}\n")) +} diff --git a/crates/saw-cli/src/lib.rs b/crates/saw-cli/src/lib.rs index 9ea9969..a21a80e 100644 --- a/crates/saw-cli/src/lib.rs +++ b/crates/saw-cli/src/lib.rs @@ -1,3 +1,6 @@ +mod keygen_local; +mod keygen_threshold; + use std::collections::BTreeMap; use std::fmt; use std::fs::{self, OpenOptions}; @@ -415,6 +418,7 @@ pub mod cli { use std::fmt; use std::path::PathBuf; + use crate::keygen_threshold; use crate::{ add_wallet_stub, gen_key, get_address, install_layout, list_wallets, validate_policy, AddressError, Chain, GenKeyError, InstallError, PolicyError, @@ -426,11 +430,13 @@ saw - Secure Agent Wallet CLI Usage: saw [options] Commands: - install Create the SAW directory layout - gen-key Generate a new wallet key pair - address Show the address for an existing wallet - list List all wallets and their addresses - policy Policy management subcommands + install Create the SAW directory layout + gen-key Generate a new wallet key pair (single-key) + keygen-local Generate 2-of-3 threshold key shares locally + keygen-threshold Run threshold keygen ceremony (distributed) + address Show the address for an existing wallet + list List all wallets and their addresses + policy Policy management subcommands Policy subcommands: policy validate Validate policy.yaml @@ -446,6 +452,12 @@ Examples: saw address --chain evm --wallet main saw list saw policy validate + +Threshold keygen: + saw keygen-threshold --relay --listen 0.0.0.0:9444 + saw keygen-threshold --party 0 --wallet main --connect ws://relay:9444 + saw keygen-threshold --party 1 --wallet main --connect ws://relay:9444 + saw keygen-threshold --party 2 --wallet main --connect ws://relay:9444 "; #[derive(Debug)] @@ -509,6 +521,8 @@ Examples: match cmd.as_str() { "--help" | "-h" => Ok(HELP.to_string()), "gen-key" => gen_key_cmd(iter), + "keygen-local" => keygen_local_cmd(iter), + "keygen-threshold" => keygen_threshold_cmd(iter), "address" => address_cmd(iter), "list" => list_cmd(iter), "policy" => policy_cmd(iter), @@ -517,6 +531,157 @@ Examples: } } + fn keygen_threshold_cmd(mut iter: I) -> Result + where + I: Iterator, + S: AsRef, + { + let mut relay_mode = false; + let mut listen: Option = None; + let mut party_id: Option = None; + let mut wallet: Option = None; + let mut connect: Option = None; + let mut root = crate::default_root(); + + while let Some(arg) = iter.next() { + match arg.as_ref() { + "--help" | "-h" => { + return Ok("\ +Usage: + saw keygen-threshold --relay --listen + saw keygen-threshold --party <0|1|2> --wallet --connect + +Options: + --relay Run as relay server (routes messages, no key material) + --listen Relay listen address (default: 0.0.0.0:9444) + --party Party index (0=daemon, 1=policy, 2=cosigner) + --wallet Wallet name + --connect Relay WebSocket URL + --root SAW data directory (default: ~/.saw) +".to_string()); + } + "--relay" => relay_mode = true, + "--listen" => { + listen = Some( + iter.next() + .ok_or(CliError::MissingArg("--listen"))? + .as_ref() + .to_string(), + ); + } + "--party" => { + let val = iter + .next() + .ok_or(CliError::MissingArg("--party"))? + .as_ref() + .to_string(); + party_id = Some( + val.parse() + .map_err(|_| CliError::InvalidArg(format!("--party: {val}")))?, + ); + } + "--wallet" => { + wallet = Some( + iter.next() + .ok_or(CliError::MissingArg("--wallet"))? + .as_ref() + .to_string(), + ); + } + "--connect" => { + connect = Some( + iter.next() + .ok_or(CliError::MissingArg("--connect"))? + .as_ref() + .to_string(), + ); + } + "--root" => { + root = PathBuf::from( + iter.next() + .ok_or(CliError::MissingArg("--root"))? + .as_ref() + .to_string(), + ); + } + other => return Err(CliError::InvalidArg(format!("flag: {other}"))), + } + } + + // Build a tokio runtime and run + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|e| CliError::InvalidArg(format!("tokio runtime: {e}")))?; + + if relay_mode { + let addr = listen.as_deref().unwrap_or("0.0.0.0:9444"); + rt.block_on(keygen_threshold::run_relay(addr)) + .map_err(|e| CliError::InvalidArg(e)) + } else { + let pid = party_id.ok_or(CliError::MissingArg("--party"))?; + let w = wallet.ok_or(CliError::MissingArg("--wallet"))?; + let url = connect.ok_or(CliError::MissingArg("--connect"))?; + rt.block_on(keygen_threshold::run_party(pid, &w, &url, &root)) + .map_err(|e| CliError::InvalidArg(e)) + } + } + + fn keygen_local_cmd(mut iter: I) -> Result + where + I: Iterator, + S: AsRef, + { + let mut wallet: Option = None; + let mut root = crate::default_root(); + + while let Some(arg) = iter.next() { + match arg.as_ref() { + "--help" | "-h" => { + return Ok("\ +Usage: saw keygen-local --wallet [--root ] + +Generate all 3 key shares for a 2-of-3 threshold wallet locally. +Set SAW_PASSPHRASE env var to encrypt the key shares. + +Output files (in /keys/threshold/): + _party0.json → saw-daemon (agent machine) + _party1.json → saw-policy (policy server) + _party2.json → cosigner (recovery / cold storage) + .meta.json → shared metadata (address, public key) +".to_string()); + } + "--wallet" => { + wallet = Some( + iter.next() + .ok_or(CliError::MissingArg("--wallet"))? + .as_ref() + .to_string(), + ); + } + "--root" => { + root = PathBuf::from( + iter.next() + .ok_or(CliError::MissingArg("--root"))? + .as_ref() + .to_string(), + ); + } + other => return Err(CliError::InvalidArg(format!("flag: {other}"))), + } + } + + let wallet = wallet.ok_or(CliError::MissingArg("--wallet"))?; + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|e| CliError::InvalidArg(format!("tokio runtime: {e}")))?; + + rt.block_on(crate::keygen_local::run(&wallet, &root)) + .map_err(|e| CliError::InvalidArg(e)) + } + fn gen_key_cmd(mut iter: I) -> Result where I: Iterator, diff --git a/crates/saw-cosigner/Cargo.toml b/crates/saw-cosigner/Cargo.toml new file mode 100644 index 0000000..6e3f789 --- /dev/null +++ b/crates/saw-cosigner/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "saw-cosigner" +version = "0.1.0" +edition = "2021" +description = "Human cosigner for SAW threshold signing — holds Share 3, recovery + override" + +[dependencies] +saw-mpc = { path = "../saw-mpc" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["full"] } +tokio-tungstenite = { version = "0.24", features = ["native-tls"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[[bin]] +name = "saw-cosigner" +path = "src/main.rs" diff --git a/crates/saw-cosigner/src/main.rs b/crates/saw-cosigner/src/main.rs new file mode 100644 index 0000000..33d88ee --- /dev/null +++ b/crates/saw-cosigner/src/main.rs @@ -0,0 +1,68 @@ +fn main() { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive("saw_cosigner=info".parse().unwrap()), + ) + .init(); + + let args: Vec = std::env::args().skip(1).collect(); + + match run(args) { + Ok(()) => {} + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(2); + } + } +} + +fn run(args: Vec) -> Result<(), String> { + let mut iter = args.iter(); + let mut root = std::path::PathBuf::from( + std::env::var("HOME").unwrap_or_else(|_| ".".into()), + ) + .join(".saw-cosigner"); + + while let Some(arg) = iter.next() { + match arg.as_str() { + "--help" | "-h" => { + eprintln!( + "saw-cosigner - Human cosigner for SAW threshold signing\n\n\ + Usage: saw-cosigner [options]\n\n\ + Options:\n \ + --join Join a keygen or signing ceremony\n \ + --root Data directory (default: ~/.saw-cosigner)\n \ + --help Show this help\n\n\ + The cosigner holds Share 3 — used for:\n \ + - Recovery when another party is compromised\n \ + - Approving transactions that exceed policy limits\n \ + - Key refresh ceremonies\n" + ); + return Ok(()); + } + "--root" => { + root = std::path::PathBuf::from( + iter.next().ok_or("missing --root value")?, + ); + } + "--join" => { + let _url = iter.next().ok_or("missing --join value")?; + // TODO: Join keygen ceremony as party 2 (human cosigner) + // TODO: Or join escalated signing session + eprintln!("join not yet implemented"); + return Ok(()); + } + other => return Err(format!("unknown argument: {other}")), + } + } + + tracing::info!(root = %root.display(), "saw-cosigner starting"); + + // TODO: Listen for incoming signing requests (escalated from saw-policy) + // TODO: Display transaction details for human review + // TODO: On approval, participate in MPC signing round + + eprintln!("saw-cosigner: scaffolding only — not yet functional"); + Ok(()) +} diff --git a/crates/saw-daemon/Cargo.toml b/crates/saw-daemon/Cargo.toml index 6a2b4cf..6bdd1a5 100644 --- a/crates/saw-daemon/Cargo.toml +++ b/crates/saw-daemon/Cargo.toml @@ -6,17 +6,25 @@ edition = "2021" [dependencies] base64 = "0.22" bs58 = "0.5" +cggmp21 = { version = "0.6", features = ["hd-wallet", "curve-secp256k1"] } ed25519-dalek = { version = "2", features = ["rand_core"] } ethereum-types = "0.14" +generic-ec = "0.4" +futures = "0.3" hex = "0.4" k256 = { version = "0.13", features = ["ecdsa"] } +rand_core = { version = "0.6", features = ["getrandom"] } rlp = "0.5" +saw-mpc = { path = "../saw-mpc" } secp256k1 = { version = "0.29", features = ["rand", "recovery"] } -signal-hook = "0.3" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" +sha2 = "0.10" sha3 = "0.10" +signal-hook = "0.3" +tokio = { version = "1", features = ["full"] } +tokio-tungstenite = { version = "0.24", features = ["native-tls"] } [dev-dependencies] saw = { path = "../saw-cli" } diff --git a/crates/saw-daemon/src/config.rs b/crates/saw-daemon/src/config.rs new file mode 100644 index 0000000..eee6621 --- /dev/null +++ b/crates/saw-daemon/src/config.rs @@ -0,0 +1,64 @@ +//! Daemon configuration: signing mode, threshold settings. + +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +/// Top-level daemon configuration (loaded from config.yaml). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DaemonConfig { + /// Signing mode per wallet. If absent, defaults to single-key. + #[serde(default)] + pub wallets: std::collections::HashMap, +} + +/// Signing configuration for a single wallet. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WalletSigningConfig { + /// Signing mode: "single-key" or "threshold" + #[serde(default = "default_mode")] + pub mode: SigningMode, + + /// For threshold mode: URL of the policy agent WebSocket server + pub policy_url: Option, + + /// For threshold mode: path to the key share file (relative to root) + pub key_share_path: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum SigningMode { + SingleKey, + Threshold, +} + +fn default_mode() -> SigningMode { + SigningMode::SingleKey +} + +impl Default for DaemonConfig { + fn default() -> Self { + Self { + wallets: std::collections::HashMap::new(), + } + } +} + +/// Load daemon config from file. Returns default if file doesn't exist. +pub fn load_config(root: &Path) -> DaemonConfig { + let config_path = root.join("config.yaml"); + match std::fs::read_to_string(&config_path) { + Ok(contents) => { + serde_yaml::from_str(&contents).unwrap_or_else(|e| { + eprintln!("warning: invalid config.yaml: {e}, using defaults"); + DaemonConfig::default() + }) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => DaemonConfig::default(), + Err(e) => { + eprintln!("warning: could not read config.yaml: {e}, using defaults"); + DaemonConfig::default() + } + } +} diff --git a/crates/saw-daemon/src/lib.rs b/crates/saw-daemon/src/lib.rs index 6490826..5c2f590 100644 --- a/crates/saw-daemon/src/lib.rs +++ b/crates/saw-daemon/src/lib.rs @@ -1,3 +1,6 @@ +pub mod config; +pub mod threshold; + use std::collections::HashMap; use std::fs::{self, OpenOptions}; use std::io::{self, Read, Write}; @@ -130,16 +133,108 @@ struct Eip2612PermitPayload { struct Server { root: PathBuf, rate_state: HashMap>, + config: config::DaemonConfig, + /// Tokio runtime for async threshold signing (lazy-initialized). + rt: Option, + /// Cached threshold clients per wallet. + threshold_clients: HashMap, } impl Server { fn new(root: &Path) -> Self { + let cfg = config::load_config(root); + + // Pre-load threshold clients for wallets in threshold mode + let mut threshold_clients = HashMap::new(); + let mut needs_runtime = false; + + for (wallet, wcfg) in &cfg.wallets { + if wcfg.mode == config::SigningMode::Threshold { + let policy_url = match &wcfg.policy_url { + Some(url) => url.clone(), + None => { + eprintln!("warning: wallet {wallet} in threshold mode but no policy_url"); + continue; + } + }; + + let default_share_path = format!("keys/threshold/{wallet}_party0.json"); + let share_path = wcfg + .key_share_path + .as_deref() + .unwrap_or(&default_share_path); + let full_path = root.join(share_path); + + let key_share = match std::fs::read(&full_path) { + Ok(data) => { + // Try encrypted first (SAW_PASSPHRASE env var), fall back to plaintext + let passphrase = std::env::var("SAW_PASSPHRASE").unwrap_or_default(); + if saw_mpc::encryption::is_encrypted(&data) && passphrase.is_empty() { + eprintln!("error: key share for {wallet} is encrypted but SAW_PASSPHRASE not set"); + continue; + } + match saw_mpc::keygen::deserialize_key_share_encrypted(&data, passphrase.as_bytes()) { + Ok(ks) => ks, + Err(e) => { + eprintln!("warning: failed to parse key share for {wallet}: {e}"); + continue; + } + } + } + Err(e) => { + eprintln!("warning: failed to read key share for {wallet}: {e}"); + continue; + } + }; + + threshold_clients.insert( + wallet.clone(), + threshold::ThresholdClient::new(key_share, policy_url.clone()), + ); + needs_runtime = true; + } + } + + let rt = if needs_runtime { + Some( + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("failed to create tokio runtime"), + ) + } else { + None + }; + + // Start background presignature refill for each threshold wallet. + // Must be spawned inside the tokio runtime context. + if let Some(rt) = &rt { + for (wallet, client) in &threshold_clients { + let pool = client.pool(); + let key_share = client.key_share_clone(); + let policy_url = client.policy_url().to_string(); + let wallet = wallet.clone(); + rt.spawn(async move { + eprintln!("presign refill started for wallet {wallet}"); + crate::threshold::presign_refill_loop(pool, key_share, policy_url, wallet).await; + }); + } + } + Self { root: root.to_path_buf(), rate_state: HashMap::new(), + config: cfg, + rt, + threshold_clients, } } + /// Check if a wallet uses threshold signing. + fn is_threshold(&self, wallet: &str) -> bool { + self.threshold_clients.contains_key(wallet) + } + fn handle_request(&mut self, raw: &str) -> Response { let parsed: Result = serde_json::from_str(raw.trim()); let request = match parsed { @@ -206,6 +301,31 @@ impl Server { } fn handle_get_address(&self, request: Request) -> Response { + // Threshold wallets: derive address from key share's public key + if let Some(client) = self.threshold_clients.get(&request.wallet) { + let pk = client.public_key(); + let encoded = pk.to_bytes(false); + let pub_bytes = encoded.as_ref(); + let mut hasher = Keccak256::new(); + hasher.update(&pub_bytes[1..]); + let hash = hasher.finalize(); + let address = format!("0x{}", hex::encode(&hash[12..])); + let public_key = format!("0x{}", hex::encode(pub_bytes)); + + return Response { + request_id: request.request_id, + status: "approved".to_string(), + result: Some(json!({ + "address": address, + "public_key": public_key, + "chain": "evm", + "mode": "threshold" + })), + error: None, + }; + } + + // Single-key wallets: existing path match get_address(&self.root, &request.wallet) { Ok(payload) => Response { request_id: request.request_id, @@ -235,6 +355,12 @@ impl Server { } }; + // ----- Threshold signing path ----- + if self.is_threshold(&request.wallet) { + return self.handle_sign_evm_tx_threshold(request.request_id, request.wallet, payload); + } + + // ----- Single-key signing path (original) ----- let policy = match self.load_wallet_policy(&request.wallet) { Ok(policy) => policy, Err(err) => { @@ -347,6 +473,112 @@ impl Server { } } + /// Threshold signing path for EVM transactions. + /// + /// Policy evaluation happens on the remote saw-policy agent. + /// We compute the sighash locally, send it for MPC signing, + /// then reconstruct the signed transaction. + fn handle_sign_evm_tx_threshold( + &self, + request_id: String, + wallet: String, + payload: EvmTxPayload, + ) -> Response { + // Compute sighash + let (sighash, to_bytes, data_bytes) = match compute_evm_tx_sighash(&payload) { + Ok(v) => v, + Err(err) => { + return Response { + request_id, + status: "denied".to_string(), + result: None, + error: Some(err), + } + } + }; + + let client = match self.threshold_clients.get(&wallet) { + Some(c) => c, + None => { + return Response { + request_id, + status: "denied".to_string(), + result: None, + error: Some("threshold client not initialized".into()), + } + } + }; + + let rt = match &self.rt { + Some(rt) => rt, + None => { + return Response { + request_id, + status: "denied".to_string(), + result: None, + error: Some("no async runtime for threshold signing".into()), + } + } + }; + + // Build tx details for policy evaluation + let tx_details = saw_mpc::protocol::TxDetails { + chain_id: Some(payload.chain_id), + to: Some(payload.to.clone()), + value: Some(payload.value.clone()), + data_len: data_bytes.len(), + is_contract_call: !data_bytes.is_empty(), + }; + + // Run threshold signing (async via tokio runtime) + let result = rt.block_on(client.sign( + &wallet, + saw_mpc::protocol::SignAction::EvmTx, + tx_details, + &sighash, + )); + + match result { + Ok(sign_result) => { + // Extract r, s from the cggmp21 signature. + // We need to recover v (parity) by trying both and checking + // which recovers to our public key. + let sig = &sign_result.signature; + let r_bytes = sig.r.to_be_bytes(); + let s_bytes = sig.s.to_be_bytes(); + + let r_val = U256::from_big_endian(r_bytes.as_ref()); + let s_val = U256::from_big_endian(s_bytes.as_ref()); + + // Recover y_parity by comparing against known public key + let expected_pk = client.public_key(); + let expected_bytes = expected_pk.to_bytes(false); + let y_parity = recover_y_parity(r_bytes.as_ref(), s_bytes.as_ref(), &sighash, expected_bytes.as_ref()); + + match build_signed_evm_tx(&payload, &to_bytes, &data_bytes, y_parity, r_val, s_val) { + Ok(result) => Response { + request_id, + status: "approved".to_string(), + result: Some(result), + error: None, + }, + Err(err) => Response { + request_id, + status: "denied".to_string(), + result: None, + error: Some(err), + }, + } + } + Err(err) => Response { + request_id, + status: "denied".to_string(), + result: None, + error: Some(format!("{err}")), + }, + } + } + fn handle_sign_sol_tx(&mut self, request: Request) -> Response { let payload: SolTxPayload = match serde_json::from_value(request.payload) { Ok(value) => value, @@ -433,6 +665,16 @@ impl Server { } }; + // ----- Threshold signing path ----- + if self.is_threshold(&request.wallet) { + return self.handle_sign_eip2612_permit_threshold( + request.request_id, + request.wallet, + payload, + ); + } + + // ----- Single-key signing path (original) ----- let policy = match self.load_wallet_policy(&request.wallet) { Ok(policy) => policy, Err(err) => { @@ -523,6 +765,122 @@ impl Server { } } + /// Threshold signing path for EIP-2612 permits. + fn handle_sign_eip2612_permit_threshold( + &self, + request_id: String, + wallet: String, + payload: Eip2612PermitPayload, + ) -> Response { + let client = match self.threshold_clients.get(&wallet) { + Some(c) => c, + None => { + return Response { + request_id, + status: "denied".to_string(), + result: None, + error: Some("threshold client not initialized".into()), + } + } + }; + + let rt = match &self.rt { + Some(rt) => rt, + None => { + return Response { + request_id, + status: "denied".to_string(), + result: None, + error: Some("no async runtime".into()), + } + } + }; + + // Derive owner address from key share's public key + let public_key_point = client.public_key(); + let encoded = public_key_point.to_bytes(false); + let pub_bytes = encoded.as_ref(); + let mut hasher = Keccak256::new(); + hasher.update(&pub_bytes[1..]); + let hash = hasher.finalize(); + let mut owner = [0u8; 20]; + owner.copy_from_slice(&hash[12..]); + + // Verify owner matches if provided in payload + if let Some(payload_owner) = payload.owner.as_deref() { + if let Ok(expected) = parse_hex_address_fixed(payload_owner) { + if expected != owner { + return Response { + request_id, + status: "denied".to_string(), + result: None, + error: Some("owner mismatch with threshold key".into()), + }; + } + } + } + + // Compute permit digest + let digest = match compute_permit_digest(&owner, &payload) { + Ok(d) => d, + Err(err) => { + return Response { + request_id, + status: "denied".to_string(), + result: None, + error: Some(err), + } + } + }; + + // Build tx details for policy evaluation + let tx_details = saw_mpc::protocol::TxDetails { + chain_id: Some(payload.chain_id), + to: Some(payload.token.clone()), + value: Some(payload.value.clone()), + data_len: 0, + is_contract_call: false, + }; + + // Run threshold signing + let result = rt.block_on(client.sign( + &wallet, + saw_mpc::protocol::SignAction::Eip2612Permit, + tx_details, + &digest, + )); + + match result { + Ok(sign_result) => { + let sig = &sign_result.signature; + let r_bytes = sig.r.to_be_bytes(); + let s_bytes = sig.s.to_be_bytes(); + + // Recover v: try both parities + let v = recover_y_parity(r_bytes.as_ref(), s_bytes.as_ref(), &digest, &pub_bytes) + 27; + + let mut sig_out = [0u8; 65]; + sig_out[..32].copy_from_slice(r_bytes.as_ref()); + sig_out[32..64].copy_from_slice(s_bytes.as_ref()); + sig_out[64] = v; + let signature = format!("0x{}", hex::encode(sig_out)); + + Response { + request_id, + status: "approved".to_string(), + result: Some(json!({ "signature": signature })), + error: None, + } + } + Err(err) => Response { + request_id, + status: "denied".to_string(), + result: None, + error: Some(format!("{err}")), + }, + } + } + fn load_wallet_policy(&self, wallet: &str) -> Result { let policy_path = self.root.join("policy.yaml"); let contents = fs::read_to_string(&policy_path).map_err(|e| e.to_string())?; @@ -845,6 +1203,106 @@ fn read_key_bytes(root: &Path, chain: Chain, wallet: &str) -> Result, St fs::read(&path).map_err(|e| e.to_string()) } +/// Compute the EIP-1559 sighash for a transaction (the message to sign). +fn compute_evm_tx_sighash(payload: &EvmTxPayload) -> Result<([u8; 32], Vec, Vec), String> { + let to = parse_hex_address(&payload.to)?; + let value = parse_u256(&payload.value).map_err(|_| "invalid value".to_string())?; + let max_fee = + parse_u256(&payload.max_fee_per_gas).map_err(|_| "invalid max_fee".to_string())?; + let max_priority = parse_u256(&payload.max_priority_fee_per_gas) + .map_err(|_| "invalid max_priority".to_string())?; + let data = parse_hex_bytes(&payload.data)?; + + let mut rlp = RlpStream::new_list(9); + rlp.append(&payload.chain_id); + rlp.append(&payload.nonce); + rlp.append(&max_priority); + rlp.append(&max_fee); + rlp.append(&payload.gas_limit); + rlp.append(&to.as_slice()); + rlp.append(&value); + rlp.append(&data); + rlp.begin_list(0); + let unsigned_rlp = rlp.out().to_vec(); + + let mut sighash_input = vec![0x02]; + sighash_input.extend(&unsigned_rlp); + let sighash = Keccak256::digest(&sighash_input); + + let mut hash = [0u8; 32]; + hash.copy_from_slice(&sighash); + + Ok((hash, to, data)) +} + +/// Construct a signed EIP-1559 transaction from signature components. +fn build_signed_evm_tx( + payload: &EvmTxPayload, + to: &[u8], + data: &[u8], + y_parity: u8, + r_val: U256, + s_val: U256, +) -> Result { + let value = parse_u256(&payload.value).map_err(|_| "invalid value".to_string())?; + let max_fee = + parse_u256(&payload.max_fee_per_gas).map_err(|_| "invalid max_fee".to_string())?; + let max_priority = parse_u256(&payload.max_priority_fee_per_gas) + .map_err(|_| "invalid max_priority".to_string())?; + + let mut rlp_signed = RlpStream::new_list(12); + rlp_signed.append(&payload.chain_id); + rlp_signed.append(&payload.nonce); + rlp_signed.append(&max_priority); + rlp_signed.append(&max_fee); + rlp_signed.append(&payload.gas_limit); + rlp_signed.append(&to); + rlp_signed.append(&value); + rlp_signed.append(&data); + rlp_signed.begin_list(0); + rlp_signed.append(&y_parity); + rlp_signed.append(&r_val); + rlp_signed.append(&s_val); + + let mut raw_tx = vec![0x02]; + raw_tx.extend(rlp_signed.out()); + + let mut hasher = Keccak256::new(); + hasher.update(&raw_tx); + let tx_hash = format!("0x{}", hex::encode(hasher.finalize())); + let raw_tx_hex = format!("0x{}", hex::encode(raw_tx)); + + Ok(json!({ + "raw_tx": raw_tx_hex, + "tx_hash": tx_hash + })) +} + +fn recover_y_parity( + r_bytes: &[u8], + s_bytes: &[u8], + msg_hash: &[u8; 32], + expected_pk: &[u8], +) -> u8 { + let secp = Secp256k1::new(); + let msg = Message::from_digest_slice(msg_hash).expect("valid hash"); + let mut sig_bytes = [0u8; 64]; + sig_bytes[..32].copy_from_slice(r_bytes); + sig_bytes[32..].copy_from_slice(s_bytes); + for v_candidate in 0u8..2 { + if let Ok(rid) = secp256k1::ecdsa::RecoveryId::from_i32(v_candidate as i32) { + if let Ok(rec_sig) = RecoverableSignature::from_compact(&sig_bytes, rid) { + if let Ok(recovered) = secp.recover_ecdsa(&msg, &rec_sig) { + if recovered.serialize_uncompressed()[1..] == expected_pk[1..] { + return v_candidate; + } + } + } + } + } + 0 // fallback +} + fn sign_evm_tx(key_bytes: &[u8], payload: EvmTxPayload) -> Result { if key_bytes.len() != 32 { return Err("invalid evm key length".to_string()); @@ -939,6 +1397,28 @@ fn sign_sol_tx(key_bytes: &[u8], message_base64: &str) -> Result Result<[u8; 32], String> { + let token = parse_hex_address_fixed(&payload.token)?; + let spender = parse_hex_address_fixed(&payload.spender)?; + let value = parse_u256(&payload.value).map_err(|_| "invalid value".to_string())?; + let nonce = parse_u256(&payload.nonce).map_err(|_| "invalid nonce".to_string())?; + let deadline = parse_u256(&payload.deadline).map_err(|_| "invalid deadline".to_string())?; + + let domain_separator = eip712_domain_separator( + &payload.name, + &payload.version, + payload.chain_id, + &token, + ); + let struct_hash = eip2612_permit_hash(owner, &spender, value, nonce, deadline); + Ok(eip712_digest(domain_separator, struct_hash)) +} + fn sign_eip2612_permit( key_bytes: &[u8], payload: Eip2612PermitPayload, diff --git a/crates/saw-daemon/src/threshold.rs b/crates/saw-daemon/src/threshold.rs new file mode 100644 index 0000000..92cac74 --- /dev/null +++ b/crates/saw-daemon/src/threshold.rs @@ -0,0 +1,762 @@ +//! Threshold signing client for saw-daemon (Share 1). +//! +//! Manages a persistent WebSocket connection to saw-policy, a background +//! presignature pool, and two signing paths: +//! +//! - **Fast path** (presignature available): local partial sig + exchange +//! with policy → sub-50ms signing latency +//! - **Slow path** (pool empty): full inline MPC signing (fallback) +//! +//! The client maintains a persistent WebSocket connection for fast-path +//! signing. If the connection drops, it auto-reconnects on the next request. +//! Presignature generation uses separate per-operation connections since +//! it runs in the background and isn't latency-critical. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use futures::channel::mpsc; +use futures::stream::{SplitSink, SplitStream}; +use futures::{SinkExt, StreamExt}; +use tokio::sync::Mutex; +use tokio_tungstenite::tungstenite::Message as WsMessage; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; + +use cggmp21::round_based::{Incoming, MessageDestination, MessageType, Outgoing}; +use saw_mpc::protocol::*; +use saw_mpc::signing::{self, PresignaturePool}; +use saw_mpc::transport::DeliveryError; +use saw_mpc::types::{PARTY_DAEMON, PARTY_POLICY}; +use saw_mpc::{KeyShare, Secp256k1}; + +/// Default pool target size and refill threshold. +const DEFAULT_POOL_SIZE: usize = 5; +const DEFAULT_REFILL_THRESHOLD: usize = 2; + +/// Max reconnect backoff. +const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(30); + +// Type aliases for the persistent WS connection +type WsTx = SplitSink>, WsMessage>; +type WsRx = SplitStream>>; + +/// A persistent WebSocket connection that auto-reconnects on failure. +struct PersistentWs { + url: String, + tx: Option, + rx: Option, + /// Consecutive connection failures (for backoff). + failures: u32, +} + +impl PersistentWs { + fn new(url: String) -> Self { + Self { + url, + tx: None, + rx: None, + failures: 0, + } + } + + /// Ensure we have a live connection. Returns Ok if connected. + async fn ensure_connected(&mut self) -> Result<(), ThresholdError> { + if self.tx.is_some() && self.rx.is_some() { + return Ok(()); + } + + let (ws_stream, _) = tokio_tungstenite::connect_async(&self.url) + .await + .map_err(|e| ThresholdError::PolicyUnavailable(format!("connect: {e}")))?; + + let (tx, rx) = ws_stream.split(); + self.tx = Some(tx); + self.rx = Some(rx); + self.failures = 0; + eprintln!("persistent WS connected to {}", self.url); + Ok(()) + } + + /// Mark connection as dead (will reconnect on next use). + fn invalidate(&mut self) { + self.tx = None; + self.rx = None; + self.failures = self.failures.saturating_add(1); + } + + /// Backoff duration based on consecutive failures. + fn backoff(&self) -> Duration { + let secs = (1u64 << self.failures.min(5)).min(MAX_RECONNECT_DELAY.as_secs()); + Duration::from_secs(secs) + } + + /// Send a wire message. Returns Err and invalidates on failure. + async fn send(&mut self, msg: &WireMessage) -> Result<(), ThresholdError> { + self.ensure_connected().await?; + let data = serde_json::to_vec(msg) + .map_err(|e| ThresholdError::Transport(format!("serialize: {e}")))?; + let tx = self.tx.as_mut().unwrap(); + if let Err(e) = tx.send(WsMessage::Binary(data.into())).await { + self.invalidate(); + return Err(ThresholdError::Transport(format!("send: {e}"))); + } + Ok(()) + } + + /// Read the next wire message. Returns Err and invalidates on failure. + async fn recv(&mut self) -> Result { + let rx = self.rx.as_mut().ok_or_else(|| { + ThresholdError::PolicyUnavailable("not connected".into()) + })?; + loop { + match rx.next().await { + Some(Ok(WsMessage::Binary(d))) => { + return serde_json::from_slice(&d) + .map_err(|e| ThresholdError::Transport(format!("deserialize: {e}"))); + } + Some(Ok(WsMessage::Text(t))) => { + return serde_json::from_str(&t) + .map_err(|e| ThresholdError::Transport(format!("deserialize: {e}"))); + } + Some(Ok(WsMessage::Ping(_) | WsMessage::Pong(_))) => continue, + Some(Ok(WsMessage::Close(_))) => { + self.invalidate(); + return Err(ThresholdError::Transport("ws closed by remote".into())); + } + Some(Err(e)) => { + self.invalidate(); + return Err(ThresholdError::Transport(format!("ws error: {e}"))); + } + None => { + self.invalidate(); + return Err(ThresholdError::Transport("ws stream ended".into())); + } + _ => continue, + } + } + } + + /// Read with timeout. Returns None on timeout (connection stays valid). + async fn recv_timeout( + &mut self, + timeout: Duration, + ) -> Result, ThresholdError> { + match tokio::time::timeout(timeout, self.recv()).await { + Ok(result) => result.map(Some), + Err(_) => Ok(None), + } + } +} + +/// Persistent connection to saw-policy for threshold signing. +pub struct ThresholdClient { + key_share: KeyShare, + policy_url: String, + /// Presignature pool (shared with background refill task). + pool: Arc>, + /// Persistent WS for fast-path signing. + ws: Arc>, +} + +impl ThresholdClient { + pub fn new(key_share: KeyShare, policy_url: String) -> Self { + let ws = Arc::new(Mutex::new(PersistentWs::new(policy_url.clone()))); + Self { + key_share, + policy_url, + pool: Arc::new(Mutex::new(PresignaturePool::new( + DEFAULT_POOL_SIZE, + DEFAULT_REFILL_THRESHOLD, + ))), + ws, + } + } + + /// Get the shared public key from the key share. + pub fn public_key(&self) -> generic_ec::Point { + *self.key_share.shared_public_key + } + + /// Get a clone of the pool Arc (for external refill loop). + pub fn pool(&self) -> Arc> { + self.pool.clone() + } + + /// Get a clone of the key share. + pub fn key_share_clone(&self) -> KeyShare { + self.key_share.clone() + } + + /// Get the policy URL. + pub fn policy_url(&self) -> &str { + &self.policy_url + } + + /// Sign a message hash via threshold signing. + /// + /// Fast path: uses a presignature from the pool (partial sig exchange + /// over persistent WS connection). + /// Slow path: falls back to full MPC signing if pool is empty. + pub async fn sign( + &self, + wallet: &str, + action: SignAction, + tx_details: TxDetails, + message_hash: &[u8; 32], + ) -> Result { + // Try fast path + let presig_entry = { + let mut pool = self.pool.lock().await; + pool.take_next() + }; + + if let Some((presig_index, presignature)) = presig_entry { + eprintln!("using presignature {presig_index} (fast path)"); + match self + .sign_with_presignature(wallet, action.clone(), tx_details.clone(), message_hash, presig_index, presignature) + .await + { + Ok(result) => return Ok(result), + Err(e) => { + // Fast path failed (e.g. presig index mismatch after reconnect). + // Fall through to slow path rather than losing the request. + eprintln!("fast path failed: {e}, falling back to slow path"); + } + } + } else { + eprintln!("no presignatures available, using slow path"); + } + + // Slow path: full MPC signing (separate connection) + self.sign_full_mpc(wallet, action, tx_details, message_hash).await + } + + /// Fast path: sign using a pre-generated presignature over the persistent WS. + async fn sign_with_presignature( + &self, + wallet: &str, + action: SignAction, + tx_details: TxDetails, + message_hash: &[u8; 32], + presig_index: u64, + presignature: signing::Presignature, + ) -> Result { + let request_id = generate_request_id(); + + // Issue our partial signature locally (zero network!) + let our_partial = signing::issue_partial_signature(presignature, message_hash); + let our_partial_bytes = serde_json::to_vec(&our_partial) + .map_err(|e| ThresholdError::Mpc(format!("serialize partial: {e}")))?; + let _ = our_partial_bytes; // used for potential future optimization + + // Send partial sign request over persistent connection + let req = WireMessage::PartialSignRequest(PartialSignRequest { + request_id: request_id.clone(), + presig_index, + wallet: wallet.to_string(), + action, + tx_details, + message_hash: format!("0x{}", hex::encode(message_hash)), + }); + + let mut ws = self.ws.lock().await; + ws.send(&req).await?; + + // Wait for policy's partial signature + let resp = tokio::time::timeout(Duration::from_secs(5), async { + loop { + match ws.recv().await? { + WireMessage::PartialSignResponse(r) if r.request_id == request_id => { + return Ok::<_, ThresholdError>(r); + } + _ => continue, + } + } + }) + .await + .map_err(|_| ThresholdError::PolicyUnavailable("partial sign timeout (5s)".into()))??; + + drop(ws); // release lock + + if resp.decision != Decision::Approve { + return Err(match resp.decision { + Decision::Deny => ThresholdError::PolicyDenied { + rule: resp.matched_rule, + reason: resp.reason, + }, + Decision::Escalate => ThresholdError::Escalated { + rule: resp.matched_rule, + reason: resp.reason, + }, + Decision::Approve => unreachable!(), + }); + } + + let policy_partial_bytes = resp + .partial_signature + .ok_or_else(|| ThresholdError::Mpc("no partial signature in response".into()))?; + let policy_partial: cggmp21::PartialSignature = + serde_json::from_slice(&policy_partial_bytes) + .map_err(|e| ThresholdError::Mpc(format!("deserialize partial: {e}")))?; + + let signature = signing::combine_partial_signatures(&[our_partial, policy_partial]) + .map_err(|e| ThresholdError::Mpc(format!("{e}")))?; + + Ok(ThresholdSignResult { + request_id, + signature, + matched_rule: resp.matched_rule, + }) + } + + /// Slow path: full inline MPC signing (separate per-request connection). + async fn sign_full_mpc( + &self, + wallet: &str, + action: SignAction, + tx_details: TxDetails, + message_hash: &[u8; 32], + ) -> Result { + let request_id = generate_request_id(); + let session_id = SessionId::random(); + + let (ws_stream, _) = tokio_tungstenite::connect_async(&self.policy_url) + .await + .map_err(|e| ThresholdError::PolicyUnavailable(format!("connect: {e}")))?; + + let (ws_tx, mut ws_rx) = ws_stream.split(); + let ws_tx = Arc::new(Mutex::new(ws_tx)); + + let sign_req = WireMessage::SignRequest(SignRequest { + request_id: request_id.clone(), + session_id: session_id.clone(), + wallet: wallet.to_string(), + action, + tx_details, + message_hash: format!("0x{}", hex::encode(message_hash)), + }); + + send_ws(&ws_tx, &sign_req).await?; + + let decision = tokio::time::timeout(Duration::from_secs(5), async { + loop { + match read_ws_msg(&mut ws_rx).await? { + WireMessage::PolicyDecision(d) if d.request_id == request_id => { + return Ok::<_, ThresholdError>(d); + } + _ => continue, + } + } + }) + .await + .map_err(|_| ThresholdError::PolicyUnavailable("decision timeout (5s)".into()))??; + + match decision.decision { + Decision::Deny => { + return Err(ThresholdError::PolicyDenied { + rule: decision.matched_rule, + reason: decision.reason, + }); + } + Decision::Escalate => { + return Err(ThresholdError::Escalated { + rule: decision.matched_rule, + reason: decision.reason, + }); + } + Decision::Approve => {} + } + + // MPC signing + type SignMsg = cggmp21::signing::msg::Msg; + let (incoming_tx, incoming_rx) = + mpsc::unbounded::, DeliveryError>>(); + let (outgoing_tx, mut outgoing_rx) = mpsc::unbounded::>(); + + let eid_bytes: Vec = format!("sign-{request_id}").into_bytes(); + let signers = vec![PARTY_DAEMON, PARTY_POLICY]; + + let ws_tx_c = ws_tx.clone(); + let sid_out = session_id.clone(); + let out_task = tokio::spawn(async move { + while let Some(outgoing) = outgoing_rx.next().await { + let to = match outgoing.recipient { + MessageDestination::AllParties => None, + MessageDestination::OneParty(p) => Some(p), + }; + let data = match serde_json::to_vec(&outgoing.msg) { + Ok(d) => d, + Err(_) => continue, + }; + let wire = WireMessage::Mpc(MpcWireMessage { + session_id: sid_out.clone(), + from: PARTY_DAEMON, + to, + data, + }); + let json = match serde_json::to_vec(&wire) { + Ok(d) => d, + Err(_) => continue, + }; + let mut tx = ws_tx_c.lock().await; + if tx.send(WsMessage::Binary(json.into())).await.is_err() { + break; + } + } + }); + + let ks = self.key_share.clone(); + let hash = *message_hash; + let sign_task = tokio::spawn(async move { + let eid = cggmp21::ExecutionId::new(&eid_bytes); + signing::sign_full(eid, 0, &signers, &ks, &hash, (incoming_rx, outgoing_tx)).await + }); + + let counter = AtomicU64::new(0); + let sid_in = session_id; + + loop { + if sign_task.is_finished() { + break; + } + + let msg = tokio::time::timeout(Duration::from_millis(50), ws_rx.next()).await; + + let item = match msg { + Ok(Some(item)) => item, + Ok(None) => { + let _ = incoming_tx.unbounded_send(Err(DeliveryError("ws ended".into()))); + break; + } + Err(_) => continue, + }; + + let raw = match item { + Ok(WsMessage::Binary(d)) => d.to_vec(), + Ok(WsMessage::Text(t)) => t.into_bytes(), + Ok(WsMessage::Ping(_) | WsMessage::Pong(_)) => continue, + Ok(WsMessage::Close(_)) => { + let _ = incoming_tx.unbounded_send(Err(DeliveryError("ws closed".into()))); + break; + } + Err(e) => { + let _ = incoming_tx.unbounded_send(Err(DeliveryError(format!("{e}")))); + break; + } + _ => continue, + }; + + if let Ok(WireMessage::Mpc(mpc_msg)) = serde_json::from_slice::(&raw) { + if mpc_msg.session_id == sid_in { + if let Ok(msg) = serde_json::from_slice(&mpc_msg.data) { + let msg_type = if mpc_msg.to.is_some() { + MessageType::P2P + } else { + MessageType::Broadcast + }; + let incoming = Incoming { + id: counter.fetch_add(1, Ordering::Relaxed), + sender: mpc_msg.from, + msg_type, + msg, + }; + if incoming_tx.unbounded_send(Ok(incoming)).is_err() { + break; + } + } + } + } + } + + let result = sign_task + .await + .map_err(|e| ThresholdError::Mpc(format!("task panic: {e}")))? + .map_err(|e| ThresholdError::Mpc(format!("{e}")))?; + + out_task.abort(); + + Ok(ThresholdSignResult { + request_id, + signature: result, + matched_rule: decision.matched_rule, + }) + } +} + +/// Background presignature refill loop. Call from within a tokio runtime. +pub async fn presign_refill_loop( + pool: Arc>, + key_share: KeyShare, + policy_url: String, + wallet: String, +) { + loop { + let count = { + let p = pool.lock().await; + if p.needs_refill() { p.refill_count() } else { 0 } + }; + + if count > 0 { + eprintln!("presignature pool low, generating {count}"); + for _ in 0..count { + match generate_one_presignature(&pool, &key_share, &policy_url, &wallet).await { + Ok(idx) => { + let avail = pool.lock().await.available(); + eprintln!("presignature ready: index={idx} available={avail}"); + } + Err(e) => { + eprintln!("presignature generation failed: {e}, will retry"); + tokio::time::sleep(Duration::from_secs(5)).await; + break; + } + } + } + } + + tokio::time::sleep(Duration::from_secs(10)).await; + } +} + +/// Generate one presignature via MPC with the policy server. +/// Uses a separate per-operation connection (background, not latency-critical). +async fn generate_one_presignature( + pool: &Arc>, + key_share: &KeyShare, + policy_url: &str, + wallet: &str, +) -> Result { + let presig_index = { + let mut p = pool.lock().await; + p.reserve_index() + }; + + let session_id = SessionId::random(); + + let (ws_stream, _) = tokio_tungstenite::connect_async(policy_url) + .await + .map_err(|e| ThresholdError::PolicyUnavailable(format!("connect: {e}")))?; + + let (ws_tx, mut ws_rx) = ws_stream.split(); + let ws_tx = Arc::new(Mutex::new(ws_tx)); + + let req = WireMessage::PresignRequest(PresignRequest { + session_id: session_id.clone(), + presig_index, + wallet: wallet.to_string(), + }); + send_ws(&ws_tx, &req).await?; + + type SignMsg = cggmp21::signing::msg::Msg; + let (incoming_tx, incoming_rx) = + mpsc::unbounded::, DeliveryError>>(); + let (outgoing_tx, mut outgoing_rx) = mpsc::unbounded::>(); + + let eid_bytes: Vec = format!("presign-{presig_index}").into_bytes(); + let signers = vec![PARTY_DAEMON, PARTY_POLICY]; + + let ws_tx_c = ws_tx.clone(); + let sid_out = session_id.clone(); + let out_task = tokio::spawn(async move { + while let Some(outgoing) = outgoing_rx.next().await { + let to = match outgoing.recipient { + MessageDestination::AllParties => None, + MessageDestination::OneParty(p) => Some(p), + }; + let data = match serde_json::to_vec(&outgoing.msg) { + Ok(d) => d, + Err(_) => continue, + }; + let wire = WireMessage::Mpc(MpcWireMessage { + session_id: sid_out.clone(), + from: PARTY_DAEMON, + to, + data, + }); + let json = match serde_json::to_vec(&wire) { + Ok(d) => d, + Err(_) => continue, + }; + let mut tx = ws_tx_c.lock().await; + if tx.send(WsMessage::Binary(json.into())).await.is_err() { + break; + } + } + }); + + let ks = key_share.clone(); + let presign_task = tokio::spawn(async move { + let eid = cggmp21::ExecutionId::new(&eid_bytes); + signing::generate_presignature(eid, 0, &signers, &ks, (incoming_rx, outgoing_tx)).await + }); + + let counter = AtomicU64::new(0); + let sid_in = session_id; + + loop { + if presign_task.is_finished() { + break; + } + + let msg = tokio::time::timeout(Duration::from_millis(50), ws_rx.next()).await; + + let item = match msg { + Ok(Some(item)) => item, + Ok(None) => { + let _ = incoming_tx.unbounded_send(Err(DeliveryError("ws ended".into()))); + break; + } + Err(_) => continue, + }; + + let raw = match item { + Ok(WsMessage::Binary(d)) => d.to_vec(), + Ok(WsMessage::Text(t)) => t.into_bytes(), + Ok(WsMessage::Ping(_) | WsMessage::Pong(_)) => continue, + Ok(WsMessage::Close(_)) => { + let _ = incoming_tx.unbounded_send(Err(DeliveryError("ws closed".into()))); + break; + } + Err(e) => { + let _ = incoming_tx.unbounded_send(Err(DeliveryError(format!("{e}")))); + break; + } + _ => continue, + }; + + if let Ok(WireMessage::Mpc(mpc_msg)) = serde_json::from_slice::(&raw) { + if mpc_msg.session_id == sid_in { + if let Ok(msg) = serde_json::from_slice(&mpc_msg.data) { + let msg_type = if mpc_msg.to.is_some() { + MessageType::P2P + } else { + MessageType::Broadcast + }; + let incoming = Incoming { + id: counter.fetch_add(1, Ordering::Relaxed), + sender: mpc_msg.from, + msg_type, + msg, + }; + if incoming_tx.unbounded_send(Ok(incoming)).is_err() { + break; + } + } + } + } + } + + let presignature = presign_task + .await + .map_err(|e| ThresholdError::Mpc(format!("task panic: {e}")))? + .map_err(|e| ThresholdError::Mpc(format!("{e}")))?; + + out_task.abort(); + + pool.lock().await.add(presig_index, presignature); + + Ok(presig_index) +} + +/// Result of a successful threshold signing operation. +pub struct ThresholdSignResult { + pub request_id: String, + pub signature: signing::CggmpSignature, + pub matched_rule: Option, +} + +/// Errors specific to threshold signing. +#[derive(Debug)] +pub enum ThresholdError { + PolicyUnavailable(String), + PolicyDenied { + rule: Option, + reason: Option, + }, + Escalated { + rule: Option, + reason: Option, + }, + Mpc(String), + Transport(String), +} + +impl std::fmt::Display for ThresholdError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::PolicyUnavailable(e) => write!(f, "policy_unavailable: {e}"), + Self::PolicyDenied { rule, reason } => { + write!(f, "policy_denied")?; + if let Some(r) = rule { write!(f, " (rule: {r})")?; } + if let Some(r) = reason { write!(f, ": {r}")?; } + Ok(()) + } + Self::Escalated { rule, reason } => { + write!(f, "escalated")?; + if let Some(r) = rule { write!(f, " (rule: {r})")?; } + if let Some(r) = reason { write!(f, ": {r}")?; } + Ok(()) + } + Self::Mpc(e) => write!(f, "mpc_error: {e}"), + Self::Transport(e) => write!(f, "transport_error: {e}"), + } + } +} + +impl std::error::Error for ThresholdError {} + +// -- Helpers -- + +fn generate_request_id() -> String { + use rand_core::{OsRng, RngCore}; + let mut bytes = [0u8; 16]; + OsRng.fill_bytes(&mut bytes); + hex::encode(bytes) +} + +async fn send_ws( + tx: &Arc, WsMessage>>>, + msg: &WireMessage, +) -> Result<(), ThresholdError> +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, +{ + let data = serde_json::to_vec(msg).map_err(|e| ThresholdError::Transport(format!("{e}")))?; + let mut guard = tx.lock().await; + guard + .send(WsMessage::Binary(data.into())) + .await + .map_err(|e| ThresholdError::Transport(format!("{e}")))?; + Ok(()) +} + +async fn read_ws_msg( + rx: &mut futures::stream::SplitStream>, +) -> Result +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, +{ + loop { + match rx.next().await { + Some(Ok(WsMessage::Binary(d))) => { + return serde_json::from_slice(&d) + .map_err(|e| ThresholdError::Transport(format!("{e}"))); + } + Some(Ok(WsMessage::Text(t))) => { + return serde_json::from_str(&t) + .map_err(|e| ThresholdError::Transport(format!("{e}"))); + } + Some(Ok(WsMessage::Ping(_) | WsMessage::Pong(_))) => continue, + Some(Ok(WsMessage::Close(_))) => { + return Err(ThresholdError::Transport("ws closed".into())); + } + Some(Err(e)) => { + return Err(ThresholdError::Transport(format!("{e}"))); + } + None => { + return Err(ThresholdError::Transport("ws ended".into())); + } + _ => continue, + } + } +} diff --git a/crates/saw-mpc/Cargo.toml b/crates/saw-mpc/Cargo.toml new file mode 100644 index 0000000..9479fb3 --- /dev/null +++ b/crates/saw-mpc/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "saw-mpc" +version = "0.1.0" +edition = "2021" +description = "Threshold signing core for SAW — keygen, presigning, signing via CGGMP21" + +[dependencies] +cggmp21 = { version = "0.6", features = ["hd-wallet", "curve-secp256k1"] } +round-based = "0.3" +futures = "0.3" +generic-ec = "0.4" +hex = "0.4" +k256 = { version = "0.13", features = ["ecdsa"] } +rand_core = { version = "0.6", features = ["getrandom"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +sha3 = "0.10" +argon2 = "0.5" +chacha20poly1305 = "0.10" +thiserror = "1" +tokio = { version = "1", features = ["sync", "macros", "rt-multi-thread", "net", "io-util", "time"] } +tokio-tungstenite = { version = "0.24", features = ["native-tls"] } +tracing = "0.1" + +[dev-dependencies] +tracing-subscriber = "0.3" +generic-ec = "0.4" diff --git a/crates/saw-mpc/src/encryption.rs b/crates/saw-mpc/src/encryption.rs new file mode 100644 index 0000000..d340140 --- /dev/null +++ b/crates/saw-mpc/src/encryption.rs @@ -0,0 +1,161 @@ +//! Share encryption at rest using Argon2id + ChaCha20-Poly1305. +//! +//! Encrypted format (binary): +//! [4 bytes] magic: b"SAW1" +//! [1 byte] version: 0x01 +//! [32 bytes] salt (random, for Argon2id) +//! [12 bytes] nonce (random, for ChaCha20-Poly1305) +//! [N bytes] ciphertext + 16-byte Poly1305 tag +//! +//! Total overhead: 4 + 1 + 32 + 12 + 16 = 65 bytes +//! +//! Plaintext detection: if the first 4 bytes are NOT b"SAW1", assume +//! the file is unencrypted JSON (backward compatibility). + +use argon2::Argon2; +use chacha20poly1305::{ + aead::{Aead, KeyInit}, + ChaCha20Poly1305, Nonce, +}; +use rand_core::{OsRng, RngCore}; + +use crate::error::MpcError; + +const MAGIC: &[u8; 4] = b"SAW1"; +const VERSION: u8 = 0x01; +const SALT_LEN: usize = 32; +const NONCE_LEN: usize = 12; +const HEADER_LEN: usize = 4 + 1 + SALT_LEN + NONCE_LEN; // 49 + +/// Returns true if the data appears to be encrypted (starts with SAW1 magic). +pub fn is_encrypted(data: &[u8]) -> bool { + data.len() >= 4 && &data[..4] == MAGIC +} + +/// Encrypt plaintext key share bytes with a passphrase. +pub fn encrypt(plaintext: &[u8], passphrase: &[u8]) -> Result, MpcError> { + if passphrase.is_empty() { + return Err(MpcError::Encryption("empty passphrase".into())); + } + + let mut salt = [0u8; SALT_LEN]; + OsRng.fill_bytes(&mut salt); + + let mut nonce_bytes = [0u8; NONCE_LEN]; + OsRng.fill_bytes(&mut nonce_bytes); + + let key = derive_key(passphrase, &salt)?; + let cipher = ChaCha20Poly1305::new_from_slice(&key) + .map_err(|e| MpcError::Encryption(format!("cipher init: {e}")))?; + let nonce = Nonce::from_slice(&nonce_bytes); + + let ciphertext = cipher + .encrypt(nonce, plaintext) + .map_err(|e| MpcError::Encryption(format!("encrypt: {e}")))?; + + let mut out = Vec::with_capacity(HEADER_LEN + ciphertext.len()); + out.extend_from_slice(MAGIC); + out.push(VERSION); + out.extend_from_slice(&salt); + out.extend_from_slice(&nonce_bytes); + out.extend_from_slice(&ciphertext); + + Ok(out) +} + +/// Decrypt an encrypted key share. Returns the plaintext bytes. +/// +/// If the data is not encrypted (no SAW1 magic), returns it as-is +/// for backward compatibility with unencrypted key shares. +pub fn decrypt(data: &[u8], passphrase: &[u8]) -> Result, MpcError> { + if !is_encrypted(data) { + // Unencrypted — pass through + return Ok(data.to_vec()); + } + + if data.len() < HEADER_LEN + 16 { + // minimum: header + 16-byte auth tag (empty plaintext) + return Err(MpcError::Encryption("encrypted data too short".into())); + } + + let version = data[4]; + if version != VERSION { + return Err(MpcError::Encryption(format!( + "unsupported encryption version: {version}" + ))); + } + + let salt = &data[5..5 + SALT_LEN]; + let nonce_bytes = &data[5 + SALT_LEN..HEADER_LEN]; + let ciphertext = &data[HEADER_LEN..]; + + let key = derive_key(passphrase, salt)?; + let cipher = ChaCha20Poly1305::new_from_slice(&key) + .map_err(|e| MpcError::Encryption(format!("cipher init: {e}")))?; + let nonce = Nonce::from_slice(nonce_bytes); + + cipher + .decrypt(nonce, ciphertext) + .map_err(|_| MpcError::Encryption("decryption failed — wrong passphrase or corrupted data".into())) +} + +/// Derive a 256-bit key from passphrase + salt using Argon2id. +fn derive_key(passphrase: &[u8], salt: &[u8]) -> Result<[u8; 32], MpcError> { + let mut key = [0u8; 32]; + // Argon2id with default params (19 MiB memory, 2 iterations, 1 parallelism) + Argon2::default() + .hash_password_into(passphrase, salt, &mut key) + .map_err(|e| MpcError::Encryption(format!("argon2: {e}")))?; + Ok(key) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip() { + let plaintext = b"{\"key\": \"share data here\"}"; + let passphrase = b"test-passphrase-123"; + + let encrypted = encrypt(plaintext, passphrase).unwrap(); + assert!(is_encrypted(&encrypted)); + assert!(&encrypted[..4] == MAGIC); + + let decrypted = decrypt(&encrypted, passphrase).unwrap(); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn wrong_passphrase_fails() { + let plaintext = b"secret key share"; + let encrypted = encrypt(plaintext, b"correct").unwrap(); + let result = decrypt(&encrypted, b"wrong"); + assert!(result.is_err()); + } + + #[test] + fn unencrypted_passthrough() { + let plaintext = b"{\"some\": \"json\"}"; + let result = decrypt(plaintext, b"anything").unwrap(); + assert_eq!(result, plaintext); + } + + #[test] + fn empty_passphrase_rejected() { + let result = encrypt(b"data", b""); + assert!(result.is_err()); + } + + #[test] + fn different_encryptions_differ() { + let plaintext = b"same data"; + let e1 = encrypt(plaintext, b"pass").unwrap(); + let e2 = encrypt(plaintext, b"pass").unwrap(); + // Different salt + nonce → different ciphertext + assert_ne!(e1, e2); + // But both decrypt to the same thing + assert_eq!(decrypt(&e1, b"pass").unwrap(), plaintext); + assert_eq!(decrypt(&e2, b"pass").unwrap(), plaintext); + } +} diff --git a/crates/saw-mpc/src/error.rs b/crates/saw-mpc/src/error.rs new file mode 100644 index 0000000..771ceef --- /dev/null +++ b/crates/saw-mpc/src/error.rs @@ -0,0 +1,31 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum MpcError { + #[error("keygen failed: {0}")] + Keygen(String), + + #[error("aux info generation failed: {0}")] + AuxInfo(String), + + #[error("presigning failed: {0}")] + Presign(String), + + #[error("signing failed: {0}")] + Signing(String), + + #[error("transport error: {0}")] + Transport(String), + + #[error("invalid party configuration: {0}")] + Config(String), + + #[error("serialization error: {0}")] + Serde(#[from] serde_json::Error), + + #[error("io error: {0}")] + Io(#[from] std::io::Error), + + #[error("encryption error: {0}")] + Encryption(String), +} diff --git a/crates/saw-mpc/src/keygen.rs b/crates/saw-mpc/src/keygen.rs new file mode 100644 index 0000000..fa79b3a --- /dev/null +++ b/crates/saw-mpc/src/keygen.rs @@ -0,0 +1,149 @@ +//! Key generation ceremony: all parties collaborate to produce +//! key shares without any single party ever seeing the full key. + +use rand_core::OsRng; +use sha3::{Digest, Keccak256}; + +use cggmp21::supported_curves::Secp256k1; +use cggmp21::{ + key_share::AuxInfo, + ExecutionId, IncompleteKeyShare, PregeneratedPrimes, +}; + +use crate::error::MpcError; + +/// Output of a successful keygen ceremony. +pub struct KeygenOutput { + pub key_share: cggmp21::KeyShare, + pub address: String, + pub public_key: String, +} + +/// Generate precomputed Paillier primes (CPU-intensive). +pub fn pregenerate_primes() -> PregeneratedPrimes { + tracing::info!("generating Paillier primes (this may take a while)..."); + let primes = PregeneratedPrimes::generate(&mut OsRng); + tracing::info!("prime generation complete"); + primes +} + +/// Run aux info generation using a `Delivery` transport. +pub async fn generate_aux_info( + eid: ExecutionId<'_>, + party_id: u16, + num_parties: u16, + primes: PregeneratedPrimes, + delivery: D, +) -> Result +where + D: cggmp21::round_based::Delivery< + cggmp21::key_refresh::msg::aux_only::Msg< + sha2::Sha256, + cggmp21::security_level::SecurityLevel128, + >, + >, +{ + tracing::info!(party_id, num_parties, "starting aux info generation"); + + let party = cggmp21::round_based::MpcParty::connected(delivery); + + let aux_info = cggmp21::aux_info_gen(eid, party_id, num_parties, primes) + .start(&mut OsRng, party) + .await + .map_err(|e| MpcError::AuxInfo(format!("{e:?}")))?; + + tracing::info!(party_id, "aux info generation complete"); + Ok(aux_info) +} + +/// Run distributed key generation using a `Delivery` transport. +pub async fn generate_key( + eid: ExecutionId<'_>, + party_id: u16, + num_parties: u16, + threshold: u16, + delivery: D, +) -> Result, MpcError> +where + D: cggmp21::round_based::Delivery< + cggmp21::keygen::ThresholdMsg< + Secp256k1, + cggmp21::security_level::SecurityLevel128, + sha2::Sha256, + >, + >, +{ + tracing::info!(party_id, num_parties, threshold, "starting key generation"); + + let party = cggmp21::round_based::MpcParty::connected(delivery); + + let incomplete = cggmp21::keygen::(eid, party_id, num_parties) + .set_threshold(threshold) + .start(&mut OsRng, party) + .await + .map_err(|e| MpcError::Keygen(format!("{e:?}")))?; + + tracing::info!(party_id, "key generation complete"); + Ok(incomplete) +} + +/// Combine IncompleteKeyShare + AuxInfo → complete KeyShare + derive ETH address. +pub fn complete_key_share( + incomplete: IncompleteKeyShare, + aux_info: AuxInfo, +) -> Result { + let key_share = cggmp21::KeyShare::from_parts((incomplete, aux_info)) + .map_err(|e| MpcError::Keygen(format!("failed to combine key share: {e:?}")))?; + + // Derive Ethereum address from shared public key + let public_key_point = key_share.shared_public_key; + let encoded = public_key_point.to_bytes(false); + let pub_bytes = encoded.as_ref(); + let public_key = format!("0x{}", hex::encode(pub_bytes)); + + let mut hasher = Keccak256::new(); + hasher.update(&pub_bytes[1..]); + let hash = hasher.finalize(); + let address = format!("0x{}", hex::encode(&hash[12..])); + + tracing::info!(address = %address, "key share completed"); + + Ok(KeygenOutput { + key_share, + address, + public_key, + }) +} + +/// Serialize a KeyShare for storage. +pub fn serialize_key_share( + key_share: &cggmp21::KeyShare, +) -> Result, MpcError> { + serde_json::to_vec(key_share).map_err(MpcError::Serde) +} + +/// Deserialize a KeyShare from storage. +pub fn deserialize_key_share( + data: &[u8], +) -> Result, MpcError> { + serde_json::from_slice(data).map_err(MpcError::Serde) +} + +/// Serialize and encrypt a KeyShare for storage. +pub fn serialize_key_share_encrypted( + key_share: &cggmp21::KeyShare, + passphrase: &[u8], +) -> Result, MpcError> { + let plaintext = serialize_key_share(key_share)?; + crate::encryption::encrypt(&plaintext, passphrase) +} + +/// Decrypt and deserialize a KeyShare from storage. +/// If the data is not encrypted, falls back to plaintext deserialization. +pub fn deserialize_key_share_encrypted( + data: &[u8], + passphrase: &[u8], +) -> Result, MpcError> { + let plaintext = crate::encryption::decrypt(data, passphrase)?; + serde_json::from_slice(&plaintext).map_err(MpcError::Serde) +} diff --git a/crates/saw-mpc/src/lib.rs b/crates/saw-mpc/src/lib.rs new file mode 100644 index 0000000..4e9d2fb --- /dev/null +++ b/crates/saw-mpc/src/lib.rs @@ -0,0 +1,27 @@ +//! saw-mpc: Threshold signing core for SAW +//! +//! Provides CGGMP21-based threshold ECDSA: +//! - Key generation ceremony (all parties) +//! - Auxiliary info generation (Paillier setup) +//! - Presignature generation (t parties, background) +//! - Online signing (single round with presignature) +//! +//! Network-agnostic: callers provide Stream/Sink transports +//! via the `round_based::Delivery` trait. + +pub mod encryption; +pub mod error; +pub mod keygen; +pub mod protocol; +pub mod relay; +pub mod signing; +pub mod transport; +pub mod types; + +pub use error::MpcError; +pub use types::{KeyShareData, PartyId, ThresholdConfig}; + +// Re-export key cggmp21 types that consumers need +pub use cggmp21::supported_curves::Secp256k1; +pub use cggmp21::ExecutionId; +pub use cggmp21::KeyShare; diff --git a/crates/saw-mpc/src/protocol.rs b/crates/saw-mpc/src/protocol.rs new file mode 100644 index 0000000..b7ee504 --- /dev/null +++ b/crates/saw-mpc/src/protocol.rs @@ -0,0 +1,173 @@ +//! Protocol coordination: ties together keygen, presigning, and signing +//! with the transport layer and session management. + +use serde::{Deserialize, Serialize}; + +use crate::types::PartyId; + +/// Unique identifier for an MPC protocol execution. +/// Must never be reused across different protocol runs. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct SessionId(pub String); + +impl SessionId { + /// Generate a new random session ID. + pub fn random() -> Self { + use rand_core::{OsRng, RngCore}; + let mut bytes = [0u8; 16]; + OsRng.fill_bytes(&mut bytes); + Self(hex::encode(bytes)) + } + + /// Create from a known string (e.g., received from coordinator). + pub fn from_str(s: &str) -> Self { + Self(s.to_string()) + } + + /// Convert to cggmp21 ExecutionId bytes. + pub fn as_bytes(&self) -> &[u8] { + self.0.as_bytes() + } +} + +/// A request from saw-daemon to saw-policy for signing. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SignRequest { + /// Unique request ID + pub request_id: String, + /// Session ID for the MPC protocol execution + pub session_id: SessionId, + /// Wallet name + pub wallet: String, + /// What action is being performed + pub action: SignAction, + /// Transaction details for policy evaluation + pub tx_details: TxDetails, + /// The message hash to sign (32 bytes, hex) + pub message_hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum SignAction { + EvmTx, + Eip2612Permit, +} + +/// Transaction details sent to saw-policy for evaluation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TxDetails { + pub chain_id: Option, + pub to: Option, + pub value: Option, + pub data_len: usize, + /// Whether this is a contract call (non-empty data) + pub is_contract_call: bool, +} + +/// Policy decision from saw-policy. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PolicyDecision { + pub request_id: String, + pub decision: Decision, + /// Which rule matched (for audit) + pub matched_rule: Option, + /// Reason if denied or escalated + pub reason: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Decision { + /// Policy approves — proceed with MPC signing + Approve, + /// Policy denies — reject the request + Deny, + /// Policy escalates — requires human cosigner + Escalate, +} + +/// Request to generate a presignature in the background (daemon → policy). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PresignRequest { + /// Session ID for the MPC presign execution + pub session_id: SessionId, + /// Sequential index — both sides use this to match presignatures + pub presig_index: u64, + /// Wallet name + pub wallet: String, +} + +/// Result of a full MPC signing session (policy → daemon). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SigningComplete { + pub request_id: String, + pub success: bool, + pub error: Option, +} + +/// Acknowledgement that a presignature was generated (policy → daemon). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PresignReady { + pub presig_index: u64, +} + +/// Request to sign using a pre-generated presignature (daemon → policy). +/// The online phase: no MPC rounds, just partial signature exchange. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PartialSignRequest { + pub request_id: String, + /// Which presignature to consume + pub presig_index: u64, + /// Wallet name + pub wallet: String, + /// Action for policy evaluation + pub action: SignAction, + /// Transaction details for policy evaluation + pub tx_details: TxDetails, + /// The message hash to sign (32 bytes, hex) + pub message_hash: String, +} + +/// Response with the policy's partial signature (policy → daemon). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PartialSignResponse { + pub request_id: String, + pub decision: Decision, + pub matched_rule: Option, + pub reason: Option, + /// Policy's partial signature (only present if approved) + pub partial_signature: Option>, +} + +/// Wire message between saw-daemon and saw-policy. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum WireMessage { + /// Signing request — full MPC inline (daemon → policy) + SignRequest(SignRequest), + /// Policy decision for full MPC signing (policy → daemon) + PolicyDecision(PolicyDecision), + /// MPC protocol message (bidirectional) + Mpc(MpcWireMessage), + /// Request to generate a presignature (daemon → policy) + PresignRequest(PresignRequest), + /// Presignature generation complete (policy → daemon) + PresignReady(PresignReady), + /// Online signing with presignature (daemon → policy) + PartialSignRequest(PartialSignRequest), + /// Policy's partial signature response (policy → daemon) + PartialSignResponse(PartialSignResponse), + /// Signing completed (policy → daemon) + SigningComplete(SigningComplete), + /// Heartbeat / keepalive + Ping, + Pong, +} + +/// Serialized MPC round message for transport. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MpcWireMessage { + pub session_id: SessionId, + pub from: PartyId, + pub to: Option, + /// Serialized round-based protocol message + pub data: Vec, +} diff --git a/crates/saw-mpc/src/relay.rs b/crates/saw-mpc/src/relay.rs new file mode 100644 index 0000000..ca3bda7 --- /dev/null +++ b/crates/saw-mpc/src/relay.rs @@ -0,0 +1,380 @@ +//! WebSocket relay server for multi-party MPC ceremonies. +//! +//! All parties connect to the relay. The relay routes MPC messages: +//! - Broadcast → forward to all other parties +//! - P2P → forward to the specific target party +//! +//! Used for keygen ceremonies where all 3 parties must participate. +//! For 2-party signing, use the direct WsConnection instead. + +use std::collections::HashMap; +use std::sync::atomic::Ordering; +use std::sync::Arc; + +use futures::{SinkExt, StreamExt}; +use tokio::net::TcpListener; +use tokio::sync::{mpsc, Mutex}; +use tokio_tungstenite::tungstenite::Message as WsMessage; + +use serde::{Deserialize, Serialize}; + +use crate::transport::DeliveryError; +use crate::types::PartyId; + +/// Message format for the relay protocol. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RelayMessage { + /// Protocol phase (e.g., "aux", "keygen", "sign") + pub phase: String, + /// Sender party ID + pub from: PartyId, + /// Target: None = broadcast, Some(id) = P2P + pub to: Option, + /// Serialized protocol message + pub data: Vec, +} + +/// Control messages between parties and the relay. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum RelayEnvelope { + /// Party announces itself on connect + Join { party_id: PartyId }, + /// Relay confirms party joined + Joined { party_id: PartyId, total: usize }, + /// Relay announces all parties are present + AllJoined { parties: Vec }, + /// Signal to start a phase + StartPhase { phase: String }, + /// Party signals phase complete + PhaseComplete { phase: String, party_id: PartyId }, + /// MPC protocol message + Mpc(RelayMessage), + /// Error + Error { message: String }, +} + +/// Run a relay server that routes MPC messages between N parties. +pub async fn run_relay( + listen_addr: &str, + expected_parties: u16, +) -> Result<(), crate::error::MpcError> { + let listener = TcpListener::bind(listen_addr) + .await + .map_err(|e| crate::error::MpcError::Transport(format!("bind: {e}")))?; + + tracing::info!(listen = %listen_addr, expected = expected_parties, "relay server started"); + + // Party senders: party_id → channel to send messages to that party + let senders: Arc>>> = + Arc::new(Mutex::new(HashMap::new())); + + let mut join_handles = Vec::new(); + let mut connected = 0u16; + + while connected < expected_parties { + let (tcp, addr) = listener + .accept() + .await + .map_err(|e| crate::error::MpcError::Transport(format!("accept: {e}")))?; + + tracing::info!(%addr, "new connection"); + + let ws = tokio_tungstenite::accept_async(tcp) + .await + .map_err(|e| crate::error::MpcError::Transport(format!("handshake: {e}")))?; + + let (mut ws_tx, mut ws_rx) = ws.split(); + + // Read Join message + let join_msg = loop { + match ws_rx.next().await { + Some(Ok(WsMessage::Text(t))) => { + break serde_json::from_str::(&t) + .map_err(|e| crate::error::MpcError::Transport(format!("bad join: {e}")))?; + } + Some(Ok(WsMessage::Binary(d))) => { + break serde_json::from_slice::(&d) + .map_err(|e| crate::error::MpcError::Transport(format!("bad join: {e}")))?; + } + Some(Ok(_)) => continue, + _ => return Err(crate::error::MpcError::Transport("connection lost before join".into())), + } + }; + + let party_id = match join_msg { + RelayEnvelope::Join { party_id } => party_id, + _ => return Err(crate::error::MpcError::Transport("expected Join message".into())), + }; + + if party_id >= expected_parties { + let err = RelayEnvelope::Error { + message: format!("party_id {party_id} >= {expected_parties}"), + }; + let _ = ws_tx + .send(WsMessage::Text(serde_json::to_string(&err).unwrap().into())) + .await; + continue; + } + + // Create channel for sending messages to this party + let (party_tx, mut party_rx) = mpsc::unbounded_channel::(); + + { + let mut map = senders.lock().await; + if map.contains_key(&party_id) { + let err = RelayEnvelope::Error { + message: format!("party {party_id} already connected"), + }; + let _ = ws_tx + .send(WsMessage::Text(serde_json::to_string(&err).unwrap().into())) + .await; + continue; + } + map.insert(party_id, party_tx); + connected = map.len() as u16; + } + + // Send Joined confirmation + let joined = RelayEnvelope::Joined { + party_id, + total: connected as usize, + }; + let _ = ws_tx + .send(WsMessage::Text(serde_json::to_string(&joined).unwrap().into())) + .await; + + tracing::info!(party_id, connected, "party joined"); + + // Spawn writer task: channel → WebSocket + let write_handle = tokio::spawn(async move { + while let Some(msg) = party_rx.recv().await { + let json = serde_json::to_string(&msg).unwrap(); + if ws_tx.send(WsMessage::Text(json.into())).await.is_err() { + break; + } + } + }); + + // Spawn reader task: WebSocket → route to other parties + let senders_clone = senders.clone(); + let read_handle = tokio::spawn(async move { + while let Some(item) = ws_rx.next().await { + let raw = match item { + Ok(WsMessage::Text(t)) => t.into_bytes(), + Ok(WsMessage::Binary(d)) => d.to_vec(), + Ok(WsMessage::Ping(_) | WsMessage::Pong(_)) => continue, + Ok(WsMessage::Close(_)) | Err(_) => break, + _ => continue, + }; + + let envelope: RelayEnvelope = match serde_json::from_slice(&raw) { + Ok(e) => e, + Err(_) => continue, + }; + + match &envelope { + RelayEnvelope::Mpc(relay_msg) => { + let map = senders_clone.lock().await; + match relay_msg.to { + Some(target) => { + // P2P: send to specific party + if let Some(tx) = map.get(&target) { + let _ = tx.send(envelope.clone()); + } + } + None => { + // Broadcast: send to all except sender + for (&pid, tx) in map.iter() { + if pid != party_id { + let _ = tx.send(envelope.clone()); + } + } + } + } + } + RelayEnvelope::PhaseComplete { .. } => { + // Forward to all other parties + let map = senders_clone.lock().await; + for (&pid, tx) in map.iter() { + if pid != party_id { + let _ = tx.send(envelope.clone()); + } + } + } + _ => {} + } + } + }); + + join_handles.push((party_id, write_handle, read_handle)); + } + + // All parties connected — send AllJoined to everyone + { + let map = senders.lock().await; + let parties: Vec = map.keys().copied().collect(); + let msg = RelayEnvelope::AllJoined { + parties: parties.clone(), + }; + for tx in map.values() { + let _ = tx.send(msg.clone()); + } + tracing::info!(?parties, "all parties joined, ceremony can begin"); + } + + // Keep relay alive until all read tasks finish (parties disconnect) + for (pid, wh, rh) in join_handles { + let _ = rh.await; + wh.abort(); + tracing::info!(party_id = pid, "party disconnected"); + } + + Ok(()) +} + +/// Connect to a relay as a party and get a channel-based delivery for MPC. +/// +/// Returns a delivery pair `(incoming_rx, outgoing_tx)` compatible with +/// cggmp21's `MpcParty::connected()`, plus a control channel for +/// phase coordination. +pub async fn connect_to_relay( + url: &str, + party_id: PartyId, + phase: &str, +) -> Result< + ( + futures::channel::mpsc::UnboundedReceiver, DeliveryError>>, + futures::channel::mpsc::UnboundedSender>, + ), + crate::error::MpcError, +> +where + M: serde::Serialize + serde::de::DeserializeOwned + Clone + Send + 'static, +{ + let (ws, _) = tokio_tungstenite::connect_async(url) + .await + .map_err(|e| crate::error::MpcError::Transport(format!("connect: {e}")))?; + + let (mut ws_tx, mut ws_rx) = ws.split(); + + // Send Join + let join = RelayEnvelope::Join { party_id }; + ws_tx + .send(WsMessage::Text(serde_json::to_string(&join).unwrap().into())) + .await + .map_err(|e| crate::error::MpcError::Transport(format!("send join: {e}")))?; + + // Wait for Joined + AllJoined + loop { + let raw = match ws_rx.next().await { + Some(Ok(WsMessage::Text(t))) => t.into_bytes(), + Some(Ok(WsMessage::Binary(d))) => d.to_vec(), + Some(Ok(_)) => continue, + _ => return Err(crate::error::MpcError::Transport("connection lost".into())), + }; + let env: RelayEnvelope = serde_json::from_slice(&raw) + .map_err(|e| crate::error::MpcError::Transport(format!("bad msg: {e}")))?; + match env { + RelayEnvelope::Joined { .. } => continue, + RelayEnvelope::AllJoined { .. } => break, + RelayEnvelope::Error { message } => { + return Err(crate::error::MpcError::Transport(format!("relay error: {message}"))); + } + _ => continue, + } + } + + // Build channel-based delivery + let (incoming_tx, incoming_rx) = futures::channel::mpsc::unbounded(); + let (outgoing_tx, mut outgoing_rx) = + futures::channel::mpsc::unbounded::>(); + + let counter = Arc::new(std::sync::atomic::AtomicU64::new(0)); + let phase_str = phase.to_string(); + + // Outgoing: MPC Outgoing → RelayEnvelope::Mpc → WebSocket + let phase_out = phase_str.clone(); + let ws_tx = Arc::new(Mutex::new(ws_tx)); + let ws_tx_out = ws_tx.clone(); + tokio::spawn(async move { + use cggmp21::round_based::MessageDestination; + + while let Some(outgoing) = outgoing_rx.next().await { + let to = match outgoing.recipient { + MessageDestination::AllParties => None, + MessageDestination::OneParty(p) => Some(p), + }; + let data = match serde_json::to_vec(&outgoing.msg) { + Ok(d) => d, + Err(_) => continue, + }; + let env = RelayEnvelope::Mpc(RelayMessage { + phase: phase_out.clone(), + from: party_id, + to, + data, + }); + let json = serde_json::to_string(&env).unwrap(); + let mut tx = ws_tx_out.lock().await; + if tx.send(WsMessage::Text(json.into())).await.is_err() { + break; + } + } + }); + + // Incoming: WebSocket → RelayEnvelope::Mpc → Incoming + let phase_in = phase_str; + let ws_rx = Arc::new(Mutex::new(ws_rx)); + let ws_rx_in = ws_rx.clone(); + let cnt = counter; + tokio::spawn(async move { + use cggmp21::round_based::{Incoming, MessageType}; + + let mut rx = ws_rx_in.lock().await; + while let Some(item) = rx.next().await { + let raw = match item { + Ok(WsMessage::Text(t)) => t.into_bytes(), + Ok(WsMessage::Binary(d)) => d.to_vec(), + Ok(WsMessage::Close(_)) => { + let _ = incoming_tx.unbounded_send(Err(DeliveryError("closed".into()))); + break; + } + Err(e) => { + let _ = incoming_tx.unbounded_send(Err(DeliveryError(format!("{e}")))); + break; + } + _ => continue, + }; + + let env: RelayEnvelope = match serde_json::from_slice(&raw) { + Ok(e) => e, + Err(_) => continue, + }; + + if let RelayEnvelope::Mpc(relay_msg) = env { + if relay_msg.phase != phase_in { + continue; + } + if let Ok(msg) = serde_json::from_slice::(&relay_msg.data) { + let msg_type = if relay_msg.to.is_some() { + MessageType::P2P + } else { + MessageType::Broadcast + }; + let incoming = Incoming { + id: cnt.fetch_add(1, Ordering::Relaxed), + sender: relay_msg.from, + msg_type, + msg, + }; + if incoming_tx.unbounded_send(Ok(incoming)).is_err() { + break; + } + } + } + } + }); + + Ok((incoming_rx, outgoing_tx)) +} diff --git a/crates/saw-mpc/src/signing.rs b/crates/saw-mpc/src/signing.rs new file mode 100644 index 0000000..fd962bd --- /dev/null +++ b/crates/saw-mpc/src/signing.rs @@ -0,0 +1,182 @@ +//! Signing: presignature generation and online signing. +//! +//! Two-phase approach for minimum latency: +//! 1. Presign (background): 3 MPC rounds between t parties → presignature +//! 2. Sign (online): combine presignature + message hash → ECDSA signature +//! +//! SECURITY: Never reuse a presignature for two different messages! + +use rand_core::OsRng; + +use cggmp21::supported_curves::Secp256k1; +use cggmp21::{DataToSign, ExecutionId, PartialSignature}; + +use crate::error::MpcError; + +// Re-export types callers need +pub use cggmp21::Presignature; +pub use cggmp21::Signature as CggmpSignature; + +/// ECDSA signature with recovery id for Ethereum. +#[derive(Debug, Clone)] +pub struct EthSignature { + pub r: [u8; 32], + pub s: [u8; 32], + pub v: u8, +} + +impl EthSignature { + pub fn to_rsv(&self) -> [u8; 65] { + let mut out = [0u8; 65]; + out[..32].copy_from_slice(&self.r); + out[32..64].copy_from_slice(&self.s); + out[64] = self.v; + out + } + + pub fn to_hex(&self) -> String { + format!("0x{}", hex::encode(self.to_rsv())) + } +} + +/// Pool of ready-to-use presignatures for low-latency signing. +/// +/// Presignatures are indexed by a sequential `presig_index` so both +/// daemon and policy can agree on which presignature to consume. +pub struct PresignaturePool { + pool: std::collections::BTreeMap>, + /// Next index to assign when generating a new presignature + next_index: u64, + target_size: usize, + refill_threshold: usize, +} + +impl PresignaturePool { + pub fn new(target_size: usize, refill_threshold: usize) -> Self { + Self { + pool: std::collections::BTreeMap::new(), + next_index: 0, + target_size, + refill_threshold, + } + } + + /// Take a presignature by index (for coordinated consumption). + pub fn take(&mut self, index: u64) -> Option> { + self.pool.remove(&index) + } + + /// Take the lowest-indexed presignature available. + pub fn take_next(&mut self) -> Option<(u64, cggmp21::Presignature)> { + let index = *self.pool.keys().next()?; + self.pool.remove(&index).map(|p| (index, p)) + } + + pub fn available(&self) -> usize { + self.pool.len() + } + + pub fn needs_refill(&self) -> bool { + self.pool.len() < self.refill_threshold + } + + pub fn refill_count(&self) -> usize { + self.target_size.saturating_sub(self.pool.len()) + } + + /// Reserve the next presig_index (call before starting MPC generation). + pub fn reserve_index(&mut self) -> u64 { + let idx = self.next_index; + self.next_index += 1; + idx + } + + /// Store a generated presignature at the given index. + pub fn add(&mut self, index: u64, presig: cggmp21::Presignature) { + self.pool.insert(index, presig); + } +} + +/// Generate a presignature via MPC between t parties. +pub async fn generate_presignature( + eid: ExecutionId<'_>, + party_index_in_signing: u16, + parties_indexes_at_keygen: &[u16], + key_share: &cggmp21::KeyShare, + delivery: D, +) -> Result, MpcError> +where + D: cggmp21::round_based::Delivery< + cggmp21::signing::msg::Msg, + >, +{ + tracing::info!( + party_index_in_signing, + ?parties_indexes_at_keygen, + "starting presignature generation" + ); + + let party = cggmp21::round_based::MpcParty::connected(delivery); + + let presig = cggmp21::signing(eid, party_index_in_signing, parties_indexes_at_keygen, key_share) + .generate_presignature(&mut OsRng, party) + .await + .map_err(|e| MpcError::Presign(format!("{e:?}")))?; + + tracing::info!("presignature generation complete"); + Ok(presig) +} + +/// Issue a partial signature from a presignature (local, no network). +pub fn issue_partial_signature( + presig: cggmp21::Presignature, + message_hash: &[u8; 32], +) -> PartialSignature { + // Use raw message hash as scalar — EVM sighash is already Keccak256'd, + // we must NOT hash it again or the recovered address will be wrong. + let data = DataToSign::from_scalar(generic_ec::Scalar::from_be_bytes_mod_order(message_hash)); + presig.issue_partial_signature(data) +} + +/// Combine partial signatures into a complete ECDSA signature. +pub fn combine_partial_signatures( + partials: &[PartialSignature], +) -> Result, MpcError> { + PartialSignature::combine(partials) + .ok_or_else(|| MpcError::Signing("failed to combine partial signatures — possible cheating".into())) +} + +/// Full signing in one shot (all MPC rounds inline, no presignature). +pub async fn sign_full( + eid: ExecutionId<'_>, + party_index_in_signing: u16, + parties_indexes_at_keygen: &[u16], + key_share: &cggmp21::KeyShare, + message_hash: &[u8; 32], + delivery: D, +) -> Result, MpcError> +where + D: cggmp21::round_based::Delivery< + cggmp21::signing::msg::Msg, + >, +{ + tracing::info!( + party_index_in_signing, + ?parties_indexes_at_keygen, + "starting full signing" + ); + + // Use raw message hash as scalar — EVM sighash is already Keccak256'd, + // we must NOT hash it again or the recovered address will be wrong. + let data = DataToSign::from_scalar(generic_ec::Scalar::from_be_bytes_mod_order(message_hash)); + let party = cggmp21::round_based::MpcParty::connected(delivery); + + let sig = cggmp21::signing(eid, party_index_in_signing, parties_indexes_at_keygen, key_share) + .sign(&mut OsRng, party, data) + .await + .map_err(|e| MpcError::Signing(format!("{e:?}")))?; + + tracing::info!("full signing complete"); + Ok(sig) +} + diff --git a/crates/saw-mpc/src/transport.rs b/crates/saw-mpc/src/transport.rs new file mode 100644 index 0000000..2bab2ab --- /dev/null +++ b/crates/saw-mpc/src/transport.rs @@ -0,0 +1,383 @@ +//! Transport layer: provides `Delivery` implementations for cggmp21. +//! +//! - `in_memory_delivery`: for testing with multiple parties in one process +//! - `WsDelivery`: WebSocket-based delivery for production use +//! +//! The WebSocket transport works in two modes: +//! - **Client** (saw-daemon): connects to saw-policy's WS server +//! - **Server** (saw-policy): listens for incoming connections from saw-daemon + +use std::fmt; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +use futures::channel::mpsc; +use futures::stream::{SplitSink, SplitStream}; +use futures::{SinkExt, StreamExt}; +use cggmp21::round_based::{ + Incoming, MessageDestination, MessageType, Outgoing, +}; + +use serde::{de::DeserializeOwned, Serialize}; +use tokio::sync::Mutex; + +use crate::error::MpcError; +use crate::protocol::{MpcWireMessage, SessionId, WireMessage}; +use crate::types::PartyId; + +/// Error type for delivery. +#[derive(Debug)] +pub struct DeliveryError(pub String); + +impl fmt::Display for DeliveryError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "delivery error: {}", self.0) + } +} + +impl std::error::Error for DeliveryError {} + +// --------------------------------------------------------------------------- +// WebSocket transport +// --------------------------------------------------------------------------- + +use tokio_tungstenite::tungstenite::Message as WsMessage; + +/// A WebSocket connection that can send/receive `WireMessage`s and be +/// converted into a cggmp21-compatible `Delivery` for MPC protocol messages. +/// +/// Generic over the underlying stream type so it works with both: +/// - Client connections (`MaybeTlsStream`) +/// - Server-accepted connections (`TcpStream`) +pub struct WsConnection { + ws_tx: Arc, WsMessage>>>, + ws_rx: Arc>>>, + /// This party's index + pub party_id: PartyId, + /// Remote party's index + pub remote_party_id: PartyId, + /// Total number of parties in the protocol + pub num_parties: u16, +} + +impl WsConnection +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, +{ + /// Wrap an already-established WebSocket stream. + pub fn new( + ws_stream: tokio_tungstenite::WebSocketStream, + party_id: PartyId, + remote_party_id: PartyId, + num_parties: u16, + ) -> Self { + let (ws_tx, ws_rx) = ws_stream.split(); + Self { + ws_tx: Arc::new(Mutex::new(ws_tx)), + ws_rx: Arc::new(Mutex::new(ws_rx)), + party_id, + remote_party_id, + num_parties, + } + } + + /// Send a `WireMessage` over the WebSocket. + pub async fn send(&self, msg: &WireMessage) -> Result<(), MpcError> { + let data = serde_json::to_vec(msg).map_err(MpcError::Serde)?; + let mut tx = self.ws_tx.lock().await; + tx.send(WsMessage::Binary(data.into())) + .await + .map_err(|e| MpcError::Transport(format!("ws send: {e}")))?; + Ok(()) + } + + /// Receive the next `WireMessage` from the WebSocket. + /// Blocks until a message arrives or the connection closes. + pub async fn recv(&self) -> Result { + let mut rx = self.ws_rx.lock().await; + loop { + match rx.next().await { + Some(Ok(WsMessage::Binary(data))) => { + return serde_json::from_slice(&data).map_err(MpcError::Serde); + } + Some(Ok(WsMessage::Text(text))) => { + return serde_json::from_str(&text).map_err(MpcError::Serde); + } + Some(Ok(WsMessage::Ping(_) | WsMessage::Pong(_))) => continue, + Some(Ok(WsMessage::Close(_))) => { + return Err(MpcError::Transport("ws closed by remote".into())); + } + Some(Err(e)) => { + return Err(MpcError::Transport(format!("ws recv: {e}"))); + } + None => { + return Err(MpcError::Transport("ws stream ended".into())); + } + _ => continue, + } + } + } + + /// Convert this connection into a cggmp21-compatible `Delivery` for one MPC session. + /// + /// Returns `(incoming_rx, outgoing_tx)` — pass to `MpcParty::connected()`. + /// + /// **Consumes** the connection: the spawned tasks take ownership of the WS halves. + /// Non-MPC `WireMessage`s (Ping, Pong, SignRequest, PolicyDecision) are dropped + /// in this layer — handle those *before* calling `into_delivery`. + pub fn into_delivery( + self, + session_id: SessionId, + ) -> ( + mpsc::UnboundedReceiver, DeliveryError>>, + mpsc::UnboundedSender>, + ) + where + M: Serialize + DeserializeOwned + Clone + Send + 'static, + { + let (incoming_tx, incoming_rx) = mpsc::unbounded(); + let (outgoing_tx, outgoing_rx) = mpsc::unbounded::>(); + + let party_id = self.party_id; + let ws_tx = self.ws_tx; + let ws_rx = self.ws_rx; + let msg_counter = Arc::new(AtomicU64::new(0)); + + let sid_send = session_id.clone(); + let sid_recv = session_id; + + // Outgoing: MPC Outgoing → serialize → WireMessage::Mpc → WebSocket + tokio::spawn(async move { + let mut rx = outgoing_rx; + while let Some(outgoing) = rx.next().await { + let to = match outgoing.recipient { + MessageDestination::AllParties => None, + MessageDestination::OneParty(p) => Some(p), + }; + + let data = match serde_json::to_vec(&outgoing.msg) { + Ok(d) => d, + Err(e) => { + tracing::error!("serialize MPC msg: {e}"); + continue; + } + }; + + let wire = WireMessage::Mpc(MpcWireMessage { + session_id: sid_send.clone(), + from: party_id, + to, + data, + }); + + let json = match serde_json::to_vec(&wire) { + Ok(d) => d, + Err(e) => { + tracing::error!("serialize wire msg: {e}"); + continue; + } + }; + + let mut tx = ws_tx.lock().await; + if let Err(e) = tx.send(WsMessage::Binary(json.into())).await { + tracing::error!("ws send failed: {e}"); + break; + } + } + }); + + // Incoming: WebSocket → WireMessage::Mpc → deserialize → Incoming + let counter = msg_counter; + tokio::spawn(async move { + let mut rx = ws_rx.lock().await; + while let Some(item) = rx.next().await { + let raw = match item { + Ok(WsMessage::Binary(data)) => data.to_vec(), + Ok(WsMessage::Text(text)) => text.into_bytes(), + Ok(WsMessage::Ping(_) | WsMessage::Pong(_)) => continue, + Ok(WsMessage::Close(_)) => { + let _ = incoming_tx.unbounded_send(Err(DeliveryError( + "ws closed".into(), + ))); + break; + } + Err(e) => { + let _ = incoming_tx.unbounded_send(Err(DeliveryError( + format!("ws error: {e}"), + ))); + break; + } + _ => continue, + }; + + let wire: WireMessage = match serde_json::from_slice(&raw) { + Ok(w) => w, + Err(e) => { + tracing::warn!("malformed wire msg: {e}"); + continue; + } + }; + + if let WireMessage::Mpc(mpc_msg) = wire { + if mpc_msg.session_id != sid_recv { + continue; + } + + let msg: M = match serde_json::from_slice(&mpc_msg.data) { + Ok(m) => m, + Err(e) => { + tracing::warn!("deserialize MPC payload: {e}"); + continue; + } + }; + + let msg_type = if mpc_msg.to.is_some() { + MessageType::P2P + } else { + MessageType::Broadcast + }; + + let incoming = Incoming { + id: counter.fetch_add(1, Ordering::Relaxed), + sender: mpc_msg.from, + msg_type, + msg, + }; + + if incoming_tx.unbounded_send(Ok(incoming)).is_err() { + break; + } + } + // Non-MPC messages silently ignored in delivery layer + } + }); + + (incoming_rx, outgoing_tx) + } +} + +// --------------------------------------------------------------------------- +// Convenience constructors +// --------------------------------------------------------------------------- + +/// Client-side: connect to a WebSocket server. +pub async fn ws_connect( + url: &str, + party_id: PartyId, + remote_party_id: PartyId, + num_parties: u16, +) -> Result< + WsConnection>, + MpcError, +> { + tracing::info!(url, party_id, remote_party_id, "ws connecting"); + let (ws_stream, _) = tokio_tungstenite::connect_async(url) + .await + .map_err(|e| MpcError::Transport(format!("ws connect: {e}")))?; + Ok(WsConnection::new(ws_stream, party_id, remote_party_id, num_parties)) +} + +/// Server-side: accept a single WebSocket connection on a TCP listener. +pub async fn ws_accept( + listener: &tokio::net::TcpListener, + party_id: PartyId, + remote_party_id: PartyId, + num_parties: u16, +) -> Result, MpcError> { + tracing::info!(party_id, "waiting for incoming ws connection"); + let (tcp_stream, addr) = listener + .accept() + .await + .map_err(|e| MpcError::Transport(format!("tcp accept: {e}")))?; + tracing::info!(%addr, "accepted tcp connection, upgrading to ws"); + + let ws_stream = tokio_tungstenite::accept_async(tcp_stream) + .await + .map_err(|e| MpcError::Transport(format!("ws handshake: {e}")))?; + + Ok(WsConnection::new(ws_stream, party_id, remote_party_id, num_parties)) +} + +// --------------------------------------------------------------------------- +// In-memory delivery (testing) +// --------------------------------------------------------------------------- + +/// In-memory delivery for N parties in one process (testing). +/// +/// Returns a Vec of (Receiver, Sender) pairs — one per party — that +/// implement the `Delivery` trait expected by cggmp21. +pub fn in_memory_delivery( + n: u16, +) -> Vec<( + mpsc::UnboundedReceiver, DeliveryError>>, + mpsc::UnboundedSender>, +)> +where + M: Clone + Send + 'static, +{ + let msg_counter = Arc::new(AtomicU64::new(0)); + + let mut party_out_txs = Vec::with_capacity(n as usize); + let mut party_in_txs: Vec, DeliveryError>>> = + Vec::with_capacity(n as usize); + let mut party_in_rxs = Vec::with_capacity(n as usize); + + let mut out_rxs = Vec::with_capacity(n as usize); + + for _ in 0..n { + let (out_tx, out_rx) = mpsc::unbounded::>(); + let (in_tx, in_rx) = mpsc::unbounded::, DeliveryError>>(); + party_out_txs.push(out_tx); + out_rxs.push(out_rx); + party_in_txs.push(in_tx); + party_in_rxs.push(in_rx); + } + + let shared_in_txs = Arc::new(party_in_txs); + + for sender_idx in 0..n { + let mut rx = out_rxs.remove(0); + let txs = shared_in_txs.clone(); + let counter = msg_counter.clone(); + let n_parties = n; + + tokio::spawn(async move { + while let Some(outgoing) = rx.next().await { + let msg_id = counter.fetch_add(1, Ordering::Relaxed); + + match outgoing.recipient { + MessageDestination::AllParties => { + for r in 0..n_parties { + if r != sender_idx { + let incoming = Incoming { + id: msg_id, + sender: sender_idx, + msg_type: MessageType::Broadcast, + msg: outgoing.msg.clone(), + }; + let _ = txs[r as usize].unbounded_send(Ok(incoming)); + } + } + } + MessageDestination::OneParty(recipient) => { + if recipient < n_parties { + let incoming = Incoming { + id: msg_id, + sender: sender_idx, + msg_type: MessageType::P2P, + msg: outgoing.msg.clone(), + }; + let _ = txs[recipient as usize].unbounded_send(Ok(incoming)); + } + } + } + } + }); + } + + party_in_rxs + .into_iter() + .zip(party_out_txs) + .map(|(rx, tx)| (rx, tx)) + .collect() +} diff --git a/crates/saw-mpc/src/types.rs b/crates/saw-mpc/src/types.rs new file mode 100644 index 0000000..0448fb9 --- /dev/null +++ b/crates/saw-mpc/src/types.rs @@ -0,0 +1,68 @@ +use serde::{Deserialize, Serialize}; + +/// Party identifier in the threshold scheme. +/// 0 = saw-daemon, 1 = saw-policy, 2 = saw-cosigner (human) +pub type PartyId = u16; + +pub const PARTY_DAEMON: PartyId = 0; +pub const PARTY_POLICY: PartyId = 1; +pub const PARTY_COSIGNER: PartyId = 2; + +/// Configuration for a threshold signing setup. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ThresholdConfig { + /// This party's index (0, 1, or 2) + pub party_id: PartyId, + /// Threshold required to sign (default: 2) + pub threshold: u16, + /// Total number of parties (default: 3) + pub num_parties: u16, + /// Wallet name + pub wallet: String, + /// Chain type + pub chain: Chain, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Chain { + Evm, + Sol, +} + +impl ThresholdConfig { + /// Standard 2-of-3 config for a given party. + pub fn new_2of3(party_id: PartyId, wallet: &str, chain: Chain) -> Self { + Self { + party_id, + threshold: 2, + num_parties: 3, + wallet: wallet.to_string(), + chain, + } + } + + /// 2-of-2 config (no human recovery share). + pub fn new_2of2(party_id: PartyId, wallet: &str, chain: Chain) -> Self { + Self { + party_id, + threshold: 2, + num_parties: 2, + wallet: wallet.to_string(), + chain, + } + } +} + +/// Metadata stored alongside the serialized cggmp21 key share. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KeyShareData { + /// SAW threshold config + pub config: ThresholdConfig, + /// Ethereum address derived from the combined public key + pub address: String, + /// Hex-encoded combined public key + pub public_key: String, + /// Creation timestamp (unix seconds) + pub created_at: u64, +} diff --git a/crates/saw-mpc/tests/daemon_policy_flow.rs b/crates/saw-mpc/tests/daemon_policy_flow.rs new file mode 100644 index 0000000..21bec43 --- /dev/null +++ b/crates/saw-mpc/tests/daemon_policy_flow.rs @@ -0,0 +1,298 @@ +//! End-to-end integration test: saw-daemon ThresholdClient → saw-policy server. +//! +//! 1. Generate key shares (in-memory, 2-of-3) +//! 2. Start saw-policy server with share 1 + permissive policy +//! 3. ThresholdClient (share 0) sends sign request +//! 4. Policy approves → MPC signing → verified ECDSA signature + +// This test lives in saw-mpc to avoid circular deps, but tests the full flow +// by directly using saw_daemon::threshold and saw_policy server internals. +// In a real deploy, these are separate binaries on separate machines. + +// NOTE: This test cannot import saw-policy (binary crate) or saw-daemon (has +// Unix-specific deps). Instead we test the WebSocket transport + MPC flow +// end-to-end using just saw-mpc primitives — simulating what daemon and policy do. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +use futures::channel::mpsc; +use futures::{SinkExt, StreamExt}; +use tokio::sync::Mutex; +use tokio_tungstenite::tungstenite::Message as WsMessage; + +use cggmp21::round_based::{Incoming, MessageDestination, MessageType, Outgoing}; +use saw_mpc::error::MpcError; +use saw_mpc::keygen; +use saw_mpc::protocol::*; +use saw_mpc::signing; +use saw_mpc::transport::DeliveryError; +use saw_mpc::types::{PARTY_DAEMON, PARTY_POLICY}; +use saw_mpc::{KeyShare, Secp256k1}; + +type SignMsg = cggmp21::signing::msg::Msg; + +#[tokio::test] +async fn full_daemon_policy_flow() { + let _ = tracing_subscriber::fmt::try_init(); + + let n: u16 = 3; + let t: u16 = 2; + + // --- Keygen --- + let primes: Vec<_> = (0..n).map(|_| keygen::pregenerate_primes()).collect(); + + let aux_eid = cggmp21::ExecutionId::new(b"flow-aux"); + let aux_deliveries = saw_mpc::transport::in_memory_delivery(n); + let mut aux_handles = Vec::new(); + for (i, (delivery, prime)) in aux_deliveries.into_iter().zip(primes).enumerate() { + let eid = aux_eid.clone(); + aux_handles.push(tokio::spawn(async move { + keygen::generate_aux_info(eid, i as u16, n, prime, delivery).await + })); + } + let mut aux_infos = Vec::new(); + for h in aux_handles { + aux_infos.push(h.await.unwrap().unwrap()); + } + + let keygen_eid = cggmp21::ExecutionId::new(b"flow-keygen"); + let keygen_deliveries = saw_mpc::transport::in_memory_delivery(n); + let mut keygen_handles = Vec::new(); + for (i, delivery) in keygen_deliveries.into_iter().enumerate() { + let eid = keygen_eid.clone(); + keygen_handles.push(tokio::spawn(async move { + keygen::generate_key(eid, i as u16, n, t, delivery).await + })); + } + let mut key_shares = Vec::new(); + for h in keygen_handles { + key_shares.push(h.await.unwrap().unwrap()); + } + let mut complete_shares = Vec::new(); + for (inc, aux) in key_shares.into_iter().zip(aux_infos) { + complete_shares.push(keygen::complete_key_share(inc, aux).unwrap().key_share); + } + + let ks_daemon = complete_shares[PARTY_DAEMON as usize].clone(); + let ks_policy = complete_shares[PARTY_POLICY as usize].clone(); + + println!("Keygen done, testing full daemon→policy flow over WebSocket..."); + + // --- Set up WS server (policy side) --- + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let ws_url = format!("ws://{addr}"); + + let message_hash = [0xBEu8; 32]; + + // Policy server task + let hash_p = message_hash; + let policy_task = tokio::spawn(async move { + let (tcp, _) = listener.accept().await.unwrap(); + let ws = tokio_tungstenite::accept_async(tcp).await.unwrap(); + let (ws_tx, mut ws_rx) = ws.split(); + let ws_tx = Arc::new(Mutex::new(ws_tx)); + + // 1. Read SignRequest + let raw = loop { + if let Some(Ok(WsMessage::Binary(d))) = ws_rx.next().await { + break d.to_vec(); + } + }; + let wire: WireMessage = serde_json::from_slice(&raw).unwrap(); + let sign_req = match wire { + WireMessage::SignRequest(r) => r, + _ => panic!("expected SignRequest"), + }; + + // 2. Send Approve decision + let decision = PolicyDecision { + request_id: sign_req.request_id.clone(), + decision: Decision::Approve, + matched_rule: Some("test-allow-all".into()), + reason: None, + }; + let data = serde_json::to_vec(&WireMessage::PolicyDecision(decision)).unwrap(); + ws_tx.lock().await.send(WsMessage::Binary(data.into())).await.unwrap(); + + // 3. MPC signing + let (incoming_tx, incoming_rx) = mpsc::unbounded::, DeliveryError>>(); + let (outgoing_tx, mut outgoing_rx) = mpsc::unbounded::>(); + + let eid_bytes: Vec = format!("sign-{}", sign_req.request_id).into_bytes(); + let signers = vec![PARTY_DAEMON, PARTY_POLICY]; + let sid = sign_req.session_id.clone(); + + // Outgoing router + let ws_tx_c = ws_tx.clone(); + let sid_out = sid.clone(); + tokio::spawn(async move { + while let Some(out) = outgoing_rx.next().await { + let to = match out.recipient { + MessageDestination::AllParties => None, + MessageDestination::OneParty(p) => Some(p), + }; + let data = serde_json::to_vec(&out.msg).unwrap(); + let wire = WireMessage::Mpc(MpcWireMessage { + session_id: sid_out.clone(), + from: PARTY_POLICY, + to, + data, + }); + let json = serde_json::to_vec(&wire).unwrap(); + let mut tx = ws_tx_c.lock().await; + let _ = tx.send(WsMessage::Binary(json.into())).await; + } + }); + + // Sign task + let sign_task = tokio::spawn(async move { + let eid = cggmp21::ExecutionId::new(&eid_bytes); + signing::sign_full(eid, 1, &signers, &ks_policy, &hash_p, (incoming_rx, outgoing_tx)).await + }); + + // Incoming router + let counter = AtomicU64::new(0); + let sid_in = sid; + loop { + if sign_task.is_finished() { break; } + let msg = tokio::time::timeout(std::time::Duration::from_millis(50), ws_rx.next()).await; + match msg { + Ok(Some(Ok(WsMessage::Binary(d)))) => { + if let Ok(WireMessage::Mpc(m)) = serde_json::from_slice::(&d) { + if m.session_id == sid_in { + if let Ok(msg) = serde_json::from_slice::(&m.data) { + let mt = if m.to.is_some() { MessageType::P2P } else { MessageType::Broadcast }; + let _ = incoming_tx.unbounded_send(Ok(Incoming { + id: counter.fetch_add(1, Ordering::Relaxed), + sender: m.from, msg_type: mt, msg, + })); + } + } + } + } + _ => {} + } + } + + sign_task.await.unwrap().unwrap() + }); + + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + // --- Daemon client side --- + let hash_d = message_hash; + let daemon_task = tokio::spawn(async move { + let (ws, _) = tokio_tungstenite::connect_async(&ws_url).await.unwrap(); + let (ws_tx, mut ws_rx) = ws.split(); + let ws_tx = Arc::new(Mutex::new(ws_tx)); + + let request_id = "test-req-001".to_string(); + let session_id = SessionId::from_str("test-session-001"); + + // 1. Send SignRequest + let req = WireMessage::SignRequest(SignRequest { + request_id: request_id.clone(), + session_id: session_id.clone(), + wallet: "test-wallet".into(), + action: SignAction::EvmTx, + tx_details: TxDetails { + chain_id: Some(1), + to: Some("0x0000000000000000000000000000000000000001".into()), + value: Some("0".into()), + data_len: 0, + is_contract_call: false, + }, + message_hash: format!("0x{}", hex::encode(hash_d)), + }); + let data = serde_json::to_vec(&req).unwrap(); + ws_tx.lock().await.send(WsMessage::Binary(data.into())).await.unwrap(); + + // 2. Read PolicyDecision + let decision = loop { + if let Some(Ok(WsMessage::Binary(d))) = ws_rx.next().await { + if let Ok(WireMessage::PolicyDecision(dec)) = serde_json::from_slice(&d) { + break dec; + } + } + }; + assert_eq!(decision.decision, Decision::Approve); + println!("Policy approved! Starting MPC..."); + + // 3. MPC signing + let (incoming_tx, incoming_rx) = mpsc::unbounded::, DeliveryError>>(); + let (outgoing_tx, mut outgoing_rx) = mpsc::unbounded::>(); + + let eid_bytes: Vec = format!("sign-{request_id}").into_bytes(); + let signers = vec![PARTY_DAEMON, PARTY_POLICY]; + let sid = session_id.clone(); + + let ws_tx_c = ws_tx.clone(); + let sid_out = sid.clone(); + tokio::spawn(async move { + while let Some(out) = outgoing_rx.next().await { + let to = match out.recipient { + MessageDestination::AllParties => None, + MessageDestination::OneParty(p) => Some(p), + }; + let data = serde_json::to_vec(&out.msg).unwrap(); + let wire = WireMessage::Mpc(MpcWireMessage { + session_id: sid_out.clone(), + from: PARTY_DAEMON, + to, + data, + }); + let json = serde_json::to_vec(&wire).unwrap(); + let mut tx = ws_tx_c.lock().await; + let _ = tx.send(WsMessage::Binary(json.into())).await; + } + }); + + let sign_task = tokio::spawn(async move { + let eid = cggmp21::ExecutionId::new(&eid_bytes); + signing::sign_full(eid, 0, &signers, &ks_daemon, &hash_d, (incoming_rx, outgoing_tx)).await + }); + + let counter = AtomicU64::new(0); + let sid_in = sid; + loop { + if sign_task.is_finished() { break; } + let msg = tokio::time::timeout(std::time::Duration::from_millis(50), ws_rx.next()).await; + match msg { + Ok(Some(Ok(WsMessage::Binary(d)))) => { + if let Ok(WireMessage::Mpc(m)) = serde_json::from_slice::(&d) { + if m.session_id == sid_in { + if let Ok(msg) = serde_json::from_slice::(&m.data) { + let mt = if m.to.is_some() { MessageType::P2P } else { MessageType::Broadcast }; + let _ = incoming_tx.unbounded_send(Ok(Incoming { + id: counter.fetch_add(1, Ordering::Relaxed), + sender: m.from, msg_type: mt, msg, + })); + } + } + } + } + _ => {} + } + } + + sign_task.await.unwrap().unwrap() + }); + + let (sig_policy, sig_daemon) = tokio::join!(policy_task, daemon_task); + let sig_policy = sig_policy.unwrap(); + let sig_daemon = sig_daemon.unwrap(); + + assert_eq!(sig_policy, sig_daemon); + + // Verify + let data = cggmp21::DataToSign::from_scalar( + generic_ec::Scalar::from_be_bytes_mod_order(&message_hash), + ); + sig_daemon + .verify(&complete_shares[0].shared_public_key, &data) + .expect("verification failed"); + + println!("✓ Full daemon→policy signing flow verified!"); +} diff --git a/crates/saw-mpc/tests/keygen_and_sign.rs b/crates/saw-mpc/tests/keygen_and_sign.rs new file mode 100644 index 0000000..3d2ff44 --- /dev/null +++ b/crates/saw-mpc/tests/keygen_and_sign.rs @@ -0,0 +1,112 @@ +//! Integration test: run a full 2-of-3 keygen ceremony followed by +//! presignature generation and signing, all in-memory. + +use saw_mpc::keygen; +use saw_mpc::signing; +use saw_mpc::transport; + +#[tokio::test] +async fn keygen_2of3_and_sign() { + let _ = tracing_subscriber::fmt::try_init(); + + let n: u16 = 3; + let t: u16 = 2; + + // --- Phase 1: Aux info generation --- + // Each party needs Paillier primes (slow, do in parallel) + let primes: Vec<_> = (0..n).map(|_| keygen::pregenerate_primes()).collect(); + + let aux_eid = cggmp21::ExecutionId::new(b"test-aux-info-001"); + let aux_deliveries = transport::in_memory_delivery(n); + + let mut aux_handles = Vec::new(); + for (i, (delivery, prime)) in aux_deliveries.into_iter().zip(primes).enumerate() { + let eid = aux_eid.clone(); + aux_handles.push(tokio::spawn(async move { + keygen::generate_aux_info(eid, i as u16, n, prime, delivery).await + })); + } + + let mut aux_infos = Vec::new(); + for handle in aux_handles { + let aux = handle.await.unwrap().expect("aux info gen failed"); + aux_infos.push(aux); + } + + // --- Phase 2: Key generation --- + let keygen_eid = cggmp21::ExecutionId::new(b"test-keygen-001"); + let keygen_deliveries = transport::in_memory_delivery(n); + + let mut keygen_handles = Vec::new(); + for (i, delivery) in keygen_deliveries.into_iter().enumerate() { + let eid = keygen_eid.clone(); + keygen_handles.push(tokio::spawn(async move { + keygen::generate_key(eid, i as u16, n, t, delivery).await + })); + } + + let mut incomplete_shares = Vec::new(); + for handle in keygen_handles { + let share = handle.await.unwrap().expect("keygen failed"); + incomplete_shares.push(share); + } + + // --- Phase 3: Complete key shares --- + let mut key_shares = Vec::new(); + let mut address = String::new(); + for (incomplete, aux) in incomplete_shares.into_iter().zip(aux_infos) { + let output = keygen::complete_key_share(incomplete, aux) + .expect("complete key share failed"); + if address.is_empty() { + address = output.address.clone(); + } else { + // All parties should derive the same address + assert_eq!(address, output.address, "address mismatch between parties"); + } + key_shares.push(output.key_share); + } + + println!("Generated wallet address: {address}"); + assert!(address.starts_with("0x")); + assert_eq!(address.len(), 42); // 0x + 40 hex chars + + // --- Phase 4: Signing (parties 0 and 1, i.e., daemon + policy) --- + let message_hash = [0x42u8; 32]; // test message + + // For 2-of-3, parties 0 and 1 sign. Their indices in the signing + // group are 0 and 1, but their keygen indices are also 0 and 1. + let signers_at_keygen: Vec = vec![0, 1]; + let sign_eid = cggmp21::ExecutionId::new(b"test-sign-001"); + let sign_deliveries = transport::in_memory_delivery(t); + + let mut sign_handles = Vec::new(); + for (i, delivery) in sign_deliveries.into_iter().enumerate() { + let eid = sign_eid.clone(); + let ks = key_shares[i].clone(); + let signers = signers_at_keygen.clone(); + let hash = message_hash; + sign_handles.push(tokio::spawn(async move { + signing::sign_full(eid, i as u16, &signers, &ks, &hash, delivery).await + })); + } + + let mut signatures = Vec::new(); + for handle in sign_handles { + let sig = handle.await.unwrap().expect("signing failed"); + signatures.push(sig); + } + + // Both parties should produce the same signature + assert_eq!(signatures[0], signatures[1], "signatures should match"); + println!("Signature: r={:?}, s={:?}", signatures[0].r, signatures[0].s); + + // Verify the signature against the public key + let data = cggmp21::DataToSign::from_scalar( + generic_ec::Scalar::from_be_bytes_mod_order(&message_hash), + ); + signatures[0] + .verify(&key_shares[0].shared_public_key, &data) + .expect("signature verification failed"); + + println!("✓ Signature verified successfully!"); +} diff --git a/crates/saw-mpc/tests/keygen_ceremony.rs b/crates/saw-mpc/tests/keygen_ceremony.rs new file mode 100644 index 0000000..67f24d1 --- /dev/null +++ b/crates/saw-mpc/tests/keygen_ceremony.rs @@ -0,0 +1,116 @@ +//! Integration test: 3-party keygen ceremony via relay server. +//! +//! Starts a relay, connects 3 parties, runs the full ceremony: +//! aux info → keygen → complete → verify all derive same address. + +use saw_mpc::keygen; +use saw_mpc::relay; + +const N: u16 = 3; +const T: u16 = 2; + +#[tokio::test] +async fn keygen_ceremony_via_relay() { + let _ = tracing_subscriber::fmt::try_init(); + + // Generate primes + let primes: Vec<_> = (0..N).map(|_| keygen::pregenerate_primes()).collect(); + + // In-memory aux info + let aux_eid = cggmp21::ExecutionId::new(b"ceremony-aux"); + let aux_deliveries = saw_mpc::transport::in_memory_delivery(N); + let mut aux_handles = Vec::new(); + for (i, (delivery, prime)) in aux_deliveries.into_iter().zip(primes).enumerate() { + let eid = aux_eid.clone(); + aux_handles.push(tokio::spawn(async move { + keygen::generate_aux_info(eid, i as u16, N, prime, delivery).await + })); + } + let mut aux_infos = Vec::new(); + for h in aux_handles { + aux_infos.push(h.await.unwrap().unwrap()); + } + + // In-memory keygen + let keygen_eid = cggmp21::ExecutionId::new(b"ceremony-keygen"); + let keygen_deliveries = saw_mpc::transport::in_memory_delivery(N); + let mut keygen_handles = Vec::new(); + for (i, delivery) in keygen_deliveries.into_iter().enumerate() { + let eid = keygen_eid.clone(); + keygen_handles.push(tokio::spawn(async move { + keygen::generate_key(eid, i as u16, N, T, delivery).await + })); + } + let mut key_shares = Vec::new(); + for h in keygen_handles { + key_shares.push(h.await.unwrap().unwrap()); + } + let mut complete_shares = Vec::new(); + let mut address = String::new(); + for (inc, aux) in key_shares.into_iter().zip(aux_infos) { + let out = keygen::complete_key_share(inc, aux).unwrap(); + if address.is_empty() { + address = out.address.clone(); + } else { + assert_eq!(address, out.address, "address mismatch"); + } + complete_shares.push(out.key_share); + } + + println!("Keygen done: {address}"); + + // Now test relay routing with signing + // Start relay for 2 parties (signing is 2-of-3) + let listener2 = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr2 = listener2.local_addr().unwrap(); + let relay_url2 = format!("ws://{addr2}"); + let addr2_str = addr2.to_string(); + drop(listener2); // Release the port before run_relay binds it + + let relay_task = tokio::spawn(async move { + // Manual relay: accept 2, route messages + relay::run_relay(&addr2_str, 2).await + }); + + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + let message_hash = [0xCDu8; 32]; + let signers = vec![0u16, 1]; + + let url_0 = relay_url2.clone(); + let url_1 = relay_url2.clone(); + let ks_0 = complete_shares[0].clone(); + let ks_1 = complete_shares[1].clone(); + let hash = message_hash; + + let sign_0 = tokio::spawn(async move { + let delivery = relay::connect_to_relay(&url_0, 0, "sign").await.unwrap(); + let eid = cggmp21::ExecutionId::new(b"relay-sign-test"); + saw_mpc::signing::sign_full(eid, 0, &signers, &ks_0, &hash, delivery).await + }); + + let signers2 = vec![0u16, 1]; + let sign_1 = tokio::spawn(async move { + let delivery = relay::connect_to_relay(&url_1, 1, "sign").await.unwrap(); + let eid = cggmp21::ExecutionId::new(b"relay-sign-test"); + saw_mpc::signing::sign_full(eid, 1, &signers2, &ks_1, &hash, delivery).await + }); + + let (sig_0, sig_1) = tokio::join!(sign_0, sign_1); + let sig_0 = sig_0.unwrap().unwrap(); + let sig_1 = sig_1.unwrap().unwrap(); + + assert_eq!(sig_0, sig_1); + + // Verify + let data = cggmp21::DataToSign::from_scalar( + generic_ec::Scalar::from_be_bytes_mod_order(&message_hash), + ); + sig_0 + .verify(&complete_shares[0].shared_public_key, &data) + .expect("verification failed"); + + println!("✓ Relay-routed signing verified!"); + + relay_task.abort(); // Clean up +} diff --git a/crates/saw-mpc/tests/ws_transport.rs b/crates/saw-mpc/tests/ws_transport.rs new file mode 100644 index 0000000..6e8b7a0 --- /dev/null +++ b/crates/saw-mpc/tests/ws_transport.rs @@ -0,0 +1,137 @@ +//! Integration test: 2-party signing over WebSocket transport. +//! +//! Runs keygen in-memory (fast, already tested), then does signing +//! over a real WebSocket connection between two tasks. + +use saw_mpc::keygen; +use saw_mpc::signing; +use saw_mpc::transport; +use saw_mpc::protocol::SessionId; +use saw_mpc::types::{PARTY_DAEMON, PARTY_POLICY}; + +#[tokio::test] +async fn sign_over_websocket() { + let _ = tracing_subscriber::fmt::try_init(); + + let n: u16 = 3; + let t: u16 = 2; + + // --- Keygen in-memory (reuse proven path) --- + let primes: Vec<_> = (0..n).map(|_| keygen::pregenerate_primes()).collect(); + + let aux_eid = cggmp21::ExecutionId::new(b"ws-test-aux"); + let aux_deliveries = transport::in_memory_delivery(n); + let mut aux_handles = Vec::new(); + for (i, (delivery, prime)) in aux_deliveries.into_iter().zip(primes).enumerate() { + let eid = aux_eid.clone(); + aux_handles.push(tokio::spawn(async move { + keygen::generate_aux_info(eid, i as u16, n, prime, delivery).await + })); + } + let mut aux_infos = Vec::new(); + for h in aux_handles { + aux_infos.push(h.await.unwrap().expect("aux info failed")); + } + + let keygen_eid = cggmp21::ExecutionId::new(b"ws-test-keygen"); + let keygen_deliveries = transport::in_memory_delivery(n); + let mut keygen_handles = Vec::new(); + for (i, delivery) in keygen_deliveries.into_iter().enumerate() { + let eid = keygen_eid.clone(); + keygen_handles.push(tokio::spawn(async move { + keygen::generate_key(eid, i as u16, n, t, delivery).await + })); + } + let mut key_shares = Vec::new(); + for h in keygen_handles { + let incomplete = h.await.unwrap().expect("keygen failed"); + key_shares.push(incomplete); + } + let mut complete_shares = Vec::new(); + for (inc, aux) in key_shares.into_iter().zip(aux_infos) { + let out = keygen::complete_key_share(inc, aux).expect("complete failed"); + complete_shares.push(out.key_share); + } + + println!("Keygen done, starting WebSocket signing test..."); + + // --- Signing over WebSocket --- + let message_hash = [0xABu8; 32]; + let signers_at_keygen: Vec = vec![PARTY_DAEMON, PARTY_POLICY]; + let session_id = SessionId::random(); + + // Bind a TCP listener on a random port + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind failed"); + let addr = listener.local_addr().unwrap(); + let ws_url = format!("ws://{addr}"); + + let ks_daemon = complete_shares[PARTY_DAEMON as usize].clone(); + let ks_policy = complete_shares[PARTY_POLICY as usize].clone(); + let signers_d = signers_at_keygen.clone(); + let signers_p = signers_at_keygen.clone(); + let sid_d = session_id.clone(); + let sid_p = session_id.clone(); + let hash_d = message_hash; + let hash_p = message_hash; + + // Server side (saw-policy, party 1) + let server_handle = tokio::spawn(async move { + let conn = transport::ws_accept(&listener, PARTY_POLICY, PARTY_DAEMON, n) + .await + .expect("ws accept failed"); + + let delivery = conn.into_delivery(sid_p); + + signing::sign_full( + cggmp21::ExecutionId::new(b"ws-test-sign"), + 1, // party index in signing group + &signers_p, + &ks_policy, + &hash_p, + delivery, + ) + .await + .expect("policy signing failed") + }); + + // Small delay to let server bind + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + // Client side (saw-daemon, party 0) + let client_handle = tokio::spawn(async move { + let conn = transport::ws_connect(&ws_url, PARTY_DAEMON, PARTY_POLICY, n) + .await + .expect("ws connect failed"); + + let delivery = conn.into_delivery(sid_d); + + signing::sign_full( + cggmp21::ExecutionId::new(b"ws-test-sign"), + 0, // party index in signing group + &signers_d, + &ks_daemon, + &hash_d, + delivery, + ) + .await + .expect("daemon signing failed") + }); + + let (sig_policy, sig_daemon) = tokio::join!(server_handle, client_handle); + let sig_policy = sig_policy.unwrap(); + let sig_daemon = sig_daemon.unwrap(); + + assert_eq!(sig_policy, sig_daemon, "both parties should produce same signature"); + + // Verify + let data = cggmp21::DataToSign::from_scalar( + generic_ec::Scalar::from_be_bytes_mod_order(&message_hash), + ); + sig_daemon + .verify(&complete_shares[0].shared_public_key, &data) + .expect("signature verification failed"); + + println!("✓ WebSocket signing verified!"); +} diff --git a/crates/saw-policy/Cargo.toml b/crates/saw-policy/Cargo.toml new file mode 100644 index 0000000..e6faedd --- /dev/null +++ b/crates/saw-policy/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "saw-policy" +version = "0.1.0" +edition = "2021" +description = "Policy agent for SAW threshold signing — holds Share 2, evaluates rules, auto-cosigns" + +[dependencies] +saw-mpc = { path = "../saw-mpc" } +base64 = "0.22" +cggmp21 = { version = "0.6", features = ["hd-wallet", "curve-secp256k1"] } +futures = "0.3" +hex = "0.4" +sha2 = "0.10" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +tokio = { version = "1", features = ["full"] } +tokio-tungstenite = { version = "0.24", features = ["native-tls"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[[bin]] +name = "saw-policy" +path = "src/main.rs" diff --git a/crates/saw-policy/Dockerfile b/crates/saw-policy/Dockerfile new file mode 100644 index 0000000..eb2e212 --- /dev/null +++ b/crates/saw-policy/Dockerfile @@ -0,0 +1,34 @@ +# Multi-stage build for saw-policy +# Stage 1: Build +FROM rust:1.85 AS builder + +RUN apt-get update && apt-get install -y --no-install-recommends m4 pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* + +WORKDIR /build +COPY Cargo.toml Cargo.lock ./ +COPY crates/ crates/ + +# Build in release mode for fast Paillier/MPC ops +RUN cargo build --release -p saw-policy + +# Stage 2: Minimal runtime +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates libssl3 && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /build/target/release/saw-policy /usr/local/bin/saw-policy + +# Create data directory +RUN mkdir -p /data + +WORKDIR /data + +# Default env vars (override at deploy time) +ENV RUST_LOG=saw_policy=info +ENV SAW_ROOT=/data +ENV POLICY_PATH=/data/policy.yaml +ENV KEY_SHARE_PATH=/data/key_share.json + +EXPOSE 9443 + +CMD ["saw-policy"] diff --git a/crates/saw-policy/src/main.rs b/crates/saw-policy/src/main.rs new file mode 100644 index 0000000..fe4b53a --- /dev/null +++ b/crates/saw-policy/src/main.rs @@ -0,0 +1,158 @@ +//! saw-policy: Threshold signing policy agent (Share 2). +//! +//! Runs a WebSocket server that saw-daemon connects to. On each sign request: +//! 1. Evaluate policy rules +//! 2. Approve / Deny / Escalate +//! 3. If approved, participate in MPC signing as party 1 +//! +//! Configuration via CLI flags or environment variables: +//! --listen / PORT Listen address (default: 0.0.0.0:9443) +//! --config / POLICY_PATH Policy YAML file (default: policy.yaml) +//! --root / SAW_ROOT Data directory (default: ~/.saw-policy) +//! --share / KEY_SHARE_PATH Key share file (default: /key_share.json) +//! SAW_PASSPHRASE Passphrase to decrypt key share + +use std::path::{Path, PathBuf}; + +mod policy; +mod server; + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive("saw_policy=info".parse().unwrap()), + ) + .init(); + + let args: Vec = std::env::args().skip(1).collect(); + + if let Err(e) = run(args).await { + eprintln!("error: {e}"); + std::process::exit(2); + } +} + +async fn run(args: Vec) -> Result<(), String> { + let mut iter = args.iter(); + + // Defaults (overridden by CLI flags, then env vars) + let mut config_path: Option = None; + let mut listen: Option = None; + let mut root: Option = None; + let mut share_path: Option = None; + + while let Some(arg) = iter.next() { + match arg.as_str() { + "--help" | "-h" => { + eprintln!( + "saw-policy - Threshold signing policy agent\n\n\ + Usage: saw-policy [options]\n\n\ + Options:\n \ + --config Policy YAML (default: policy.yaml, env: POLICY_PATH)\n \ + --listen Listen address (default: 0.0.0.0:9443, env: PORT)\n \ + --root Data directory (default: ~/.saw-policy, env: SAW_ROOT)\n \ + --share Key share file (default: /key_share.json, env: KEY_SHARE_PATH)\n \ + --help Show this help\n\n\ + Environment:\n \ + SAW_PASSPHRASE Passphrase to decrypt encrypted key shares\n \ + PORT Listen port (Railway-style, sets 0.0.0.0:)\n" + ); + return Ok(()); + } + "--config" => { + config_path = Some(PathBuf::from(iter.next().ok_or("missing --config value")?)); + } + "--listen" => { + listen = Some(iter.next().ok_or("missing --listen value")?.clone()); + } + "--root" => { + root = Some(PathBuf::from(iter.next().ok_or("missing --root value")?)); + } + "--share" => { + share_path = Some(PathBuf::from(iter.next().ok_or("missing --share value")?)); + } + other => return Err(format!("unknown argument: {other}")), + } + } + + // Resolve with env var fallbacks + let listen = listen.unwrap_or_else(|| { + if let Ok(port) = std::env::var("PORT") { + format!("0.0.0.0:{port}") + } else { + "0.0.0.0:9443".to_string() + } + }); + + let config_path = config_path.unwrap_or_else(|| { + PathBuf::from(std::env::var("POLICY_PATH").unwrap_or_else(|_| "policy.yaml".into())) + }); + + let root = root.unwrap_or_else(|| { + if let Ok(r) = std::env::var("SAW_ROOT") { + PathBuf::from(r) + } else { + PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/opt/saw-policy".into())) + .join(".saw-policy") + } + }); + + let share_path = share_path.unwrap_or_else(|| { + if let Ok(p) = std::env::var("KEY_SHARE_PATH") { + PathBuf::from(p) + } else { + root.join("key_share.json") + } + }); + + // Load policy config + let policy_config = load_policy(&config_path)?; + tracing::info!(config = %config_path.display(), "loaded policy"); + + // Load key share (with optional decryption) + let key_share = load_key_share(&share_path)?; + tracing::info!(share = %share_path.display(), "loaded key share"); + + // Start server + tracing::info!(listen = %listen, "starting saw-policy server"); + server::run(&listen, key_share, policy_config) + .await + .map_err(|e| format!("server error: {e}")) +} + +fn load_policy(path: &Path) -> Result { + // Try POLICY_YAML env var first (inline config for containerized deployments) + let contents = if let Ok(yaml) = std::env::var("POLICY_YAML") { + tracing::info!("loading policy from POLICY_YAML env var"); + yaml + } else { + std::fs::read_to_string(path) + .map_err(|e| format!("read policy {}: {e}", path.display()))? + }; + serde_yaml::from_str(&contents).map_err(|e| format!("parse policy: {e}")) +} + +fn load_key_share(path: &Path) -> Result, String> { + // Try KEY_SHARE_BASE64 env var first (for containerized deployments) + let data = if let Ok(b64) = std::env::var("KEY_SHARE_BASE64") { + use base64::Engine; + tracing::info!("loading key share from KEY_SHARE_BASE64 env var"); + base64::engine::general_purpose::STANDARD + .decode(b64.trim()) + .map_err(|e| format!("decode KEY_SHARE_BASE64: {e}"))? + } else { + std::fs::read(path) + .map_err(|e| format!("read key share {}: {e}", path.display()))? + }; + + let passphrase = std::env::var("SAW_PASSPHRASE").unwrap_or_default(); + + if saw_mpc::encryption::is_encrypted(&data) && passphrase.is_empty() { + return Err("key share is encrypted but SAW_PASSPHRASE not set".into()); + } + + saw_mpc::keygen::deserialize_key_share_encrypted(&data, passphrase.as_bytes()) + .map_err(|e| format!("parse key share: {e}")) +} diff --git a/crates/saw-policy/src/policy.rs b/crates/saw-policy/src/policy.rs new file mode 100644 index 0000000..61603b1 --- /dev/null +++ b/crates/saw-policy/src/policy.rs @@ -0,0 +1,213 @@ +//! Policy engine: evaluates signing requests against configurable rules. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use saw_mpc::protocol::{Decision, PolicyDecision, SignRequest, TxDetails}; + +/// Top-level policy configuration, loaded from policy.yaml. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PolicyConfig { + #[serde(default = "default_version")] + pub version: u32, + #[serde(default)] + pub defaults: Defaults, + #[serde(default)] + pub wallets: HashMap, +} + +fn default_version() -> u32 { + 1 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Defaults { + #[serde(default = "default_action")] + pub action: String, +} + +impl Default for Defaults { + fn default() -> Self { + Self { + action: default_action(), + } + } +} + +fn default_action() -> String { + "escalate".to_string() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WalletPolicy { + pub chain: String, + #[serde(default)] + pub rules: Vec, + #[serde(default)] + pub circuit_breakers: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Rule { + pub name: String, + pub action: String, + #[serde(default)] + pub conditions: RuleConditions, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RuleConditions { + pub max_value_usd: Option, + pub allowed_chains: Option>, + pub allowed_contracts: Option>, + pub allowlist_recipients: Option>, + pub max_daily_spend_usd: Option, + pub max_per_minute: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CircuitBreaker { + pub name: String, + pub condition: String, + pub action: String, + #[serde(default)] + pub cooldown_hours: Option, +} + +/// Runtime state for policy evaluation (spend tracking, rate limits). +pub struct PolicyState { + /// Spend tracking per wallet: (amount_usd, timestamp) + daily_spend: HashMap>, + /// Rate tracking per wallet: timestamps of recent requests + rate_history: HashMap>, + /// Tripped circuit breakers: wallet → (breaker name, tripped at) + tripped_breakers: HashMap, +} + +impl PolicyState { + pub fn new() -> Self { + Self { + daily_spend: HashMap::new(), + rate_history: HashMap::new(), + tripped_breakers: HashMap::new(), + } + } +} + +/// Evaluate a signing request against the policy. +pub fn evaluate( + config: &PolicyConfig, + state: &mut PolicyState, + request: &SignRequest, +) -> PolicyDecision { + let wallet_policy = match config.wallets.get(&request.wallet) { + Some(wp) => wp, + None => { + return PolicyDecision { + request_id: request.request_id.clone(), + decision: parse_decision(&config.defaults.action), + matched_rule: None, + reason: Some("wallet not in policy".into()), + }; + } + }; + + // Check circuit breakers first + if let Some((breaker_name, _)) = state.tripped_breakers.get(&request.wallet) { + return PolicyDecision { + request_id: request.request_id.clone(), + decision: Decision::Deny, + matched_rule: Some(breaker_name.clone()), + reason: Some("circuit breaker tripped".into()), + }; + } + + // Evaluate rules top-to-bottom, first match wins + for rule in &wallet_policy.rules { + if matches_rule(rule, &request.tx_details, state, &request.wallet) { + return PolicyDecision { + request_id: request.request_id.clone(), + decision: parse_decision(&rule.action), + matched_rule: Some(rule.name.clone()), + reason: None, + }; + } + } + + // No rule matched — use default + PolicyDecision { + request_id: request.request_id.clone(), + decision: parse_decision(&config.defaults.action), + matched_rule: None, + reason: Some("no matching rule".into()), + } +} + +fn matches_rule( + rule: &Rule, + tx: &TxDetails, + state: &PolicyState, + wallet: &str, +) -> bool { + // Check chain allowlist + if let Some(allowed_chains) = &rule.conditions.allowed_chains { + if let Some(chain_id) = tx.chain_id { + if !allowed_chains.contains(&chain_id) { + return false; + } + } else { + return false; + } + } + + // Check recipient allowlist + if let Some(allowlist) = &rule.conditions.allowlist_recipients { + if let Some(to) = &tx.to { + let to_lower = to.to_lowercase(); + if !allowlist.iter().any(|a| a.to_lowercase() == to_lower) { + return false; + } + } else { + return false; + } + } + + // Check contract allowlist + if let Some(allowed_contracts) = &rule.conditions.allowed_contracts { + if let Some(to) = &tx.to { + let to_lower = to.to_lowercase(); + if !allowed_contracts.iter().any(|a| a.to_lowercase() == to_lower) { + return false; + } + } else { + return false; + } + } + + // Deny if unimplemented spend-limit conditions are set, rather than + // silently matching all transactions. + if rule.conditions.max_value_usd.is_some() { + tracing::warn!("max_value_usd condition is not yet implemented — denying to be safe"); + return false; + } + if rule.conditions.max_daily_spend_usd.is_some() { + tracing::warn!("max_daily_spend_usd condition is not yet implemented — denying to be safe"); + return false; + } + if rule.conditions.max_per_minute.is_some() { + tracing::warn!("max_per_minute condition is not yet implemented — denying to be safe"); + return false; + } + + // If we got here, all specified conditions are met + true +} + +fn parse_decision(action: &str) -> Decision { + match action { + "approve" => Decision::Approve, + "deny" => Decision::Deny, + _ => Decision::Escalate, + } +} diff --git a/crates/saw-policy/src/server.rs b/crates/saw-policy/src/server.rs new file mode 100644 index 0000000..1f32a0a --- /dev/null +++ b/crates/saw-policy/src/server.rs @@ -0,0 +1,547 @@ +//! WebSocket server: accepts connections from saw-daemon, handles sign requests. +//! +//! Protocol flow per sign request: +//! 1. saw-daemon sends WireMessage::SignRequest +//! 2. saw-policy evaluates policy → sends WireMessage::PolicyDecision +//! 3. If approved, both sides exchange WireMessage::Mpc messages (MPC signing) +//! 4. Connection stays open for subsequent requests + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +use futures::channel::mpsc; +use futures::{SinkExt, StreamExt}; +use tokio::net::TcpListener; +use tokio::sync::Mutex; +use tokio_tungstenite::tungstenite::Message as WsMessage; + +use cggmp21::round_based::{Incoming, MessageDestination, MessageType, Outgoing}; +use saw_mpc::error::MpcError; +use saw_mpc::protocol::{Decision, MpcWireMessage, SessionId, WireMessage}; +use saw_mpc::signing::{self, PresignaturePool}; +use saw_mpc::transport::DeliveryError; +use saw_mpc::types::{PARTY_DAEMON, PARTY_POLICY}; +use saw_mpc::{KeyShare, Secp256k1}; + +use crate::policy::{self, PolicyConfig, PolicyState}; + +/// Run the saw-policy WebSocket server. +pub async fn run( + listen_addr: &str, + key_share: KeyShare, + policy_config: PolicyConfig, +) -> Result<(), MpcError> { + let listener = TcpListener::bind(listen_addr) + .await + .map_err(|e| MpcError::Transport(format!("bind {listen_addr}: {e}")))?; + + tracing::info!(listen = %listen_addr, "saw-policy server listening"); + + let key_share = Arc::new(key_share); + let policy_config = Arc::new(policy_config); + + loop { + let (tcp_stream, addr) = listener + .accept() + .await + .map_err(|e| MpcError::Transport(format!("accept: {e}")))?; + + tracing::info!(%addr, "daemon connected"); + + let ks = key_share.clone(); + let pc = policy_config.clone(); + + tokio::spawn(async move { + if let Err(e) = handle_connection(tcp_stream, ks, pc).await { + tracing::error!(%addr, error = %e, "connection handler failed"); + } + tracing::info!(%addr, "daemon disconnected"); + }); + } +} + +/// Handle a single persistent WebSocket connection from saw-daemon. +async fn handle_connection( + tcp_stream: tokio::net::TcpStream, + key_share: Arc>, + policy_config: Arc, +) -> Result<(), MpcError> { + let ws_stream = tokio_tungstenite::accept_async(tcp_stream) + .await + .map_err(|e| MpcError::Transport(format!("ws handshake: {e}")))?; + + let (ws_tx, mut ws_rx) = ws_stream.split(); + let ws_tx = Arc::new(Mutex::new(ws_tx)); + let mut policy_state = PolicyState::new(); + let mut presig_pool = PresignaturePool::new(10, 2); + + loop { + let wire = match read_next_wire(&mut ws_rx).await { + Ok(msg) => msg, + Err(_) => break, + }; + + match wire { + // ---- Presignature generation (background) ---- + WireMessage::PresignRequest(presign_req) => { + let presig_index = presign_req.presig_index; + tracing::info!(presig_index, "presignature generation requested"); + + type SignMsg = cggmp21::signing::msg::Msg; + let (incoming_tx, incoming_rx) = + mpsc::unbounded::, DeliveryError>>(); + let (outgoing_tx, mut outgoing_rx) = + mpsc::unbounded::>(); + + let eid_bytes: Vec = format!("presign-{presig_index}").into_bytes(); + let signers = vec![PARTY_DAEMON, PARTY_POLICY]; + let sid = presign_req.session_id.clone(); + + // Outgoing: MPC → WS + let ws_tx_c = ws_tx.clone(); + let sid_out = sid.clone(); + let out_task = tokio::spawn(async move { + while let Some(outgoing) = outgoing_rx.next().await { + let to = match outgoing.recipient { + MessageDestination::AllParties => None, + MessageDestination::OneParty(p) => Some(p), + }; + let data = match serde_json::to_vec(&outgoing.msg) { + Ok(d) => d, + Err(_) => continue, + }; + let wire = WireMessage::Mpc(MpcWireMessage { + session_id: sid_out.clone(), + from: PARTY_POLICY, + to, + data, + }); + let json = match serde_json::to_vec(&wire) { + Ok(d) => d, + Err(_) => continue, + }; + let mut tx = ws_tx_c.lock().await; + if tx.send(WsMessage::Binary(json.into())).await.is_err() { + break; + } + } + }); + + // Presign task + let ks = key_share.clone(); + let presign_task = tokio::spawn(async move { + let eid = cggmp21::ExecutionId::new(&eid_bytes); + signing::generate_presignature(eid, 1, &signers, &ks, (incoming_rx, outgoing_tx)) + .await + }); + + // Feed MPC messages + let counter = AtomicU64::new(0); + let sid_in = sid; + + loop { + if presign_task.is_finished() { + break; + } + let msg = tokio::time::timeout( + std::time::Duration::from_millis(50), + ws_rx.next(), + ) + .await; + + let item = match msg { + Ok(Some(item)) => item, + Ok(None) => { + let _ = incoming_tx.unbounded_send(Err(DeliveryError("ws ended".into()))); + break; + } + Err(_timeout) => continue, + }; + + let raw = match item { + Ok(WsMessage::Binary(d)) => d.to_vec(), + Ok(WsMessage::Text(t)) => t.into_bytes(), + Ok(WsMessage::Ping(d)) => { + let mut tx = ws_tx.lock().await; + let _ = tx.send(WsMessage::Pong(d)).await; + continue; + } + Ok(WsMessage::Pong(_)) => continue, + Ok(WsMessage::Close(_)) => { + let _ = incoming_tx.unbounded_send(Err(DeliveryError("ws closed".into()))); + break; + } + Err(e) => { + let _ = incoming_tx.unbounded_send(Err(DeliveryError(format!("{e}")))); + break; + } + _ => continue, + }; + + if let Ok(WireMessage::Mpc(mpc_msg)) = serde_json::from_slice::(&raw) { + if mpc_msg.session_id == sid_in { + if let Ok(msg) = serde_json::from_slice(&mpc_msg.data) { + let msg_type = if mpc_msg.to.is_some() { + MessageType::P2P + } else { + MessageType::Broadcast + }; + let incoming = Incoming { + id: counter.fetch_add(1, Ordering::Relaxed), + sender: mpc_msg.from, + msg_type, + msg, + }; + if incoming_tx.unbounded_send(Ok(incoming)).is_err() { + break; + } + } + } + } + } + + match presign_task.await { + Ok(Ok(presignature)) => { + presig_pool.add(presig_index, presignature); + tracing::info!(presig_index, avail = presig_pool.available(), "presignature stored"); + let ready = WireMessage::PresignReady(saw_mpc::protocol::PresignReady { + presig_index, + }); + let _ = send_wire(&ws_tx, &ready).await; + } + Ok(Err(e)) => tracing::error!(presig_index, error = %e, "presign MPC failed"), + Err(e) => tracing::error!(presig_index, error = %e, "presign task panic"), + } + out_task.abort(); + } + + // ---- Fast path: partial signature exchange ---- + WireMessage::PartialSignRequest(partial_req) => { + tracing::info!( + request_id = %partial_req.request_id, + presig_index = partial_req.presig_index, + "partial sign request (fast path)" + ); + + // Build a SignRequest for policy evaluation + let sign_req = saw_mpc::protocol::SignRequest { + request_id: partial_req.request_id.clone(), + session_id: SessionId::random(), + wallet: partial_req.wallet.clone(), + action: partial_req.action.clone(), + tx_details: partial_req.tx_details.clone(), + message_hash: partial_req.message_hash.clone(), + }; + + let decision = policy::evaluate(&policy_config, &mut policy_state, &sign_req); + + if decision.decision != Decision::Approve { + let resp = WireMessage::PartialSignResponse(saw_mpc::protocol::PartialSignResponse { + request_id: partial_req.request_id, + decision: decision.decision, + matched_rule: decision.matched_rule, + reason: decision.reason, + partial_signature: None, + }); + let _ = send_wire(&ws_tx, &resp).await; + continue; + } + + // Take presignature from pool + let presig = presig_pool.take(partial_req.presig_index); + if presig.is_none() { + tracing::error!(presig_index = partial_req.presig_index, "presignature not found"); + let resp = WireMessage::PartialSignResponse(saw_mpc::protocol::PartialSignResponse { + request_id: partial_req.request_id, + decision: Decision::Deny, + matched_rule: None, + reason: Some("presignature not found — index mismatch".into()), + partial_signature: None, + }); + let _ = send_wire(&ws_tx, &resp).await; + continue; + } + + // Parse message hash + let hash_hex = partial_req.message_hash.trim_start_matches("0x"); + let hash_bytes = match hex::decode(hash_hex) { + Ok(b) if b.len() == 32 => { + let mut h = [0u8; 32]; + h.copy_from_slice(&b); + h + } + _ => { + let resp = WireMessage::PartialSignResponse(saw_mpc::protocol::PartialSignResponse { + request_id: partial_req.request_id, + decision: Decision::Deny, + matched_rule: None, + reason: Some("invalid message hash".into()), + partial_signature: None, + }); + let _ = send_wire(&ws_tx, &resp).await; + continue; + } + }; + + // Issue partial signature locally + let partial = signing::issue_partial_signature(presig.unwrap(), &hash_bytes); + let partial_bytes = match serde_json::to_vec(&partial) { + Ok(b) => b, + Err(e) => { + tracing::error!(error = %e, "failed to serialize partial signature"); + continue; + } + }; + + let resp = WireMessage::PartialSignResponse(saw_mpc::protocol::PartialSignResponse { + request_id: partial_req.request_id, + decision: Decision::Approve, + matched_rule: decision.matched_rule, + reason: None, + partial_signature: Some(partial_bytes), + }); + send_wire(&ws_tx, &resp).await?; + tracing::info!("partial signature sent (fast path complete)"); + } + + // ---- Full MPC signing (slow path) ---- + WireMessage::SignRequest(sign_req) => { + tracing::info!( + request_id = %sign_req.request_id, + wallet = %sign_req.wallet, + "evaluating sign request" + ); + + let decision = + policy::evaluate(&policy_config, &mut policy_state, &sign_req); + + tracing::info!( + request_id = %sign_req.request_id, + decision = ?decision.decision, + "policy decision" + ); + + send_wire(&ws_tx, &WireMessage::PolicyDecision(decision.clone())).await?; + + if decision.decision != Decision::Approve { + continue; + } + + // Parse message hash + let hash_hex = sign_req.message_hash.trim_start_matches("0x"); + let hash_bytes = match hex::decode(hash_hex) { + Ok(b) if b.len() == 32 => b, + _ => { + tracing::error!("invalid message hash"); + let resp = WireMessage::PolicyDecision(saw_mpc::protocol::PolicyDecision { + request_id: sign_req.request_id.clone(), + decision: Decision::Deny, + matched_rule: None, + reason: Some("invalid message hash".into()), + }); + let _ = send_wire(&ws_tx, &resp).await; + continue; + } + }; + let mut hash = [0u8; 32]; + hash.copy_from_slice(&hash_bytes); + + // MPC signing — build channel-based delivery + // The signing message type for cggmp21 + type SignMsg = cggmp21::signing::msg::Msg; + let (incoming_tx, incoming_rx) = + mpsc::unbounded::, DeliveryError>>(); + let (outgoing_tx, mut outgoing_rx) = + mpsc::unbounded::>(); + + let eid_bytes: Vec = format!("sign-{}", sign_req.request_id).into_bytes(); + let signers = vec![PARTY_DAEMON, PARTY_POLICY]; + let sid = sign_req.session_id.clone(); + + // Outgoing task: MPC → WS + let ws_tx_c = ws_tx.clone(); + let sid_out = sid.clone(); + let out_task = tokio::spawn(async move { + while let Some(outgoing) = outgoing_rx.next().await { + let to = match outgoing.recipient { + MessageDestination::AllParties => None, + MessageDestination::OneParty(p) => Some(p), + }; + let data = match serde_json::to_vec(&outgoing.msg) { + Ok(d) => d, + Err(_) => continue, + }; + let wire = WireMessage::Mpc(MpcWireMessage { + session_id: sid_out.clone(), + from: PARTY_POLICY, + to, + data, + }); + let json = match serde_json::to_vec(&wire) { + Ok(d) => d, + Err(_) => continue, + }; + let mut tx = ws_tx_c.lock().await; + if tx.send(WsMessage::Binary(json.into())).await.is_err() { + break; + } + } + }); + + // Sign task + let ks = key_share.clone(); + let sign_task = tokio::spawn(async move { + let eid = cggmp21::ExecutionId::new(&eid_bytes); + signing::sign_full(eid, 1, &signers, &ks, &hash, (incoming_rx, outgoing_tx)) + .await + }); + + // Feed MPC messages from WS → incoming channel until sign completes + let counter = AtomicU64::new(0); + let sid_in = sid; + + loop { + if sign_task.is_finished() { + break; + } + + // Use a timeout so we periodically check if sign_task finished + let msg = tokio::time::timeout( + std::time::Duration::from_millis(50), + ws_rx.next(), + ) + .await; + + let item = match msg { + Ok(Some(item)) => item, + Ok(None) => { + let _ = incoming_tx.unbounded_send(Err(DeliveryError( + "ws ended".into(), + ))); + break; + } + Err(_timeout) => continue, + }; + + let raw = match item { + Ok(WsMessage::Binary(d)) => d.to_vec(), + Ok(WsMessage::Text(t)) => t.into_bytes(), + Ok(WsMessage::Ping(d)) => { + let mut tx = ws_tx.lock().await; + let _ = tx.send(WsMessage::Pong(d)).await; + continue; + } + Ok(WsMessage::Pong(_)) => continue, + Ok(WsMessage::Close(_)) => { + let _ = incoming_tx.unbounded_send(Err(DeliveryError( + "ws closed".into(), + ))); + break; + } + Err(e) => { + let _ = incoming_tx.unbounded_send(Err(DeliveryError( + format!("{e}"), + ))); + break; + } + _ => continue, + }; + + if let Ok(WireMessage::Mpc(mpc_msg)) = serde_json::from_slice::(&raw) { + if mpc_msg.session_id == sid_in { + if let Ok(msg) = serde_json::from_slice(&mpc_msg.data) { + let msg_type = if mpc_msg.to.is_some() { + MessageType::P2P + } else { + MessageType::Broadcast + }; + let incoming = Incoming { + id: counter.fetch_add(1, Ordering::Relaxed), + sender: mpc_msg.from, + msg_type, + msg, + }; + if incoming_tx.unbounded_send(Ok(incoming)).is_err() { + break; + } + } + } + } + } + + // Collect result + let result = sign_task + .await + .map_err(|e| MpcError::Signing(format!("task panic: {e}")))?; + out_task.abort(); + + let (success, error) = match result { + Ok(_) => { + tracing::info!(request_id = %sign_req.request_id, "signing ok"); + (true, None) + } + Err(ref e) => { + tracing::error!(request_id = %sign_req.request_id, error = %e, "signing failed"); + (false, Some(e.to_string())) + } + }; + + let complete = WireMessage::SigningComplete(saw_mpc::protocol::SigningComplete { + request_id: sign_req.request_id.clone(), + success, + error, + }); + send_wire(&ws_tx, &complete).await?; + } + WireMessage::Ping => { + send_wire(&ws_tx, &WireMessage::Pong).await?; + } + _ => {} + } + } + + Ok(()) +} + +// Helpers + +async fn read_next_wire( + rx: &mut futures::stream::SplitStream>, +) -> Result +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, +{ + loop { + match rx.next().await { + Some(Ok(WsMessage::Binary(d))) => { + return serde_json::from_slice(&d).map_err(MpcError::Serde); + } + Some(Ok(WsMessage::Text(t))) => { + return serde_json::from_str(&t).map_err(MpcError::Serde); + } + Some(Ok(WsMessage::Ping(_) | WsMessage::Pong(_))) => continue, + Some(Ok(WsMessage::Close(_))) => { + return Err(MpcError::Transport("closed".into())); + } + Some(Err(e)) => return Err(MpcError::Transport(format!("{e}"))), + None => return Err(MpcError::Transport("ended".into())), + _ => continue, + } + } +} + +async fn send_wire( + tx: &Arc, WsMessage>>>, + msg: &WireMessage, +) -> Result<(), MpcError> +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, +{ + let data = serde_json::to_vec(msg).map_err(MpcError::Serde)?; + let mut guard = tx.lock().await; + guard + .send(WsMessage::Binary(data.into())) + .await + .map_err(|e| MpcError::Transport(format!("{e}")))?; + Ok(()) +} diff --git a/docker/recovery/.gitignore b/docker/recovery/.gitignore new file mode 100644 index 0000000..1decca8 --- /dev/null +++ b/docker/recovery/.gitignore @@ -0,0 +1 @@ +party2.json.enc diff --git a/docker/recovery/Dockerfile b/docker/recovery/Dockerfile new file mode 100644 index 0000000..706738e --- /dev/null +++ b/docker/recovery/Dockerfile @@ -0,0 +1,33 @@ +# SAW Recovery Co-signer (Party 2) +# Runs saw-policy with the recovery key share. +# Used when party 0 (daemon) or party 1 (primary policy) is lost. +# +# Usage: +# docker build -t saw-recovery . +# docker run -p 8080:8080 \ +# -e SAW_PASSPHRASE="your-passphrase" \ +# -e KEY_SHARE_BASE64="" \ +# -e POLICY_YAML="" \ +# saw-recovery + +FROM rust:1.85-bookworm AS builder + +WORKDIR /build +COPY . . + +RUN apt-get update && apt-get install -y --no-install-recommends m4 pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* +RUN cargo build --release -p saw-policy + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates libssl3 && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /build/target/release/saw-policy /usr/local/bin/saw-policy + +RUN mkdir -p /data +WORKDIR /data + +RUN useradd -r -s /bin/false saw +USER saw + +EXPOSE 8080 +CMD ["saw-policy"] diff --git a/docker/recovery/README.md b/docker/recovery/README.md new file mode 100644 index 0000000..4d62cff --- /dev/null +++ b/docker/recovery/README.md @@ -0,0 +1,36 @@ +# SAW Recovery Co-signer (Party 2) + +Runs `saw-policy` with the recovery key share. Use when party 0 (daemon) or party 1 (primary policy) is lost and you need to sign with the surviving party. + +## Quick start + +```bash +# Build from repo root +docker build -f docker/recovery/Dockerfile -t saw-recovery . + +# Run (macOS) +docker run -p 9443:9443 \ + -e SAW_PASSPHRASE="your-passphrase" \ + -e KEY_SHARE_BASE64="$(base64 -i ~/saw-recovery/party2.json.enc)" \ + saw-recovery + +# Run (Linux) +docker run -p 9443:9443 \ + -e SAW_PASSPHRASE="your-passphrase" \ + -e KEY_SHARE_BASE64="$(base64 -w0 ~/saw-recovery/party2.json.enc)" \ + saw-recovery +``` + +## Recovery procedure + +1. Start this container +2. Point the surviving party's config at `wss://your-machine:9443` +3. Sign a transaction to transfer funds to a new wallet +4. Generate new key shares for the new wallet +5. Destroy this container + +## Security + +- Store `party2.json.enc` and `SAW_PASSPHRASE` in separate locations +- Only run this container during recovery — not 24/7 +- After recovery, rotate to fresh key shares diff --git a/docs/threat-model-and-design-rationale.md b/docs/threat-model-and-design-rationale.md new file mode 100644 index 0000000..5ad737f --- /dev/null +++ b/docs/threat-model-and-design-rationale.md @@ -0,0 +1,65 @@ +# Threat Model & Design Rationale + +**Date:** 2026-02-15 +**Authors:** Slyme + Modus + +## Why Threshold Signing? + +A natural question: if an attacker gets root on the machine running saw-daemon, they can initiate transactions through the normal signing API — so what does threshold MPC actually buy you? + +### What root access gives an attacker + +With root on the agent machine, an attacker **can**: +- Call saw-daemon's signing API directly +- Initiate any transaction through the normal flow +- Read environment variables, memory, disk + +With threshold signing, an attacker **cannot**: +- Extract the full private key (only a key share exists on each machine) +- Use the key offline or on another system +- Sign without the policy co-signer approving + +### The key insight + +**Threshold MPC protects against key exfiltration. The policy co-signer protects against unauthorized signing.** These are complementary — you need both for full security. + +Without MPC, even with a policy co-signer, an attacker who compromises the agent machine gets the full private key. They can: +- Exfiltrate it and use it later, even after you've patched the breach +- Use it from any machine, bypassing the policy signer entirely +- Drain funds at their leisure, long after the initial compromise + +With MPC, a compromise of one machine is recoverable — rotate the shares via a new keygen ceremony and the stolen share becomes useless. + +### Why not just use an on-chain multisig? + +On-chain multisig (e.g., Safe) provides similar co-signing guarantees and is battle-tested. We considered this. The tradeoffs: + +| | Threshold ECDSA | On-chain Multisig | +|---|---|---| +| On-chain appearance | Normal EOA | Contract wallet | +| Gas overhead | None | Higher (multi-sig tx) | +| Chain support | Any EVM chain | Needs deployed contracts | +| Complexity | High (MPC protocol) | Low (well-known pattern) | +| Key exfiltration protection | ✅ PK never exists | ❌ Each signer holds full key | +| Ecosystem compatibility | Universal (looks like EOA) | Some protocols don't support contract wallets | + +For a framework serving other developers' agents — potentially holding unknown amounts of value — the stronger guarantee of "private key never exists in any single location" justifies the added complexity. An accidental git push, a log leak, or a memory dump can never expose a key that doesn't exist. + +### Why we kept all three deployment modes + +Not every agent needs the same security posture. A bot managing $10 in gas money has different needs than one managing a treasury. + +1. **Single-key (default)** — Zero additional infrastructure. Works today. Appropriate for low-value wallets, development, and agents that don't handle significant funds. + +2. **Self-hosted threshold** — Run your own policy signer on a separate machine. Full control, no third-party trust. Appropriate for teams who can manage infrastructure and want strong key protection. + +3. **TEE/premium threshold** — Managed co-signing service running in a Trusted Execution Environment. Appropriate for agents handling real money where operators want professional-grade security without running their own infrastructure. + +The framework defaults to single-key so there's zero friction to get started. Upgrading to threshold is a configuration change — the client SDK API is identical across all modes. + +### Accepted risks + +- **Root compromise + active policy signer**: An attacker with root on the agent machine can sign transactions as long as they pass policy rules and the policy signer is reachable. Mitigation: strict policy rules (allowlists, rate limits, circuit breakers). +- **Policy signer downtime**: Signing fails fast (5s timeout) and returns `policy_unavailable`. The agent decides whether to retry, queue, or skip. No silent failures. +- **2-of-2 mode (no recovery share)**: Supported but explicitly warned against. If either share is lost, the wallet is irrecoverable. Only appropriate when operators accept this tradeoff. +- **No key refresh in v1**: The cggmp21 crate doesn't support threshold key refresh. If a share is suspected compromised, the mitigation is a full re-keygen to a new wallet and on-chain fund transfer. This is a known limitation we accept for v1. diff --git a/docs/threshold-signing-spec.md b/docs/threshold-signing-spec.md new file mode 100644 index 0000000..a888eb7 --- /dev/null +++ b/docs/threshold-signing-spec.md @@ -0,0 +1,178 @@ +# SAW Threshold Signing + +**Status:** Implemented (branch `slymebot/threshold-signing`) +**Date:** 2026-02-16 + +## Overview + +2-of-3 threshold ECDSA for SAW using the CGGMP21 protocol. The full private key never exists on any single machine — not on disk, not in memory, not during keygen. + +## Architecture + +``` +Agent Machine Policy Server (Railway/VPS) Human (Docker/offline) +┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ +│ saw-daemon │◄──WSS──►│ saw-policy │ │ saw-policy │ +│ Party 0 share │ │ Party 1 share │ │ Party 2 share │ +│ Unix socket API │ │ Policy engine │ │ Recovery only │ +└──────────────────┘ └──────────────────┘ └──────────────────┘ + ▲ + Agent code +``` + +**Normal signing:** Party 0 + Party 1 (daemon + policy, automatic) +**Recovery:** Any 2 of 3 parties can sign if one is lost + +## Components + +| Component | Crate | Role | +|-----------|-------|------| +| saw-daemon | `saw-daemon` | Holds party 0 share, serves Unix socket to agent, coordinates MPC | +| saw-policy | `saw-policy` | Holds party 1 share, evaluates policy rules, co-signs or denies | +| saw-mpc | `saw-mpc` | Core MPC library wrapping cggmp21 (keygen, signing, presignatures) | +| saw-cli | `saw-cli` | CLI for keygen, key management, wallet listing | + +## Signing Flow + +``` +Agent ──signTx()──► saw-daemon ──SignRequest──► saw-policy + │ │ + │ [Policy evaluates: │ + │ chain, recipient, │ + │ value, rate limits] │ + │ │ + │ ◄──PolicyDecision─── │ + │ (approve/deny) │ + │ │ + [If approved: 2-party MPC sign] │ + │ ◄──MPC rounds──► │ + │ │ +Agent ◄──{raw_tx}──── │ +``` + +**Fast path:** Pre-generated presignatures → single round partial signature exchange (~50ms) +**Slow path:** Full MPC signing when no presignatures available (~200ms) + +## Presignature Pool + +Daemon and policy maintain a synchronized pool of presignatures for instant signing: +- **Target:** 5 presignatures ready +- **Refill threshold:** 2 remaining → trigger background refill +- **Memory-only** — not persisted to disk (regenerated on restart) + +## Key Generation + +Two modes: + +### Local keygen (current) +Generate all 3 shares in one process, then distribute: +```bash +SAW_PASSPHRASE="secret" saw keygen-local --wallet mywallet --root ~/.saw +``` +Outputs 3 encrypted share files + metadata with the derived address. + +### Distributed keygen (implemented, not yet tested at scale) +Relay-based ceremony where each party runs independently: +```bash +saw keygen-threshold --relay --listen 0.0.0.0:9444 # relay +saw keygen-threshold --party 0 --wallet main --connect ws://relay:9444 +saw keygen-threshold --party 1 --wallet main --connect ws://relay:9444 +saw keygen-threshold --party 2 --wallet main --connect ws://relay:9444 +``` + +## Encryption at Rest + +Key shares are encrypted with **Argon2id + ChaCha20-Poly1305**: +- KDF: Argon2id (memory-hard, resistant to GPU/ASIC attacks) +- AEAD: ChaCha20-Poly1305 +- Format: `SAW1` magic bytes + salt + nonce + ciphertext + tag +- Passphrase via `SAW_PASSPHRASE` env var +- Backward-compatible: detects plaintext shares and loads them directly + +## Policy Engine + +```yaml +version: 1 +wallets: + base-test: + chain: evm + rules: + - name: base-sepolia-allow + action: approve + conditions: + allowed_chains: [84532] + - name: catch-all + action: deny +``` + +Rules evaluated top-to-bottom, first match wins. Actions: `approve`, `deny`, `escalate`. + +## Configuration + +### saw-daemon (`config.yaml`) +```yaml +wallets: + base-test: + mode: threshold + policy_url: "wss://saw-policy-production.up.railway.app" + key_share_path: "keys/threshold/base-test_party0.json" +``` + +### saw-policy (env vars for deployment) +| Variable | Description | +|----------|-------------| +| `KEY_SHARE_BASE64` | Base64-encoded encrypted key share | +| `POLICY_YAML` | Base64-encoded policy.yaml | +| `SAW_PASSPHRASE` | Passphrase to decrypt key share | +| `PORT` | Listen port (default: 9443) | + +## Deployment + +### Primary policy server (Railway) +```bash +railway up --service saw-policy +``` + +### Recovery container (Docker) +```bash +docker build -f docker/recovery/Dockerfile -t saw-recovery . +docker run -p 9443:9443 \ + -e SAW_PASSPHRASE="passphrase" \ + -e KEY_SHARE_BASE64="$(base64 -i party2.json.enc)" \ + saw-recovery +``` + +## Recovery Scenarios + +| Lost | Recovery path | +|------|--------------| +| Party 0 (daemon) | Party 1 (Railway) + Party 2 (Docker) sign → transfer funds to new wallet | +| Party 1 (Railway) | Party 0 (daemon) + Party 2 (Docker) sign → transfer funds to new wallet | +| Party 2 (recovery) | No immediate impact — Party 0 + Party 1 still sign normally. Generate new shares. | +| Two parties | **Unrecoverable** — this is inherent to 2-of-3. Back up shares separately. | + +## Transport + +- **Daemon ↔ Policy:** WebSocket (plaintext WS over Railway's TLS termination) +- **Message format:** JSON-serialized `WireMessage` enum (SignRequest, PolicyDecision, MpcWireMessage, PresignRequest, etc.) +- **Reconnection:** Persistent WS with exponential backoff (1s → 30s) +- **Future:** mTLS with pinned certificates + +## Limitations + +- **EVM only** — Solana requires Ed25519 (FROST protocol, future work) +- **No key refresh** — cggmp21 doesn't support threshold refresh yet. Compromised share → re-keygen + fund transfer. +- **No WS authentication** — relies on TLS termination. Auth planned for production. +- **No mTLS yet** — using Railway's HTTPS proxy. Direct mTLS planned. + +## Protocol: CGGMP21 + +Library: [dfns/cggmp21](https://github.com/dfns/cggmp21) v0.6.3 (Rust, audited by Kudelski) + +Key properties: +- Non-interactive presigning (message-independent preprocessing) +- Identifiable abort (cheating party is identified) +- UC-secure (composable security proof) +- 4 rounds presign + 1 round online sign + +**Critical implementation note:** Use `DataToSign::from_scalar()` with raw message hash bytes for EVM signing. Do NOT use `DataToSign::from_digest()` — it double-hashes, causing ecrecover to return the wrong address. diff --git a/railway.toml b/railway.toml new file mode 100644 index 0000000..98a5c76 --- /dev/null +++ b/railway.toml @@ -0,0 +1,2 @@ +[build] +dockerfilePath = "crates/saw-policy/Dockerfile"