diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/mise.lock b/mise.lock index 346f56d..5cdc53d 100644 --- a/mise.lock +++ b/mise.lock @@ -7,6 +7,7 @@ backend = "aqua:ast-grep/ast-grep" [tools.ast-grep."platforms.linux-arm64"] checksum = "sha256:3ba383839044cf9817929435f5ce0027f91d06931e8efb32d942e58d73d92be5" url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-aarch64-unknown-linux-gnu.zip" +url_api = "https://api.github.com/repos/ast-grep/ast-grep/releases/assets/388772218" [tools.ast-grep."platforms.linux-arm64-musl"] checksum = "sha256:3ba383839044cf9817929435f5ce0027f91d06931e8efb32d942e58d73d92be5" @@ -15,6 +16,7 @@ url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-aarch64 [tools.ast-grep."platforms.linux-x64"] checksum = "sha256:5de8b87cba67fc8dc3e239d54b6484802ad745a7ae3de76be4fe89661dc52657" url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-x86_64-unknown-linux-gnu.zip" +url_api = "https://api.github.com/repos/ast-grep/ast-grep/releases/assets/388771275" [tools.ast-grep."platforms.linux-x64-musl"] checksum = "sha256:5de8b87cba67fc8dc3e239d54b6484802ad745a7ae3de76be4fe89661dc52657" @@ -23,14 +25,17 @@ url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-x86_64- [tools.ast-grep."platforms.macos-arm64"] checksum = "sha256:c3961d8e8a4ee0ce2d0d98c7beeb168bb331cdc766b53630118a7b6c4fd39015" url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-aarch64-apple-darwin.zip" +url_api = "https://api.github.com/repos/ast-grep/ast-grep/releases/assets/388770234" [tools.ast-grep."platforms.macos-x64"] checksum = "sha256:a038965bfd7fe44257c771cdf8918dc3467dd8ec0eef673b8b14f639b144cdbd" url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-x86_64-apple-darwin.zip" +url_api = "https://api.github.com/repos/ast-grep/ast-grep/releases/assets/388770498" [tools.ast-grep."platforms.windows-x64"] checksum = "sha256:fe34f631bb24c08ad146f92ca2a92971a53d179461b509fd8d32dc863bff9f83" url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-x86_64-pc-windows-msvc.zip" +url_api = "https://api.github.com/repos/ast-grep/ast-grep/releases/assets/388771363" [[tools."cargo:cargo-audit"]] version = "0.22.1" @@ -57,7 +62,7 @@ version = "0.9.133" backend = "cargo:cargo-nextest" [[tools."cargo:cargo-shear"]] -version = "1.11.2" +version = "1.13.4" backend = "cargo:cargo-shear" [[tools."cargo:cargo-vet"]] @@ -78,7 +83,27 @@ backend = "cargo:flutter_rust_bridge_codegen" [[tools.flutter]] version = "3.41.7-stable" -backend = "asdf:flutter" +backend = "http:flutter" + +[tools.flutter."platforms.linux-x64"] +checksum = "sha256:f344d5057db52abc2a63cd3a7c7370957b7685d1fca5e5fbe2ce4dfe74657a79" +url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.41.7-stable.tar.xz" + +[tools.flutter."platforms.linux-x64-musl"] +checksum = "sha256:f344d5057db52abc2a63cd3a7c7370957b7685d1fca5e5fbe2ce4dfe74657a79" +url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.41.7-stable.tar.xz" + +[tools.flutter."platforms.macos-arm64"] +checksum = "sha256:2e3e6af44d1adccf695deff52e5e4c8beb10e5625066b27ad082b38b83ef805e" +url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_arm64_3.41.7-stable.zip" + +[tools.flutter."platforms.macos-x64"] +checksum = "sha256:a0b9af49e6e1a6800f31a408b98c1d7bd51e98650a8b9ebcd77168b48c916ff0" +url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_3.41.7-stable.zip" + +[tools.flutter."platforms.windows-x64"] +checksum = "sha256:de17b513b740a931c5dbc3f96b5a659c1612dfe6b5e1f910c5ad954a8bac17ee" +url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/windows/flutter_windows_3.41.7-stable.zip" [[tools.protoc]] version = "29.6" @@ -87,30 +112,37 @@ backend = "aqua:protocolbuffers/protobuf/protoc" [tools.protoc."platforms.linux-arm64"] checksum = "sha256:2594ff4fcae8cb57310d394d0961b236190ad9c5efbfdf1f597ea471d424fe79" url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-aarch_64.zip" +url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/350795076" [tools.protoc."platforms.linux-arm64-musl"] checksum = "sha256:2594ff4fcae8cb57310d394d0961b236190ad9c5efbfdf1f597ea471d424fe79" url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-aarch_64.zip" +url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/350795076" [tools.protoc."platforms.linux-x64"] checksum = "sha256:48785a926e73ffa3f68e2f22b14e7b849620c7a1d36809ac9249a5495e280323" url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-x86_64.zip" +url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/350795083" [tools.protoc."platforms.linux-x64-musl"] checksum = "sha256:48785a926e73ffa3f68e2f22b14e7b849620c7a1d36809ac9249a5495e280323" url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-x86_64.zip" +url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/350795083" [tools.protoc."platforms.macos-arm64"] checksum = "sha256:b9576b5fa1a1ef3fe13a8c91d9d8204b46545759bea5ae155cd6ba2ea4cdaeed" url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-osx-aarch_64.zip" +url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/350795082" [tools.protoc."platforms.macos-x64"] checksum = "sha256:312f04713946921cc0187ef34df80241ddca1bab6f564c636885fd2cc90d3f88" url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-osx-x86_64.zip" +url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/350795085" [tools.protoc."platforms.windows-x64"] checksum = "sha256:1ebd7c87baffb9f1c47169b640872bf5fb1e4408079c691af527be9561d8f6f7" url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-win64.zip" +url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/350795088" [[tools.python]] version = "3.14.4" @@ -122,8 +154,8 @@ url = "https://github.com/astral-sh/python-build-standalone/releases/download/20 provenance = "github-attestations" [tools.python."platforms.linux-arm64-musl"] -checksum = "sha256:b8b597fdb2f8dccdc502c11947b60a4b65eb6bce79cfa60c7ccf9b6e8352c60a" -url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.14.4+20260414-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz" +checksum = "sha256:a10687b226e0941632569836bc1d8fa6353a8e3e8424316467ca9cdf220b983d" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.14.4+20260414-aarch64-unknown-linux-musl-install_only_stripped.tar.gz" provenance = "github-attestations" [tools.python."platforms.linux-x64"] @@ -132,12 +164,12 @@ url = "https://github.com/astral-sh/python-build-standalone/releases/download/20 provenance = "github-attestations" [tools.python."platforms.linux-x64-musl"] -checksum = "sha256:fe9a9c32d13870af632cbac3dfc7528ae53597e94472aa4c7d6a42e8166136cd" -url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.14.4+20260414-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz" +checksum = "sha256:d6005226cd24e780630626232c7a63243d4885fdf975dcf930a0758a0759ce14" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.14.4+20260414-x86_64-unknown-linux-musl-install_only_stripped.tar.gz" provenance = "github-attestations" [tools.python."platforms.macos-arm64"] -checksum = "blake3:0314ec66e0f33ec04959583b5900bc8edae371a396aa96b8874e750d1fe936e6" +checksum = "sha256:6f304f4ec30854611f23316578302235fb517cd970519ecdd11a8c4db87fd843" url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.14.4+20260414-aarch64-apple-darwin-install_only_stripped.tar.gz" provenance = "github-attestations" @@ -154,3 +186,6 @@ provenance = "github-attestations" [[tools.rust]] version = "1.95.0" backend = "core:rust" + +[tools.rust.options] +components = "clippy,rust-analyzer" diff --git a/protobufs/operator/governance.proto b/protobufs/operator/governance.proto index 132a69f..589635b 100644 --- a/protobufs/operator/governance.proto +++ b/protobufs/operator/governance.proto @@ -2,6 +2,8 @@ syntax = "proto3"; package arbiter.operator.governance; +import "google/protobuf/empty.proto"; + message Request { oneof payload { CreateProposalRequest create = 1; @@ -13,14 +15,13 @@ message Request { message CreateProposalRequest { oneof kind { ApproveSdkClientPayload approve_sdk_client = 1; - GrantWalletAccessPayload grant_wallet_access = 3; - ApproveServerUpdatePayload approve_server_update = 4; - ReplaceOperatorPayload replace_operator = 5; - UpdateShamirParametersPayload update_shamir_parameters = 6; - ApprovePersistentGrantPayload approve_persistent_grant = 7; - ApproveOneOffTransactionPayload approve_one_off_transaction = 8; + GrantWalletAccessPayload grant_wallet_access = 2; + ReplaceOperatorPayload replace_operator = 3; + google.protobuf.Empty trigger_rekey = 4; + ApprovePersistentGrantPayload approve_persistent_grant = 5; + ApproveOneOffTransactionPayload approve_one_off_transaction = 6; } - optional uint32 ttl_secs = 2; + optional uint32 ttl_secs = 7; } message ReplaceOperatorPayload { @@ -28,12 +29,6 @@ message ReplaceOperatorPayload { bytes new_pubkey = 2; } -message UpdateShamirParametersPayload { - uint32 new_n = 1; -} - -message ApproveServerUpdatePayload {} - message ApproveSdkClientPayload { int32 client_id = 1; } diff --git a/protobufs/operator/vault/bootstrap.proto b/protobufs/operator/vault/bootstrap.proto index fc0edf8..ab72c73 100644 --- a/protobufs/operator/vault/bootstrap.proto +++ b/protobufs/operator/vault/bootstrap.proto @@ -18,8 +18,7 @@ message ContributePassphrase { } message ContributeRecoveryPassphrase { - int32 recovery_operator_id = 1; - bytes passphrase = 2; + bytes passphrase = 1; } enum BootstrapResult { diff --git a/protobufs/operator/vault/rekey.proto b/protobufs/operator/vault/rekey.proto index 5a1de2c..f6d1a63 100644 --- a/protobufs/operator/vault/rekey.proto +++ b/protobufs/operator/vault/rekey.proto @@ -7,8 +7,7 @@ message ContributePassphrase { } message ContributeRecoveryPassphrase { - int32 recovery_operator_id = 1; - bytes passphrase = 2; + bytes passphrase = 1; } enum RekeyResult { diff --git a/protobufs/operator/vault/unseal.proto b/protobufs/operator/vault/unseal.proto index 5e770fd..ef981a5 100644 --- a/protobufs/operator/vault/unseal.proto +++ b/protobufs/operator/vault/unseal.proto @@ -20,8 +20,7 @@ message ContributePassphrase { } message ContributeRecoveryPassphrase { - int32 recovery_operator_id = 1; - bytes passphrase = 2; + bytes passphrase = 1; } enum UnsealResult { diff --git a/server/Cargo.lock b/server/Cargo.lock index e36c264..c608b77 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -24,7 +24,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ "crypto-common 0.1.7", - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -674,6 +674,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + [[package]] name = "anyhow" version = "1.0.102" @@ -707,6 +713,7 @@ dependencies = [ "memsafe", "ml-dsa", "rand 0.10.1", + "strum 0.28.0", "thiserror", "x-wing", ] @@ -766,11 +773,14 @@ dependencies = [ "kameo", "kameo_actors", "ml-dsa", + "mockall", "mutants", "pem", "proptest", + "prost", "prost-types", "rand 0.10.1", + "rand_core 0.6.4", "rcgen", "restructed", "rstest", @@ -779,6 +789,7 @@ dependencies = [ "smlang", "strum 0.28.0", "subtle", + "tempfile", "test-log", "thiserror", "tokio", @@ -786,6 +797,7 @@ dependencies = [ "tonic", "tracing", "tracing-subscriber", + "vsss-rs", "x25519-dalek 2.0.1", ] @@ -1283,7 +1295,7 @@ version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -1612,8 +1624,22 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ - "generic-array", + "generic-array 0.14.7", "rand_core 0.6.4", + "serdect 0.2.0", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-bigint" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96272c2ff28b807e09250b180ad1fb7889a3258f7455759b5c3c58b719467130" +dependencies = [ + "num-traits", + "rand_core 0.6.4", + "serdect 0.3.0", "subtle", "zeroize", ] @@ -1624,7 +1650,7 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "generic-array", + "generic-array 0.14.7", "rand_core 0.6.4", "typenum", ] @@ -1927,7 +1953,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" dependencies = [ - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -1965,6 +1991,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + [[package]] name = "downcast-rs" version = "2.0.2" @@ -2007,7 +2039,7 @@ dependencies = [ "digest 0.10.7", "elliptic-curve", "rfc6979", - "serdect", + "serdect 0.2.0", "signature 2.2.0", "spki 0.7.3", ] @@ -2040,16 +2072,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", - "crypto-bigint", + "crypto-bigint 0.5.5", "digest 0.10.7", "ff", - "generic-array", + "generic-array 0.14.7", "group", + "hkdf", "pkcs8 0.10.2", "rand_core 0.6.4", "sec1", - "serdect", + "serdect 0.2.0", "subtle", + "tap", + "zeroize", +] + +[[package]] +name = "elliptic-curve-tools" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de2b6fae800f08032a6ea32995b52925b1d451bff9d445c8ab2932323277faf" +dependencies = [ + "elliptic-curve", + "heapless", + "hex", + "multiexp", + "serde", "zeroize", ] @@ -2123,6 +2171,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ + "bitvec", "rand_core 0.6.4", "subtle", ] @@ -2200,6 +2249,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fragile" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8878864ba14bb86e818a412bfd6f18f9eabd4ec0f008a28e8f7eb61db532fcf9" +dependencies = [ + "futures-core", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -2323,6 +2381,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "generic-array" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab9e9188e97a93276e1fe7b56401b851e2b45a46d045ca658100c1303ada649" +dependencies = [ + "rustversion", + "serde_core", + "typenum", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -2406,6 +2475,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2446,6 +2524,16 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -2473,6 +2561,15 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + [[package]] name = "hmac" version = "0.12.1" @@ -2543,6 +2640,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5" dependencies = [ "ctutils", + "serde", "typenum", "zeroize", ] @@ -2808,7 +2906,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -2947,15 +3045,15 @@ dependencies = [ "ecdsa", "elliptic-curve", "once_cell", - "serdect", + "serdect 0.2.0", "sha2 0.10.9", "signature 2.2.0", ] [[package]] name = "kameo" -version = "0.20.0" -source = "git+https://github.com/hdbg/kameo.git?rev=805b417#805b41783fe90b54827ecad142b422c7a9b69b9a" +version = "0.22.2" +source = "git+https://github.com/hdbg/kameo.git?rev=17af90e3#17af90e3ae95fc6f89fa31a2f1b9506ac127f0b7" dependencies = [ "downcast-rs", "dyn-clone", @@ -2968,8 +3066,8 @@ dependencies = [ [[package]] name = "kameo_actors" -version = "0.5.0" -source = "git+https://github.com/hdbg/kameo.git?rev=805b417#805b41783fe90b54827ecad142b422c7a9b69b9a" +version = "0.8.1" +source = "git+https://github.com/hdbg/kameo.git?rev=17af90e3#17af90e3ae95fc6f89fa31a2f1b9506ac127f0b7" dependencies = [ "futures", "glob", @@ -2980,14 +3078,13 @@ dependencies = [ [[package]] name = "kameo_macros" -version = "0.20.0" -source = "git+https://github.com/hdbg/kameo.git?rev=805b417#805b41783fe90b54827ecad142b422c7a9b69b9a" +version = "0.21.1" +source = "git+https://github.com/hdbg/kameo.git?rev=17af90e3#17af90e3ae95fc6f89fa31a2f1b9506ac127f0b7" dependencies = [ - "darling 0.23.0", "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.4", ] [[package]] @@ -3278,6 +3375,32 @@ dependencies = [ "zeroize", ] +[[package]] +name = "mockall" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a6ceddfe3ce334925e96bf420fdb2dcee5bed6c632a168ece622676dadeaf8a" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "mockall_derive", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall_derive" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cfe16fbe8a314aeec0b861ac24e60b1e123e97634bab045475b9d6a18416fd8" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "module-lattice" version = "0.2.2" @@ -3290,6 +3413,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "multiexp" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ec2ce93a6f06ac6cae04c1da3f2a6a24fcfc1f0eb0b4e0f3d302f0df45326cb" +dependencies = [ + "ff", + "group", + "rand_core 0.6.4", + "rustversion", + "std-shims", + "zeroize", +] + [[package]] name = "multimap" version = "0.10.1" @@ -3321,6 +3458,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -3329,6 +3480,19 @@ checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", + "rand 0.8.6", + "serde", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", + "rand 0.8.6", + "serde", ] [[package]] @@ -3346,6 +3510,29 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", + "serde", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3647,6 +3834,32 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -4454,9 +4667,9 @@ checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ "base16ct", "der 0.7.10", - "generic-array", + "generic-array 0.14.7", "pkcs8 0.10.2", - "serdect", + "serdect 0.2.0", "subtle", "zeroize", ] @@ -4622,6 +4835,16 @@ dependencies = [ "serde", ] +[[package]] +name = "serdect" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f42f67da2385b51a5f9652db9c93d78aeaf7610bf5ec366080b6de810604af53" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "sha2" version = "0.10.9" @@ -4787,6 +5010,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" + [[package]] name = "spki" version = "0.7.3" @@ -4831,6 +5060,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "std-shims" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "227c4f8561598188d0df96dbe749824576174bba278b5b6bb2eacff1066067d0" +dependencies = [ + "hashbrown 0.16.1", + "rustversion", + "spin", +] + [[package]] name = "string_morph" version = "0.1.0" @@ -4934,6 +5174,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn-solidity" version = "1.5.7" @@ -4995,6 +5246,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "test-log" version = "0.2.20" @@ -5574,6 +5831,27 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsss-rs" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ec751bdcc8bda099e269b24cc6b4ad14f9ce8b0490c1599174070e792ecd70c" +dependencies = [ + "crypto-bigint 0.5.5", + "crypto-bigint 0.6.1", + "elliptic-curve", + "elliptic-curve-tools", + "generic-array 1.4.1", + "hex", + "hybrid-array", + "num", + "rand_core 0.6.4", + "serde", + "sha3 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/server/Cargo.toml b/server/Cargo.toml index 34ef2fe..0cadbce 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -12,8 +12,8 @@ base64 = "0.22.1" chrono = { version = "0.4.44", features = ["serde"] } futures = "0.3.32" k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] } -kameo = {git = "https://github.com/hdbg/kameo.git", rev = "805b417"} -kameo_actors = {git = "https://github.com/hdbg/kameo.git", rev = "805b417"} +kameo = {git = "https://github.com/hdbg/kameo.git", rev = "17af90e3"} +kameo_actors = {git = "https://github.com/hdbg/kameo.git", rev = "17af90e3"} hmac = "0.13.0" miette = { version = "7.6.0", features = ["fancy", "serde"] } ml-dsa = { version = "0.1.0-rc.9", features = ["zeroize"] } @@ -27,6 +27,7 @@ rustls = { version = "0.23.40", features = ["aws-lc-rs", "logging", "prefer-post rustls-pki-types = "1.14.1" sha2 = "0.11" smlang = "0.8.0" +strum = { version = "0.28.0", features = ["derive"] } thiserror = "2.0.18" tokio = { version = "1.52.1", features = ["full"] } tokio-stream = { version = "0.1.18", features = ["full"] } @@ -106,7 +107,6 @@ indexing_slicing = "warn" infinite_loop = "warn" inline_asm_x86_att_syntax = "warn" inline_asm_x86_intel_syntax = "warn" -integer_division = "warn" large_include_file = "warn" lossy_float_literal = "warn" map_with_unused_argument_over_ranges = "warn" @@ -168,3 +168,4 @@ nursery = { level = "warn", priority = -1 } pedantic = { level = "warn", priority = -1 } type_repetition_in_bounds = "allow" # sometimes, it's better for readability this way +unused_async_trait_impl = "allow" diff --git a/server/clippy.toml b/server/clippy.toml index bed3c74..8fd6ebd 100644 --- a/server/clippy.toml +++ b/server/clippy.toml @@ -26,3 +26,5 @@ trait-assoc-item-kinds-order = [ "type", "fn", ] # community tested standard + +too-many-lines-threshold = 150 diff --git a/server/crates/arbiter-client/src/auth.rs b/server/crates/arbiter-client/src/auth.rs index eae51e9..6829302 100644 --- a/server/crates/arbiter-client/src/auth.rs +++ b/server/crates/arbiter-client/src/auth.rs @@ -2,7 +2,7 @@ use crate::{ storage::StorageError, transport::{ClientTransport, next_request_id}, }; -use arbiter_crypto::authn::{self, CLIENT_CONTEXT, SigningKey}; +use arbiter_crypto::authn::{self, SigningContext, SigningKey}; use arbiter_proto::{ ClientMetadata, proto::{ @@ -110,7 +110,7 @@ async fn send_auth_challenge_solution( }; let challenge_payload: Vec = challenge.format(); let signature = key - .sign_message(&challenge_payload, CLIENT_CONTEXT) + .sign_message(&challenge_payload, SigningContext::Client) .map_err(|_| AuthError::UnexpectedAuthResponse)? .to_bytes(); diff --git a/server/crates/arbiter-crypto/Cargo.toml b/server/crates/arbiter-crypto/Cargo.toml index 3cbe8ec..2238670 100644 --- a/server/crates/arbiter-crypto/Cargo.toml +++ b/server/crates/arbiter-crypto/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" ml-dsa = {workspace = true, optional = true } rand = {workspace = true, optional = true} memsafe = {version = "0.4.0", optional = true} +strum = { workspace = true, optional = true } hmac.workspace = true alloy.workspace = true x-wing = { version = "0.1.0-rc.0", features = ["zeroize"] } @@ -18,7 +19,7 @@ workspace = true [features] default = ["authn", "safecell"] -authn = ["dep:ml-dsa", "dep:rand"] +authn = ["dep:ml-dsa", "dep:rand", "dep:strum"] safecell = ["dep:memsafe"] [lib] diff --git a/server/crates/arbiter-crypto/src/authn/v1.rs b/server/crates/arbiter-crypto/src/authn/v1.rs index 6f38e44..5e147e8 100644 --- a/server/crates/arbiter-crypto/src/authn/v1.rs +++ b/server/crates/arbiter-crypto/src/authn/v1.rs @@ -5,9 +5,25 @@ use ml_dsa::{ SigningKey as MlDsaSigningKey, VerifyingKey as MlDsaVerifyingKey, signature::Keypair as _, }; use rand::RngExt; +use strum::IntoStaticStr; -pub static CLIENT_CONTEXT: &[u8] = b"arbiter_client"; -pub static OPERATOR_CONTEXT: &[u8] = b"arbiter_operator"; +/// Domain separation tag mixed into every ML-DSA signature. +#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoStaticStr)] +pub enum SigningContext { + #[strum(serialize = "arbiter_client")] + Client, + #[strum(serialize = "arbiter_operator")] + Operator, + #[strum(serialize = "arbiter_governance_vote")] + GovernanceVote, +} + +impl SigningContext { + #[must_use] + pub fn as_bytes(self) -> &'static [u8] { + <&'static str>::from(self).as_bytes() + } +} const NONCE_SIZE: usize = 32; @@ -85,10 +101,26 @@ impl PublicKey { } #[must_use] - pub fn verify(&self, challenge: &AuthChallenge, context: &[u8], signature: &Signature) -> bool { + pub fn verify( + &self, + challenge: &AuthChallenge, + context: SigningContext, + signature: &Signature, + ) -> bool { let challenge = challenge.format(); self.0 - .verify_with_context(&challenge, context, &signature.0) + .verify_with_context(&challenge, context.as_bytes(), &signature.0) + } + + #[must_use] + pub fn verify_message( + &self, + message: &[u8], + context: SigningContext, + signature: &Signature, + ) -> bool { + self.0 + .verify_with_context(message, context.as_bytes(), &signature.0) } } @@ -115,17 +147,21 @@ impl SigningKey { self.0.verifying_key().into() } - pub fn sign_message(&self, message: &[u8], context: &[u8]) -> Result { + pub fn sign_message( + &self, + message: &[u8], + context: SigningContext, + ) -> Result { self.0 .signing_key() - .sign_deterministic(message, context) + .sign_deterministic(message, context.as_bytes()) .map(Into::into) } pub fn sign_challenge( &self, challenge: &AuthChallenge, - context: &[u8], + context: SigningContext, ) -> Result { let challenge = challenge.format(); @@ -192,7 +228,7 @@ mod tests { use crate::authn::AuthChallenge; - use super::{CLIENT_CONTEXT, PublicKey, Signature, SigningKey, OPERATOR_CONTEXT}; + use super::{PublicKey, Signature, SigningContext, SigningKey}; #[test] fn public_key_round_trip_decodes() { @@ -208,7 +244,7 @@ mod tests { fn signature_round_trip_decodes() { let key = SigningKey::generate(); let signature = key - .sign_message(b"challenge", CLIENT_CONTEXT) + .sign_message(b"challenge", SigningContext::Client) .expect("signature should be created"); let decoded = @@ -223,11 +259,11 @@ mod tests { let public_key = key.public_key(); let challenge = AuthChallenge::generate(&mut rand::rng()); let signature = key - .sign_challenge(&challenge, CLIENT_CONTEXT) + .sign_challenge(&challenge, SigningContext::Client) .expect("signature should be created"); - assert!(public_key.verify(&challenge, CLIENT_CONTEXT, &signature)); - assert!(!public_key.verify(&challenge, OPERATOR_CONTEXT, &signature)); + assert!(public_key.verify(&challenge, SigningContext::Client, &signature)); + assert!(!public_key.verify(&challenge, SigningContext::Operator, &signature)); } #[test] @@ -240,13 +276,13 @@ mod tests { let challenge = AuthChallenge::generate(&mut rand::rng()); let signature = restored - .sign_challenge(&challenge, CLIENT_CONTEXT) + .sign_challenge(&challenge, SigningContext::Client) .expect("signature should be created"); assert!( restored .public_key() - .verify(&challenge, CLIENT_CONTEXT, &signature) + .verify(&challenge, SigningContext::Client, &signature) ); } } diff --git a/server/crates/arbiter-crypto/src/safecell.rs b/server/crates/arbiter-crypto/src/safecell.rs index 15b0044..b16194c 100644 --- a/server/crates/arbiter-crypto/src/safecell.rs +++ b/server/crates/arbiter-crypto/src/safecell.rs @@ -22,7 +22,7 @@ pub trait SafeCellHandle { fn read(&mut self) -> Self::CellRead<'_>; fn write(&mut self) -> Self::CellWrite<'_>; - fn new_inline(f: F) -> Self + fn new_inline_default(f: F) -> Self where Self: Sized, T: Default, @@ -36,6 +36,14 @@ pub trait SafeCellHandle { cell } + fn new_inline(f: Box) -> Self + where + Self: Sized, + F: for<'a> FnOnce() -> T, + { + Self::new(f()) + } + #[inline(always)] fn read_inline(&mut self, f: F) -> R where diff --git a/server/crates/arbiter-proto/src/lib.rs b/server/crates/arbiter-proto/src/lib.rs index 0b91e11..17f7582 100644 --- a/server/crates/arbiter-proto/src/lib.rs +++ b/server/crates/arbiter-proto/src/lib.rs @@ -23,6 +23,10 @@ pub mod proto { tonic::include_proto!("arbiter.operator.evm"); } + pub mod governance { + tonic::include_proto!("arbiter.operator.governance"); + } + pub mod sdk_client { tonic::include_proto!("arbiter.operator.sdk_client"); } @@ -34,6 +38,10 @@ pub mod proto { tonic::include_proto!("arbiter.operator.vault.bootstrap"); } + pub mod rekey { + tonic::include_proto!("arbiter.operator.vault.rekey"); + } + pub mod unseal { tonic::include_proto!("arbiter.operator.vault.unseal"); } diff --git a/server/crates/arbiter-server/Cargo.toml b/server/crates/arbiter-server/Cargo.toml index 7790bd6..5bf6960 100644 --- a/server/crates/arbiter-server/Cargo.toml +++ b/server/crates/arbiter-server/Cargo.toml @@ -37,11 +37,12 @@ kameo.workspace = true chacha20poly1305 = { version = "0.10.1", features = ["std"] } argon2 = { version = "0.5.3", features = ["zeroize"] } restructed = "0.2.2" -strum = { version = "0.28.0", features = ["derive"] } +strum.workspace = true pem = "3.0.6" sha2.workspace = true hmac.workspace = true alloy.workspace = true +prost.workspace = true prost-types.workspace = true arbiter-tokens-registry.path = "../arbiter-tokens-registry" anyhow = "1.0.102" @@ -50,12 +51,16 @@ subtle = "2.6.1" x25519-dalek.workspace = true k256.workspace = true kameo_actors.workspace = true +vsss-rs = "5.4.0" +rand_core = "0.6" [dev-dependencies] proptest = "1.11.0" rstest.workspace = true test-log = { version = "0.2", default-features = false, features = ["trace"] } ml-dsa.workspace = true +mockall = "0.15.0" +tempfile = "3.27.0" [lib] doctest = false diff --git a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql index 37509f1..8db1fb0 100644 --- a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql +++ b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql @@ -37,7 +37,11 @@ create table if not exists tls_history ( create table if not exists arbiter_settings ( id INTEGER not null PRIMARY KEY CHECK (id = 1), -- singleton row, id must be 1 root_key_id integer references root_key_history (id) on delete RESTRICT, -- if null, means wasn't bootstrapped yet - tls_id integer references tls_history (id) on delete RESTRICT + tls_id integer references tls_history (id) on delete RESTRICT, + -- Shamir threshold of the split that produced the stored shares. Null before bootstrap. + -- Recorded rather than recomputed: an aborted operator replacement leaves fewer share + -- rows than the split has shares, and a recomputed threshold would then be wrong. + shamir_threshold integer ) STRICT; insert into arbiter_settings (id) values (1) on conflict do nothing; @@ -56,6 +60,7 @@ create table if not exists operator ( share blob not null, share_nonce blob not null, + share_salt blob not null, created_at integer not null default(unixepoch ('now')), updated_at integer not null default(unixepoch ('now')) @@ -108,6 +113,7 @@ create table if not exists evm_wallet_access ( id integer not null primary key, wallet_id integer not null references evm_wallet (id) on delete cascade, client_id integer not null references program_client (id) on delete cascade, + revoked_at integer, -- unix timestamp when revoked, null = still active created_at integer not null default(unixepoch ('now')) ) STRICT; @@ -215,3 +221,156 @@ create table if not exists integrity_envelope ( ) STRICT; create unique index if not exists uniq_integrity_envelope_entity on integrity_envelope (entity_kind, entity_id); + +create table if not exists proposal ( + id integer not null primary key, + kind text not null, + initiator_id integer not null references operator_identity(id) on delete restrict, + created_at integer not null default(unixepoch('now')), + expires_at integer not null, + status text not null default 'pending' + check (status in ('pending', 'approved', 'rejected')) +) STRICT; + +-- Parameters of an approved-or-pending proposal +create table if not exists proposal_approve_sdk_client ( + proposal_id integer not null primary key references proposal(id) on delete cascade, + client_id integer not null references program_client(id) on delete restrict +) STRICT; + +create table if not exists proposal_grant_wallet_access ( + proposal_id integer not null primary key references proposal(id) on delete cascade, + wallet_id integer not null references evm_wallet(id) on delete restrict, + client_id integer not null references program_client(id) on delete restrict +) STRICT; + +create table if not exists proposal_replace_operator ( + proposal_id integer not null primary key references proposal(id) on delete cascade, + old_operator_id integer not null references operator_identity(id) on delete restrict, + new_pubkey blob not null +) STRICT; + +-- The transaction an operator votes to sign. +create table if not exists proposal_one_off_transaction ( + proposal_id integer not null primary key references proposal(id) on delete cascade, + client_id integer not null references program_client(id) on delete restrict, + wallet_address blob not null check (length(wallet_address) = 20), + chain_id integer not null, + nonce integer not null, + gas_limit integer not null, + max_fee_per_gas blob not null check (length(max_fee_per_gas) = 16), + max_priority_fee_per_gas blob not null check (length(max_priority_fee_per_gas) = 16), + to_address blob not null check (length(to_address) = 20), + value blob not null check (length(value) = 32), + input blob not null +) STRICT; + +-- The grant an operator votes to creat +create table if not exists proposal_persistent_grant ( + proposal_id integer not null primary key references proposal (id) on delete cascade, + wallet_access_id integer not null references evm_wallet_access (id) on delete restrict, + chain_id integer not null, -- EIP-155 chain ID + valid_from integer, -- unix timestamp (seconds), null = no lower bound + valid_until integer, -- unix timestamp (seconds), null = no upper bound + max_gas_fee_per_gas blob check (max_gas_fee_per_gas is null or length(max_gas_fee_per_gas) = 32), + max_priority_fee_per_gas blob check (max_priority_fee_per_gas is null or length(max_priority_fee_per_gas) = 32), + rate_limit_count integer, -- max transactions in window, null = unlimited + rate_limit_window_secs integer, -- window duration in seconds, null = unlimited + check ((rate_limit_count is null) = (rate_limit_window_secs is null)) +) STRICT; + +-- `specific = ether_transfer` +create table if not exists proposal_persistent_grant_ether ( + proposal_id integer not null primary key references proposal_persistent_grant (proposal_id) on delete cascade, + window_secs integer not null, + max_volume blob not null check (length(max_volume) = 32) +) STRICT; + +create table if not exists proposal_persistent_grant_ether_target ( + id integer not null primary key, + proposal_id integer not null references proposal_persistent_grant_ether (proposal_id) on delete cascade, + address blob not null check (length(address) = 20) +) STRICT; + +create unique index if not exists uniq_proposal_ether_target on proposal_persistent_grant_ether_target (proposal_id, address); + +-- `specific = token_transfer` +create table if not exists proposal_persistent_grant_token ( + proposal_id integer not null primary key references proposal_persistent_grant (proposal_id) on delete cascade, + token_contract blob not null check (length(token_contract) = 20), + receiver blob check (receiver is null or length(receiver) = 20) +) STRICT; + +create table if not exists proposal_persistent_grant_token_limit ( + id integer not null primary key, + proposal_id integer not null references proposal_persistent_grant_token (proposal_id) on delete cascade, + window_secs integer not null, + max_volume blob not null check (length(max_volume) = 32) +) STRICT; + +create table if not exists proposal_vote ( + id integer not null primary key, + proposal_id integer not null references proposal(id) on delete cascade, + operator_id integer not null references operator_identity(id) on delete restrict, + approve integer not null check (approve in (0, 1)), + signature blob not null, + voted_at integer not null default(unixepoch('now')), + unique (proposal_id, operator_id) +) STRICT; + + +-- The signature the vault produced for an approved transaction, by component. +-- +-- secp256k1 signatures have three common encodings (Electrum v=27/28, raw parity, +-- ERC-2098 compact); a single blob would not say which one it holds. `y_parity` is +-- the raw bit -- add 27 to rebuild the Electrum form `Signature::as_bytes` emits. +create table if not exists proposal_one_off_transaction_result ( + proposal_id integer not null primary key + references proposal_one_off_transaction (proposal_id) on delete cascade, + r blob not null check (length(r) = 32), + s blob not null check (length(s) = 32), + y_parity integer not null check (y_parity in (0, 1)), + created_at integer not null default(unixepoch('now')) +) STRICT; + +-- =============================== +-- Recovery Operators (§3.4/§3.5/§3.6) +-- =============================== + +-- Encrypted Shamir shares for recovery operators (mirrors the `operator` table). +create table if not exists recovery_operator ( + id integer not null primary key references recovery_operator_identity(id) on delete restrict, + share blob not null, + share_nonce blob not null, + share_salt blob not null, + created_at integer not null default(unixepoch('now')), + updated_at integer not null default(unixepoch('now')) +) STRICT; + +create table if not exists recovery_operator_identity ( + id integer not null primary key, + public_key blob not null unique, + created_at integer not null default(unixepoch('now')), + updated_at integer not null default(unixepoch('now')) +) STRICT; + +-- One active wakeup request at a time. A request is pending when cancelled_at IS NULL +-- and requested_at + 14 days > now. It becomes active (recovery live) after 14 days. +create table if not exists recovery_wakeup_request ( + id integer not null primary key, + requested_by integer not null references operator_identity(id) on delete restrict, + requested_at integer not null default(unixepoch('now')), + cancelled_by integer references operator_identity(id) on delete restrict, + cancelled_at integer +) STRICT; + +-- Votes cast by recovery operators; only allowed on replace_operator proposals. +create table if not exists recovery_proposal_vote ( + id integer not null primary key, + proposal_id integer not null references proposal(id) on delete cascade, + recovery_operator_id integer not null references recovery_operator_identity(id) on delete restrict, + approve integer not null check (approve in (0, 1)), + signature blob not null, + voted_at integer not null default(unixepoch('now')), + unique (proposal_id, recovery_operator_id) +) STRICT; diff --git a/server/crates/arbiter-server/src/actors/bootstrap.rs b/server/crates/arbiter-server/src/actors/bootstrap.rs index c1fe6af..ac2a5f9 100644 --- a/server/crates/arbiter-server/src/actors/bootstrap.rs +++ b/server/crates/arbiter-server/src/actors/bootstrap.rs @@ -4,28 +4,43 @@ use arbiter_proto::{BOOTSTRAP_PATH, home_path}; use diesel::QueryDsl; use diesel_async::RunQueryDsl; use kameo::{Actor, messages}; -use rand::{RngExt, distr::Alphanumeric, make_rng, rngs::StdRng}; +use rand::{ + distr::{Alphanumeric, SampleString as _}, + make_rng, + rngs::StdRng, +}; +use std::path::Path; use subtle::ConstantTimeEq as _; use thiserror::Error; const TOKEN_LENGTH: usize = 64; -pub async fn generate_token() -> Result { - let rng: StdRng = make_rng(); +pub async fn generate_token(home: &Path) -> Result { + let mut rng: StdRng = make_rng(); - let token = rng.sample_iter(Alphanumeric).take(TOKEN_LENGTH).fold( - String::default(), - |mut accum, char| { - accum += char.to_string().as_str(); - accum - }, - ); + // `Alphanumeric` samples raw `u8` ASCII codes, not `char`s -- `SampleString::sample_string` + // is `rand`'s own documented way to turn that into an actual TOKEN_LENGTH-character string + // (see the "Passwords" example on `Alphanumeric`'s docs). A prior version of this function + // called `.to_string()` on the sampled `u8` directly, which stringifies the numeric byte + // value (e.g. `65` instead of `'A'`) rather than the character it represents, silently + // producing a variable-length, all-decimal-digit string instead of a real token. + let token = Alphanumeric.sample_string(&mut rng, TOKEN_LENGTH); - tokio::fs::write(home_path()?.join(BOOTSTRAP_PATH), token.as_str()).await?; + tokio::fs::write(home.join(BOOTSTRAP_PATH), token.as_str()).await?; Ok(token) } +/// A token file is only trustworthy if it looks like something `generate_token` could have +/// produced. Anything else -- empty (a crash between the file's truncate and write), foreign +/// content, or a trailing newline added by an editor -- must not be adopted as a live +/// credential: an empty file would make every empty-string token verify, and mismatched +/// content would silently lock out every operator holding the real, already-printed token. +#[must_use] +fn is_valid_token(candidate: &str) -> bool { + candidate.len() == TOKEN_LENGTH && candidate.chars().all(|c| c.is_ascii_alphanumeric()) +} + #[derive(Error, Debug)] pub enum Error { #[error("Database error: {0}")] @@ -44,31 +59,77 @@ pub struct Bootstrapper { } impl Bootstrapper { + /// Production constructor: resolves the real `~/.arbiter` directory and delegates. pub async fn new(db: &DatabasePool) -> Result { - let row_count: i64 = { - let mut conn = db.get().await?; - - schema::operator::table - .count() - .get_result(&mut conn) - .await? - }; - - let token = if row_count == 0 { - let token = generate_token().await?; - Some(token) - } else { - None - }; - - Ok(Self { token }) + let home = home_path()?; + Self::new_in(db, &home).await } -} -#[messages] -impl Bootstrapper { - #[message] - pub fn is_correct_token(&self, token: String) -> bool { + /// Carries all of `new`'s logic, parameterized on the directory the token file lives in. + /// `new` resolves the real home directory and calls this; tests call it directly with a + /// throwaway temp directory so they never touch the real `~/.arbiter/bootstrap_token`. + pub async fn new_in(db: &DatabasePool, home: &Path) -> Result { + let mut conn = db.get().await?; + + let bootstrapped: bool = schema::arbiter_settings::table + .select(schema::arbiter_settings::root_key_id) + .first::>(&mut conn) + .await? + .is_some(); + + if bootstrapped { + return Ok(Self { token: None }); + } + + let any_operator_registered: bool = schema::operator_identity::table + .count() + .get_result::(&mut conn) + .await? + > 0; + + if !any_operator_registered { + // Nobody has used the current token yet, so there is nothing to preserve across a + // restart: generate a fresh one, exactly as on a first run. Reusing an old file + // here would let a token survive a database reset, silently reviving trust in + // whoever still held it. + return Ok(Self { + token: Some(generate_token(home).await?), + }); + } + + // At least one operator has already registered with the current token: every other + // declared operator still needs that same token, including across a restart, so an + // existing file is reused rather than replaced -- but only if it still looks like a + // real token. A truncated, foreign, or corrupted file must not become a live + // credential (see `is_valid_token`). + let path = home.join(BOOTSTRAP_PATH); + let token = match tokio::fs::read_to_string(&path).await { + Ok(existing) if is_valid_token(&existing) => existing, + Ok(_) => { + // Replacing the file invalidates whatever token the already-registered + // operators were handed, so it must not happen quietly. The content itself is + // a credential and stays out of the log; the path is enough to act on. + tracing::warn!( + ?path, + "Bootstrap token file is not a well-formed token; replacing it" + ); + generate_token(home).await? + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => generate_token(home).await?, + Err(err) => return Err(Error::Io(err)), + }; + + Ok(Self { token: Some(token) }) + } + + /// Drops the token from memory. Called once the vault is bootstrapped: from then on, + /// operators are added through governance rather than through the token. + pub(crate) fn forget_token(&mut self) { + self.token = None; + } + + #[must_use] + fn is_correct_token(&self, token: &str) -> bool { self.token.as_ref().is_some_and(|expected| { let expected_bytes = expected.as_bytes(); let token_bytes = token.as_bytes(); @@ -77,15 +138,28 @@ impl Bootstrapper { bool::from(choice) }) } +} +#[messages] +impl Bootstrapper { + /// Checks the token without retiring it: every operator in a declared committee + /// authenticates with the same token during bootstrap. #[message] - pub fn consume_token(&mut self, token: String) -> bool { - if self.is_correct_token(token) { - self.token = None; - true - } else { - false - } + #[must_use] + pub fn verify_token(&self, token: String) -> bool { + self.is_correct_token(&token) + } +} + +impl kameo::prelude::Message for Bootstrapper { + type Reply = (); + + async fn handle( + &mut self, + _msg: crate::actors::vault::events::Bootstrapped, + _ctx: &mut kameo::prelude::Context, + ) -> Self::Reply { + self.forget_token(); } } @@ -96,3 +170,150 @@ impl Bootstrapper { self.token.clone() } } + +#[cfg(test)] +mod tests { + use super::*; + use diesel::{ExpressionMethods as _, insert_into, update}; + + /// A multi-operator committee registers every member with the same token, so verifying it + /// must not consume it. Only a completed bootstrap retires the token. + #[tokio::test] + async fn token_verifies_repeatedly_until_bootstrap_completes() { + let mut bootstrapper = Bootstrapper { + token: Some("test-token".to_owned()), + }; + + assert!(bootstrapper.verify_token("test-token".to_owned())); + assert!(bootstrapper.verify_token("test-token".to_owned())); + assert!(!bootstrapper.verify_token("wrong-token".to_owned())); + + bootstrapper.forget_token(); + + assert!(!bootstrapper.verify_token("test-token".to_owned())); + assert!(bootstrapper.get_token().is_none()); + } + + /// Once the vault is bootstrapped, `Bootstrapper::new_in` must return with no token -- and + /// it must do so from `arbiter_settings.root_key_id` alone, taking the early return before + /// `home` is ever consulted. Uses `new_in` with a throwaway temp directory (never the + /// production `new`, which unconditionally resolves the real home directory before this + /// method even runs) so this test cannot touch the real filesystem regardless of outcome. + #[tokio::test] + async fn new_returns_no_token_once_the_vault_is_bootstrapped() { + let db = db::create_test_pool().await; + let mut conn = db.get().await.unwrap(); + + let root_key_history_id: i32 = insert_into(schema::root_key_history::table) + .values(&db::models::NewRootKeyHistory { + ciphertext: vec![0u8; 32], + tag: vec![0u8; 16], + root_key_encryption_nonce: vec![0u8; 24], + data_encryption_nonce: vec![0u8; 24], + schema_version: 1, + salt: vec![0u8; 16], + }) + .returning(schema::root_key_history::id) + .get_result(&mut conn) + .await + .unwrap(); + + update(schema::arbiter_settings::table) + .set(schema::arbiter_settings::root_key_id.eq(root_key_history_id)) + .execute(&mut conn) + .await + .unwrap(); + drop(conn); + + let home = tempfile::tempdir().unwrap(); + let bootstrapper = Bootstrapper::new_in(&db, home.path()).await.unwrap(); + + assert!(bootstrapper.get_token().is_none()); + } + + /// The file-reuse path (an operator has already registered, so bootstrap is unfinished) + /// must not adopt a corrupted token file as a live credential: it must reject it and + /// generate a fresh one instead. This is the Critical from the review, now reachable + /// safely because `new_in` takes a throwaway temp directory instead of the real home. + #[tokio::test] + async fn new_in_rejects_and_replaces_a_corrupted_token_file() { + let db = db::create_test_pool().await; + let mut conn = db.get().await.unwrap(); + + // At least one operator must have registered, or `new_in` would regenerate + // unconditionally regardless of the file (Important 2) and never exercise validation. + insert_into(schema::operator_identity::table) + .values(schema::operator_identity::public_key.eq(vec![0u8; 32])) + .execute(&mut conn) + .await + .unwrap(); + drop(conn); + + let home = tempfile::tempdir().unwrap(); + tokio::fs::write(home.path().join(BOOTSTRAP_PATH), "") + .await + .unwrap(); + + let bootstrapper = Bootstrapper::new_in(&db, home.path()).await.unwrap(); + + let token = bootstrapper + .get_token() + .expect("a fresh token must be generated"); + assert!(is_valid_token(&token)); + + // The replacement must also have landed on disk, not just in memory, so a restart + // reads back the same (now valid) token rather than the corrupted one again. + let on_disk = tokio::fs::read_to_string(home.path().join(BOOTSTRAP_PATH)) + .await + .unwrap(); + assert_eq!(on_disk, token); + } + + /// The second defect the task exists to fix: a restart between the first registration and + /// the completed bootstrap must not invalidate the token the other declared operators were + /// already given. With an operator registered and a well-formed file on disk, `new_in` has + /// to hand back exactly what it read instead of generating a replacement. + #[tokio::test] + async fn new_in_reuses_a_valid_token_file_across_a_restart() { + let db = db::create_test_pool().await; + let mut conn = db.get().await.unwrap(); + + // Without a registered operator, `new_in` regenerates unconditionally (Important 2 of + // the round-2 review) and never reaches the reuse path this test is about. + insert_into(schema::operator_identity::table) + .values(schema::operator_identity::public_key.eq(vec![0u8; 32])) + .execute(&mut conn) + .await + .unwrap(); + drop(conn); + + let home = tempfile::tempdir().unwrap(); + // Stands in for the token a previous run wrote and printed to the console. + let handed_out = "Zq7Z2rXaB90kLmNpQwErTyUiOpAsDfGhJkLzXcVbNmQwErTyUiOpAsDfGhJkLzXc"; + assert!(is_valid_token(handed_out)); + tokio::fs::write(home.path().join(BOOTSTRAP_PATH), handed_out) + .await + .unwrap(); + + let bootstrapper = Bootstrapper::new_in(&db, home.path()).await.unwrap(); + + assert_eq!(bootstrapper.get_token().as_deref(), Some(handed_out)); + } + + /// An empty file must not be adopted as a live credential: `is_correct_token`'s + /// `is_some_and` would enter its closure for `Some(String::new())`, and comparing two empty + /// byte slices is true, so an unauthenticated `Some("")` from the wire would otherwise + /// verify. Also pins the other corrupted-content shapes `is_valid_token` must reject. + #[test] + fn is_valid_token_rejects_anything_that_is_not_a_real_token() { + assert!(!is_valid_token("")); + assert!(!is_valid_token("too-short")); + assert!(!is_valid_token(&"a".repeat(TOKEN_LENGTH - 1))); + assert!(!is_valid_token(&"a".repeat(TOKEN_LENGTH + 1))); + // A trailing newline (e.g. from an editor) must not be silently accepted either. + assert!(!is_valid_token(&format!("{}\n", "a".repeat(TOKEN_LENGTH)))); + assert!(!is_valid_token(&"!".repeat(TOKEN_LENGTH))); + + assert!(is_valid_token(&"a".repeat(TOKEN_LENGTH))); + } +} diff --git a/server/crates/arbiter-server/src/actors/evm/mod.rs b/server/crates/arbiter-server/src/actors/evm/mod.rs index 033d11f..41ab023 100644 --- a/server/crates/arbiter-server/src/actors/evm/mod.rs +++ b/server/crates/arbiter-server/src/actors/evm/mod.rs @@ -1,9 +1,13 @@ use crate::{ - actors::vault::{CreateNew, Decrypt, Vault}, + actors::{ + proposal_manager::events::ProposalApproved, + vault::{CreateNew, Decrypt, Vault}, + }, crypto::integrity, db::{ DatabaseError, DatabasePool, - models::{self, EvmWalletId}, + models::{self, EvmWalletId, ProposalId}, + proposal::{ProposalKind, grant_wallet_access, one_off_transaction, persistent_grant}, schema, }, evm::{ @@ -23,8 +27,9 @@ use diesel::{ ExpressionMethods, OptionalExtension as _, QueryDsl, SelectableHelper as _, dsl::insert_into, }; use diesel_async::RunQueryDsl; -use kameo::{Actor, actor::ActorRef, messages}; +use kameo::{Actor, actor::ActorRef, messages, prelude::Message}; use rand::{SeedableRng, rng, rngs::StdRng}; +use tracing::error; pub use crate::evm::safe_signer; @@ -62,6 +67,55 @@ pub enum Error { #[error("Integrity violation: {0}")] Integrity(#[from] integrity::Error), + + #[error("Signing error: {0}")] + Sign(#[from] SignTransactionError), + + #[error( + "Grant timestamp {0} is outside the i32 range a grant boundary column can store \ + (Unix seconds, so no later than 2038-01-19T03:14:07Z)" + )] + InvalidTimestamp(i64), + + #[error("Wallet access {0} is revoked or does not exist")] + AccessNotActive(i32), +} + +/// Converts a grant boundary from Unix seconds. `None` in means "unbounded"; a value the +/// boundary column cannot store is an error, never a silently different window. +/// +/// The range is `i32`, not `i64`, because that is what actually reaches the database: +/// `SqliteTimestamp::to_sql` narrows to `i32` (`fixme! #84`), so `3_000_000_000` -- a +/// `valid_from` in 2065 -- would wrap to 1902 and open the grant immediately instead of in +/// forty years. Accepting only what round-trips keeps the grant that gets written the grant +/// that was voted on. +fn grant_timestamp(secs: Option) -> Result>, Error> { + secs.map(|s| { + let storable = i32::try_from(s).map_err(|_| Error::InvalidTimestamp(s))?; + chrono::DateTime::from_timestamp(i64::from(storable), 0).ok_or(Error::InvalidTimestamp(s)) + }) + .transpose() +} + +/// Refuses an access id that is revoked or absent, so nothing hangs a grant off it. +async fn ensure_access_active( + conn: &mut crate::db::DatabaseConnection, + access_id: i32, +) -> Result<(), Error> { + let active: bool = diesel::select(diesel::dsl::exists( + schema::evm_wallet_access::table + .filter(schema::evm_wallet_access::id.eq(access_id)) + .filter(schema::evm_wallet_access::revoked_at.is_null()), + )) + .get_result(conn) + .await + .map_err(DatabaseError::from)?; + + if active { + Ok(()) + } else { + Err(Error::AccessNotActive(access_id)) + } } #[derive(Actor)] @@ -160,29 +214,23 @@ impl EvmActor { } #[message] - #[expect(clippy::unused_async, reason = "reserved for impl")] - pub async fn operator_delete_grant(&mut self, _grant_id: i32) -> Result<(), Error> { - // let mut conn = self.db.get().await.map_err(DatabaseError::from)?; - // let vault = self.vault.clone(); + pub async fn operator_delete_grant(&mut self, grant_id: i32) -> Result<(), Error> { + let mut conn = self.db.get().await.map_err(DatabaseError::from)?; - // diesel_async::AsyncConnection::transaction(&mut conn, |conn| { - // Box::pin(async move { - // diesel::update(schema::evm_basic_grant::table) - // .filter(schema::evm_basic_grant::id.eq(grant_id)) - // .set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now())) - // .execute(conn) - // .await?; + let affected = diesel::update(schema::evm_basic_grant::table) + .filter(schema::evm_basic_grant::id.eq(grant_id)) + .set(schema::evm_basic_grant::revoked_at.eq(models::SqliteTimestamp::now())) + .execute(&mut conn) + .await + .map_err(DatabaseError::from)?; - // let signed = integrity::evm::load_signed_grant_by_basic_id(conn, grant_id).await?; + if affected == 0 { + return Err(Error::Database(DatabaseError::from( + diesel::result::Error::NotFound, + ))); + } - // diesel::result::QueryResult::Ok(()) - // }) - // }) - // .await - // .map_err(DatabaseError::from)?; - - // Ok(()) - todo!() + Ok(()) } #[message] @@ -214,6 +262,7 @@ impl EvmActor { .select(models::EvmWalletAccess::as_select()) .filter(schema::evm_wallet_access::wallet_id.eq(wallet.id)) .filter(schema::evm_wallet_access::client_id.eq(client_id)) + .filter(schema::evm_wallet_access::revoked_at.is_null()) .first(&mut conn) .await .optional() @@ -249,6 +298,7 @@ impl EvmActor { .select(models::EvmWalletAccess::as_select()) .filter(schema::evm_wallet_access::wallet_id.eq(wallet.id)) .filter(schema::evm_wallet_access::client_id.eq(client_id)) + .filter(schema::evm_wallet_access::revoked_at.is_null()) .first(&mut conn) .await .optional() @@ -273,3 +323,372 @@ impl EvmActor { Ok(signer.sign_transaction_sync(&mut transaction)?) } } + +impl Message for EvmActor { + type Reply = (); + + /// Every subscriber sees every approval and acts only on the kinds it owns. + async fn handle( + &mut self, + msg: ProposalApproved, + _ctx: &mut kameo::prelude::Context, + ) -> Self::Reply { + let result = match msg.kind { + ProposalKind::GrantWalletAccess(settings) => self.grant_wallet_access(&settings).await, + ProposalKind::ApprovePersistentGrant(settings) => { + self.create_persistent_grant(*settings).await + } + ProposalKind::ApproveOneOffTransaction(settings) => { + self.sign_one_off_transaction(msg.id, *settings).await + } + _ => return, + }; + + if let Err(error) = result { + error!( + ?error, + proposal_id = msg.id.to_raw(), + "Failed to execute an approved proposal" + ); + } + } +} + +impl EvmActor { + async fn grant_wallet_access( + &mut self, + settings: &grant_wallet_access::Settings, + ) -> Result<(), Error> { + let mut conn = self.db.get().await.map_err(DatabaseError::from)?; + + // Revives a previously revoked row instead of conflicting on it forever: + // `uniq_wallet_access` is a unique index on `(wallet_id, client_id)`. Visibility is + // all this restores -- revocation closes the grants that hung off the access, so a + // persistent grant needs its own vote again (§3.2). See + // `peers::operator::session::handlers::revoke_wallet_access`. + insert_into(schema::evm_wallet_access::table) + .values(( + schema::evm_wallet_access::wallet_id.eq(EvmWalletId::from_raw(settings.wallet_id)), + schema::evm_wallet_access::client_id.eq(settings.client_id), + )) + .on_conflict(( + schema::evm_wallet_access::wallet_id, + schema::evm_wallet_access::client_id, + )) + .do_update() + .set(schema::evm_wallet_access::revoked_at.eq(None::)) + .execute(&mut conn) + .await + .map_err(DatabaseError::from)?; + + Ok(()) + } + + async fn create_persistent_grant( + &mut self, + grant: persistent_grant::Settings, + ) -> Result<(), Error> { + use crate::evm::policies::{ + TransactionRateLimit, VolumeRateLimit, ether_transfer, token_transfers, + }; + use alloy::primitives::U256; + use chrono::Duration; + + // A persistent grant is only as good as the visibility it hangs off (§3.2, two + // separate votes). The proposal names the access id when it is created and can be + // approved much later, so the access may have been revoked in between; a grant + // against a revoked access would sit dormant and go live the moment anyone re-grants. + let mut conn = self.db.get().await.map_err(DatabaseError::from)?; + ensure_access_active(&mut conn, grant.wallet_access_id).await?; + drop(conn); + + let volume = |limit: persistent_grant::VolumeLimit| VolumeRateLimit { + max_volume: U256::from_be_bytes(limit.max_volume), + window: Duration::seconds(limit.window_secs), + }; + + let basic = SharedGrantSettings { + wallet_access_id: grant.wallet_access_id, + chain: grant.chain_id, + valid_from: grant_timestamp(grant.valid_from_secs)?, + valid_until: grant_timestamp(grant.valid_until_secs)?, + max_gas_fee_per_gas: grant.max_gas_fee_per_gas.map(U256::from_be_bytes), + max_priority_fee_per_gas: grant.max_priority_fee_per_gas.map(U256::from_be_bytes), + rate_limit: grant.rate_limit.map(|r| TransactionRateLimit { + count: r.count, + window: Duration::seconds(r.window_secs), + }), + }; + + let specific = match grant.specific { + persistent_grant::Specific::EtherTransfer { targets, limit } => { + SpecificGrant::EtherTransfer(ether_transfer::Settings { + target: targets.into_iter().map(Address::from).collect(), + limit: volume(limit), + }) + } + persistent_grant::Specific::TokenTransfer { + token_contract, + receiver, + volume_limits, + } => SpecificGrant::TokenTransfer(token_transfers::Settings { + token_contract: Address::from(token_contract), + target: receiver.map(Address::from), + volume_limits: volume_limits.into_iter().map(volume).collect(), + }), + }; + + self.operator_create_grant(basic, specific).await?; + + Ok(()) + } + + async fn sign_one_off_transaction( + &mut self, + proposal_id: ProposalId, + tx: one_off_transaction::Settings, + ) -> Result<(), Error> { + use alloy::{ + eips::eip2930::AccessList, + primitives::{Bytes, TxKind, U256}, + }; + + let transaction = TxEip1559 { + chain_id: tx.chain_id, + nonce: tx.nonce, + gas_limit: tx.gas_limit, + max_fee_per_gas: tx.max_fee_per_gas, + max_priority_fee_per_gas: tx.max_priority_fee_per_gas, + to: TxKind::Call(Address::from(tx.to)), + value: U256::from_be_bytes(tx.value), + input: Bytes::from(tx.input), + access_list: AccessList::default(), + }; + + let signature = self + .client_sign_transaction(tx.client_id, Address::from(tx.wallet_address), transaction) + .await?; + + let mut conn = self.db.get().await.map_err(DatabaseError::from)?; + one_off_transaction::store_signature(proposal_id, &signature, &mut conn) + .await + .map_err(DatabaseError::from)?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::{Error, EvmActor, ensure_access_active, grant_timestamp}; + use crate::db::{self, models, schema}; + + use diesel::{ExpressionMethods as _, QueryDsl as _, dsl::insert_into}; + use diesel_async::RunQueryDsl; + + #[test] + fn absent_timestamp_stays_absent() { + assert!(grant_timestamp(None).unwrap().is_none()); + } + + #[test] + fn in_range_timestamp_is_converted() { + let converted = grant_timestamp(Some(1_800_000_000)).unwrap(); + assert_eq!(converted.unwrap().timestamp(), 1_800_000_000); + } + + /// An unrepresentable expiry must not silently become "no expiry": that would widen the + /// grant beyond what was voted on. + #[test] + fn out_of_range_timestamp_is_an_error() { + let err = grant_timestamp(Some(i64::MAX)).unwrap_err(); + assert!(matches!(err, Error::InvalidTimestamp(i64::MAX))); + } + + /// A `valid_from` past 2038 is representable as a `DateTime` but not as the `i32` the + /// boundary column stores: `3_000_000_000` (2065) wraps to a negative, which reads back as + /// 1902 and makes the grant active immediately. Refusing it is the only way the grant + /// that lands can match the window that was voted on. + #[test] + fn a_timestamp_past_2038_is_an_error() { + let past_2038 = 3_000_000_000_i64; + assert!( + chrono::DateTime::from_timestamp(past_2038, 0).is_some(), + "the fixture must be a date chrono accepts, or it proves nothing about storage" + ); + + let err = grant_timestamp(Some(past_2038)).unwrap_err(); + assert!( + matches!(err, Error::InvalidTimestamp(got) if got == past_2038), + "expected an out-of-range error, got {err:?}" + ); + } + + /// The last second the boundary column can hold must still be accepted: the range check + /// has to stop at what storage can take, not short of it. + #[test] + fn the_last_storable_timestamp_is_accepted() { + let converted = grant_timestamp(Some(i64::from(i32::MAX))).unwrap(); + assert_eq!(converted.unwrap().timestamp(), i64::from(i32::MAX)); + } + + /// Seeds a wallet, a client and one access row between them, and returns the access id. + async fn seed_access(conn: &mut db::DatabaseConnection) -> i32 { + let root_key_id: models::RootKeyHistoryId = insert_into(schema::root_key_history::table) + .values(&models::NewRootKeyHistory { + ciphertext: vec![0u8; 32], + tag: vec![0u8; 16], + root_key_encryption_nonce: vec![0u8; 24], + data_encryption_nonce: vec![0u8; 24], + schema_version: 1, + salt: vec![0u8; 16], + }) + .returning(schema::root_key_history::id) + .get_result(conn) + .await + .unwrap(); + + let aead_id: i32 = insert_into(schema::aead_encrypted::table) + .values(&models::NewAeadEncrypted { + ciphertext: vec![0u8; 32], + tag: vec![0u8; 16], + current_nonce: vec![0u8; 24], + schema_version: 1, + associated_root_key_id: root_key_id, + created_at: chrono::Utc::now().into(), + }) + .returning(schema::aead_encrypted::id) + .get_result(conn) + .await + .unwrap(); + + let wallet_id: models::EvmWalletId = insert_into(schema::evm_wallet::table) + .values(( + schema::evm_wallet::address.eq(rand::random::<[u8; 20]>().to_vec()), + schema::evm_wallet::aead_encrypted_id.eq(aead_id), + )) + .returning(schema::evm_wallet::id) + .get_result(conn) + .await + .unwrap(); + + let metadata_id: i32 = insert_into(schema::client_metadata::table) + .values(schema::client_metadata::name.eq("test")) + .returning(schema::client_metadata::id) + .get_result(conn) + .await + .unwrap(); + + let client_id: i32 = insert_into(schema::program_client::table) + .values(( + schema::program_client::public_key.eq(rand::random::<[u8; 32]>().to_vec()), + schema::program_client::metadata_id.eq(metadata_id), + )) + .returning(schema::program_client::id) + .get_result(conn) + .await + .unwrap(); + + insert_into(schema::evm_wallet_access::table) + .values(( + schema::evm_wallet_access::wallet_id.eq(wallet_id), + schema::evm_wallet_access::client_id.eq(client_id), + )) + .returning(schema::evm_wallet_access::id) + .get_result(conn) + .await + .unwrap() + } + + /// Both directions, so a guard that refused everything could not pass: a live access is + /// let through, a revoked one is not. + #[tokio::test] + async fn only_a_live_access_passes_the_grant_guard() { + let pool = db::create_test_pool().await; + let mut conn = pool.get().await.unwrap(); + + let access_id = seed_access(&mut conn).await; + ensure_access_active(&mut conn, access_id) + .await + .expect("a live access must pass"); + + diesel::update(schema::evm_wallet_access::table) + .filter(schema::evm_wallet_access::id.eq(access_id)) + .set(schema::evm_wallet_access::revoked_at.eq(models::SqliteTimestamp::now())) + .execute(&mut conn) + .await + .unwrap(); + + let err = ensure_access_active(&mut conn, access_id) + .await + .expect_err("a revoked access must be refused"); + assert!( + matches!(err, Error::AccessNotActive(got) if got == access_id), + "expected AccessNotActive, got {err:?}" + ); + } + + /// The guard has to be wired into the executor, not just exist: an approved persistent + /// grant whose access was revoked between proposal and approval must not create a grant + /// that would go live again the moment anyone re-grants that access (§3.2). + #[tokio::test] + async fn an_approved_persistent_grant_refuses_a_revoked_access() { + use crate::actors::{GlobalActors, vault::Vault}; + use crate::db::proposal::persistent_grant; + use kameo::actor::Spawn as _; + + let pool = db::create_test_pool().await; + let mut conn = pool.get().await.unwrap(); + + let access_id = seed_access(&mut conn).await; + diesel::update(schema::evm_wallet_access::table) + .filter(schema::evm_wallet_access::id.eq(access_id)) + .set(schema::evm_wallet_access::revoked_at.eq(models::SqliteTimestamp::now())) + .execute(&mut conn) + .await + .unwrap(); + drop(conn); + + let vault = Vault::spawn( + Vault::new(pool.clone(), GlobalActors::spawn_message_bus()) + .await + .unwrap(), + ); + let mut evm_actor = EvmActor::new(vault, pool.clone()); + + let err = evm_actor + .create_persistent_grant(persistent_grant::Settings { + wallet_access_id: access_id, + chain_id: 1, + valid_from_secs: None, + valid_until_secs: None, + max_gas_fee_per_gas: None, + max_priority_fee_per_gas: None, + rate_limit: None, + specific: persistent_grant::Specific::EtherTransfer { + targets: vec![[0u8; 20]], + limit: persistent_grant::VolumeLimit { + max_volume: [0u8; 32], + window_secs: 3600, + }, + }, + }) + .await + .expect_err("a grant against a revoked access must be refused"); + assert!( + matches!(err, Error::AccessNotActive(got) if got == access_id), + "expected AccessNotActive, got {err:?}" + ); + + let grants: i64 = schema::evm_basic_grant::table + .filter(schema::evm_basic_grant::wallet_access_id.eq(access_id)) + .count() + .get_result(&mut pool.get().await.unwrap()) + .await + .unwrap(); + assert_eq!( + grants, 0, + "no grant row may be written for a revoked access" + ); + } +} diff --git a/server/crates/arbiter-server/src/actors/mod.rs b/server/crates/arbiter-server/src/actors/mod.rs index e9900ae..913ec0a 100644 --- a/server/crates/arbiter-server/src/actors/mod.rs +++ b/server/crates/arbiter-server/src/actors/mod.rs @@ -1,20 +1,31 @@ use crate::{ actors::{ - bootstrap::Bootstrapper, evm::EvmActor, flow_coordinator::FlowCoordinator, - operator_registry::OperatorRegistry, vault::Vault, + bootstrap::Bootstrapper, + evm::EvmActor, + flow_coordinator::FlowCoordinator, + operator_registry::OperatorRegistry, + proposal_manager::{ProposalManager, events::ProposalApproved}, + vault::{Vault, events}, + vault_coordinator::VaultCoordinator, }, db, }; use kameo::actor::{ActorRef, Spawn}; -use kameo_actors::{DeliveryStrategy, message_bus::MessageBus}; +use kameo_actors::{ + DeliveryStrategy, + message_bus::{MessageBus, Register}, +}; use thiserror::Error; +use tracing::error; pub mod bootstrap; pub mod evm; pub mod flow_coordinator; pub mod operator_registry; +pub mod proposal_manager; pub mod vault; +pub mod vault_coordinator; #[derive(Error, Debug)] pub enum SpawnError { @@ -30,9 +41,11 @@ pub enum SpawnError { pub struct GlobalActors { pub vault: ActorRef, pub bootstrapper: ActorRef, + pub vault_coordinator: ActorRef, pub flow_coordinator: ActorRef, pub operator_registry: ActorRef, pub evm: ActorRef, + pub proposal_manager: ActorRef, pub events: ActorRef, } @@ -42,18 +55,69 @@ impl GlobalActors { } pub async fn spawn(db: db::DatabasePool) -> Result { + let bootstrapper = Bootstrapper::new(&db).await?; + Self::spawn_with_bootstrapper(db, bootstrapper).await + } + + /// Test-facing: threads an explicit directory through to `Bootstrapper` instead of letting + /// it resolve the real home directory, so a test spawning a full `GlobalActors` can never + /// reach (let alone write to) the real `~/.arbiter/bootstrap_token`. Mirrors `spawn` + /// exactly, aside from where the token file lives. + pub async fn spawn_in( + db: db::DatabasePool, + home: &std::path::Path, + ) -> Result { + let bootstrapper = Bootstrapper::new_in(&db, home).await?; + Self::spawn_with_bootstrapper(db, bootstrapper).await + } + + async fn spawn_with_bootstrapper( + db: db::DatabasePool, + bootstrapper: Bootstrapper, + ) -> Result { let message_bus = Self::spawn_message_bus(); let key_holder = Vault::spawn(Vault::new(db.clone(), message_bus.clone()).await?); let operator_registry = OperatorRegistry::spawn(OperatorRegistry::default()); + let evm = EvmActor::spawn(EvmActor::new(key_holder.clone(), db.clone())); + let vault_coordinator = VaultCoordinator::spawn(VaultCoordinator::new( + db.clone(), + key_holder.clone(), + )); + let bootstrapper = Bootstrapper::spawn(bootstrapper); + // Approved proposals are executed by whoever owns the kind, not by ProposalManager. + for recipient in [ + evm.clone().recipient::(), + vault_coordinator.clone().recipient::(), + key_holder.clone().recipient::(), + ] { + let _ = message_bus.tell(Register(recipient)).await; + } + // The token guards bootstrap only: once the vault reports success, it must be retired. + // A dropped registration would leave the token valid forever with nothing else to + // notice, so a failure here is logged rather than silently discarded. + if let Err(err) = message_bus + .tell(Register( + bootstrapper.clone().recipient::(), + )) + .await + { + error!( + ?err, + "Failed to register Bootstrapper for the Bootstrapped event" + ); + } + Ok(Self { - bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?), - evm: EvmActor::spawn(EvmActor::new(key_holder.clone(), db)), + bootstrapper, + proposal_manager: ProposalManager::spawn(ProposalManager::new(db, message_bus.clone())), vault: key_holder, + vault_coordinator, flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::new( operator_registry.clone(), )), operator_registry, events: message_bus, + evm, }) } } diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs new file mode 100644 index 0000000..76167c0 --- /dev/null +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -0,0 +1,355 @@ +use crate::{ + actors::proposal_manager::{ + events::ProposalApproved, + store::{DieselProposalStore, ProposalStore, Tally}, + }, + crypto::governance, + db::{ + self, + models::{ + NewProposalVote, NewRecoveryProposalVote, OperatorIdentityId, Proposal, ProposalId, + ProposalStatus, RecoveryOperatorIdentityId, SqliteTimestamp, + }, + proposal::{ProposalKind, ProposalKindTag}, + }, +}; +use chrono::Utc; +use kameo::{Actor, actor::ActorRef, messages}; +use kameo_actors::message_bus::{MessageBus, Publish}; +use std::sync::Arc; +use tracing::warn; + +pub mod events; +pub mod store; + +pub const DEFAULT_TTL_SECS: u32 = 7 * 24 * 60 * 60; // 7 days +pub const MAX_TTL_SECS: u32 = DEFAULT_TTL_SECS; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VoteOutcome { + Pending, + Approved, + Rejected, +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Proposal not found")] + ProposalNotFound, + #[error("Proposal is not pending")] + ProposalNotPending, + #[error("Proposal has expired")] + ProposalExpired, + #[error("Requested TTL exceeds the maximum of {} seconds", MAX_TTL_SECS)] + TtlTooLong, + #[error("Operator already voted on this proposal")] + AlreadyVoted, + #[error("Invalid vote signature")] + InvalidSignature, + #[error("Operator not found")] + OperatorNotFound, + #[error("Database connection error: {0}")] + DatabaseConnection(#[from] db::PoolError), + #[error("Database query error: {0}")] + DatabaseQuery(#[from] diesel::result::Error), + #[error("Proposal manager is unavailable")] + Unavailable, + #[error("Recovery operators are sleeping")] + RecoveryNotActive, + #[error("Recovery operators may only vote on operator replacement")] + NotAllowedForRecoveryOperator, + #[error("A recovery wake-up is already pending or active")] + WakeupAlreadyPending, + #[error("No active recovery wake-up to cancel")] + NoActiveWakeup, +} + +#[derive(Debug)] +pub struct ProposalSummary { + pub id: ProposalId, + pub kind: ProposalKindTag, + pub initiator_id: OperatorIdentityId, + pub expires_at: SqliteTimestamp, + pub approve_count: i64, + pub reject_count: i64, +} + +#[derive(Actor)] +pub struct ProposalManager { + pub(crate) store: Arc, + pub(crate) events: ActorRef, +} + +impl ProposalManager { + pub fn new(db: db::DatabasePool, events: ActorRef) -> Self { + Self::with_store(Arc::new(DieselProposalStore::new(db)), events) + } + + /// Builds the actor over an arbitrary store, so tests can supply a mock. + pub(crate) const fn with_store( + store: Arc, + events: ActorRef, + ) -> Self { + Self { store, events } + } +} + +#[messages] +impl ProposalManager { + #[message] + pub async fn create_proposal( + &mut self, + kind: ProposalKind, + initiator_id: OperatorIdentityId, + ttl_secs: Option, + ) -> Result { + let ttl = ttl_secs.unwrap_or(DEFAULT_TTL_SECS); + if ttl > MAX_TTL_SECS { + return Err(Error::TtlTooLong); + } + let expires_at = + SqliteTimestamp::from(Utc::now() + chrono::Duration::seconds(i64::from(ttl))); + + self.store.create(kind, initiator_id, expires_at).await + } + + #[message] + pub async fn query_pending(&mut self, operator_id: OperatorIdentityId) -> Vec { + self.store + .pending_for(operator_id) + .await + .unwrap_or_else(|e| { + warn!(?e, "query_pending failed"); + vec![] + }) + } + + #[message] + pub async fn cast_vote( + &mut self, + proposal_id: ProposalId, + operator_id: OperatorIdentityId, + approve: bool, + signature: Vec, + ) -> Result { + let proposal = self.store.load(proposal_id).await?; + + // Checked before the status check so AlreadyVoted takes priority. + if self.store.has_voted(proposal_id, operator_id).await? { + return Err(Error::AlreadyVoted); + } + + Self::check_votable(&proposal)?; + + let public_key = self.store.operator_public_key(operator_id).await?; + governance::verify_vote(&public_key, proposal_id, approve, &signature) + .map_err(|_| Error::InvalidSignature)?; + + self.store + .record_vote(NewProposalVote { + proposal_id, + operator_id, + approve, + signature, + }) + .await?; + + let mut tally = self.store.tally(proposal_id).await?; + self.narrow_electorate(&proposal, &mut tally).await?; + + self.settle(&proposal, &tally).await + } + + /// §3.6: Any ordinary operator may request recovery wake-up. + /// Fails if a wake-up is already pending or active. + #[message] + pub async fn request_recovery_wakeup( + &mut self, + operator_id: OperatorIdentityId, + ) -> Result<(), Error> { + if self.store.has_uncancelled_wakeup().await? { + return Err(Error::WakeupAlreadyPending); + } + self.store.request_wakeup(operator_id).await + } + + /// §3.6: Any ordinary operator may cancel a pending wake-up request. + /// Fails if there is no uncancelled request. + #[message] + pub async fn cancel_recovery_wakeup( + &mut self, + operator_id: OperatorIdentityId, + ) -> Result<(), Error> { + if self.store.cancel_wakeup(operator_id).await? { + Ok(()) + } else { + Err(Error::NoActiveWakeup) + } + } + + /// §3.5: Recovery operators may only vote on operator replacement proposals. + /// §3.6: Voting is gated behind recovery being active (14-day window elapsed). + #[message] + pub async fn cast_recovery_vote( + &mut self, + proposal_id: ProposalId, + recovery_operator_id: RecoveryOperatorIdentityId, + approve: bool, + signature: Vec, + ) -> Result { + let proposal = self.store.load(proposal_id).await?; + + if proposal.kind != ProposalKindTag::ReplaceOperator { + return Err(Error::NotAllowedForRecoveryOperator); + } + + if !self.store.is_recovery_active().await? { + return Err(Error::RecoveryNotActive); + } + + if self + .store + .has_recovery_voted(proposal_id, recovery_operator_id) + .await? + { + return Err(Error::AlreadyVoted); + } + + Self::check_votable(&proposal)?; + + let public_key = self + .store + .recovery_operator_public_key(recovery_operator_id) + .await?; + governance::verify_vote(&public_key, proposal_id, approve, &signature) + .map_err(|_| Error::InvalidSignature)?; + + self.store + .record_recovery_vote(NewRecoveryProposalVote { + proposal_id, + recovery_operator_id, + approve, + signature, + }) + .await?; + + let mut tally = self.store.tally(proposal_id).await?; + self.narrow_electorate(&proposal, &mut tally).await?; + + self.settle(&proposal, &tally).await + } +} + +impl ProposalManager { + /// A vote only counts while the proposal is still open. + fn check_votable(proposal: &Proposal) -> Result<(), Error> { + if proposal.status != ProposalStatus::Pending { + return Err(Error::ProposalNotPending); + } + if proposal.expires_at.0 <= Utc::now() { + return Err(Error::ProposalExpired); + } + Ok(()) + } + + /// §3.5/§3.6: recovery operators join the electorate only for the kinds they may vote on, + /// and only once the wake-up window has elapsed. Counting them anywhere else makes the + /// rejection threshold unreachable and, for full-quorum kinds, approval unreachable too. + /// + /// The votes go out with the voters. A wake-up can be cancelled after recovery operators + /// have already voted (`cancel_wakeup` cancels any uncancelled request, elapsed or not), + /// so a `ReplaceOperator` tally can hold recovery approvals at the moment the committee + /// stops being eligible. Keeping those while zeroing only the electorate size would let + /// them cover ordinary votes that were never cast. + async fn narrow_electorate(&self, proposal: &Proposal, tally: &mut Tally) -> Result<(), Error> { + if !proposal.kind.recovery_may_vote() || !self.store.is_recovery_active().await? { + tally.drop_recovery(); + } + Ok(()) + } + + /// Pure quorum arithmetic — no I/O, so the rules can be tested directly (§3.3). + /// + /// A proposal is rejected once approval has become unreachable: even if every voter + /// who has not spoken yet approved, the threshold could not be met. + #[must_use] + pub(crate) const fn evaluate_quorum(tally: &Tally, requires_full_quorum: bool) -> VoteOutcome { + let total_eligible = tally.total_ordinary + tally.total_recovery; + + // No electorate, nothing to settle. Guarded before the branch rather than inside it: + // the full-quorum arm would otherwise set `threshold` to 0 and read an empty tally as + // unanimous approval. + if total_eligible <= 0 { + return VoteOutcome::Pending; + } + + #[expect( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_possible_wrap, + clippy::as_conversions, + reason = "operator counts are always small positive integers" + )] + // §3.3: key-rotation proposals require every eligible voter to approve. + // §3.5: when recovery is active, recovery operators are eligible too. + let threshold: i64 = if requires_full_quorum { + total_eligible + } else { + match crate::crypto::shamir::shamir_threshold(tally.total_ordinary as usize) { + Some(threshold) => threshold as i64, + // No ordinary operators means no electorate: nothing can settle. + None => return VoteOutcome::Pending, + } + }; + + if tally.approve() >= threshold { + VoteOutcome::Approved + } else if tally.reject() > total_eligible - threshold { + VoteOutcome::Rejected + } else { + VoteOutcome::Pending + } + } + + /// Applies the quorum rules to a fresh tally and records whatever they decide. + async fn settle(&self, proposal: &Proposal, tally: &Tally) -> Result { + let outcome = Self::evaluate_quorum(tally, proposal.kind.requires_full_quorum()); + + match outcome { + VoteOutcome::Approved => self.announce_approval(proposal).await?, + VoteOutcome::Rejected => { + self.store + .set_status(proposal.id, ProposalStatus::Rejected) + .await?; + } + VoteOutcome::Pending => {} + } + + Ok(outcome) + } + + /// Marks the proposal approved and hands the outcome to whoever owns that kind. + /// + /// The outcome is published, not executed: this actor coordinates voting and nothing + /// else. Executors subscribe on the bus, so a vote is answered once the quorum is + /// recorded rather than once the effect has landed. + async fn announce_approval(&self, proposal: &Proposal) -> Result<(), Error> { + self.store + .set_status(proposal.id, ProposalStatus::Approved) + .await?; + + let kind = self.store.load_kind(proposal.id, proposal.kind).await?; + let _ = self + .events + .tell(Publish(ProposalApproved { + id: proposal.id, + kind, + })) + .await; + + Ok(()) + } +} + +#[cfg(test)] +mod tests; diff --git a/server/crates/arbiter-server/src/actors/proposal_manager/events.rs b/server/crates/arbiter-server/src/actors/proposal_manager/events.rs new file mode 100644 index 0000000..2e2cfd7 --- /dev/null +++ b/server/crates/arbiter-server/src/actors/proposal_manager/events.rs @@ -0,0 +1,11 @@ +use crate::db::{models::ProposalId, proposal::ProposalKind}; + +/// Published once a proposal reaches its approval threshold. +/// +/// Executors subscribe on the global `MessageBus` and act on the kinds they own; +/// `ProposalManager` does not know who acts on an outcome, or whether anyone does. +#[derive(Debug, Clone)] +pub struct ProposalApproved { + pub id: ProposalId, + pub kind: ProposalKind, +} diff --git a/server/crates/arbiter-server/src/actors/proposal_manager/store.rs b/server/crates/arbiter-server/src/actors/proposal_manager/store.rs new file mode 100644 index 0000000..fd2c6d3 --- /dev/null +++ b/server/crates/arbiter-server/src/actors/proposal_manager/store.rs @@ -0,0 +1,431 @@ +//! Database access for [`super::ProposalManager`], behind a trait. +//! +//! The actor holds a `dyn ProposalStore` rather than a connection pool, so the quorum +//! rules can be exercised against a mock instead of a live SQLite file. + +use super::{Error, ProposalSummary}; +use crate::db::{ + self, + models::{ + NewProposal, NewProposalVote, NewRecoveryProposalVote, NewRecoveryWakeupRequest, + OperatorIdentityId, Proposal, ProposalId, ProposalStatus, RecoveryOperatorIdentityId, + SqliteTimestamp, + }, + proposal::{ProposalKind, ProposalKindTag}, + schema, +}; + +use async_trait::async_trait; +use chrono::Utc; +use diesel::{ + ExpressionMethods as _, QueryDsl, + dsl::{exists, select}, +}; +use diesel_async::{AsyncConnection as _, RunQueryDsl}; +use std::collections::HashMap; +use strum::IntoDiscriminant as _; + +/// Everything the quorum rules need to know about one proposal's votes. +/// +/// Votes are kept per electorate rather than pre-summed: an electorate can stop counting +/// between the vote and the tally (§3.6 -- recovery goes back to sleep the moment a wake-up +/// is cancelled), and the votes it already cast have to leave with it. A single `approve` +/// field would carry them past [`Tally::drop_recovery`] into a threshold computed for the +/// ordinary committee alone. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Tally { + pub ordinary_approve: i64, + pub ordinary_reject: i64, + pub recovery_approve: i64, + pub recovery_reject: i64, + pub total_ordinary: i64, + pub total_recovery: i64, +} + +impl Tally { + /// Approvals from every electorate that still counts. + pub const fn approve(&self) -> i64 { + self.ordinary_approve + self.recovery_approve + } + + /// Rejections from every electorate that still counts. + pub const fn reject(&self) -> i64 { + self.ordinary_reject + self.recovery_reject + } + + /// Takes the recovery committee out of the electorate, votes and all. The three numbers + /// go together: leaving the votes behind counts them against a threshold derived from an + /// electorate they are no longer part of. + pub const fn drop_recovery(&mut self) { + self.recovery_approve = 0; + self.recovery_reject = 0; + self.total_recovery = 0; + } +} + +#[cfg_attr(test, mockall::automock)] +#[async_trait] +pub trait ProposalStore: Send + Sync + 'static { + /// Writes the proposal and its kind-specific rows in one transaction. + async fn create( + &self, + kind: ProposalKind, + initiator_id: OperatorIdentityId, + expires_at: SqliteTimestamp, + ) -> Result; + + async fn load(&self, id: ProposalId) -> Result; + + async fn load_kind(&self, id: ProposalId, tag: ProposalKindTag) -> Result; + + async fn has_voted( + &self, + id: ProposalId, + operator_id: OperatorIdentityId, + ) -> Result; + + async fn has_recovery_voted( + &self, + id: ProposalId, + recovery_operator_id: RecoveryOperatorIdentityId, + ) -> Result; + + async fn operator_public_key(&self, id: OperatorIdentityId) -> Result, Error>; + + async fn recovery_operator_public_key( + &self, + id: RecoveryOperatorIdentityId, + ) -> Result, Error>; + + async fn record_vote(&self, vote: NewProposalVote) -> Result<(), Error>; + + async fn record_recovery_vote(&self, vote: NewRecoveryProposalVote) -> Result<(), Error>; + + /// Vote counts for one proposal, alongside the size of each electorate. + async fn tally(&self, id: ProposalId) -> Result; + + async fn set_status(&self, id: ProposalId, status: ProposalStatus) -> Result<(), Error>; + + /// Pending, unexpired proposals this operator has not voted on yet. + async fn pending_for( + &self, + operator_id: OperatorIdentityId, + ) -> Result, Error>; + + /// True once an uncancelled wake-up request has outlived the dispute window. + async fn is_recovery_active(&self) -> Result; + + /// True while any wake-up request stands, whether or not the window has elapsed. + async fn has_uncancelled_wakeup(&self) -> Result; + + async fn request_wakeup(&self, operator_id: OperatorIdentityId) -> Result<(), Error>; + + /// Returns false when there was no uncancelled request to cancel. + async fn cancel_wakeup(&self, operator_id: OperatorIdentityId) -> Result; +} + +pub struct DieselProposalStore { + db: db::DatabasePool, +} + +impl DieselProposalStore { + pub const fn new(db: db::DatabasePool) -> Self { + Self { db } + } +} + +/// `NotFound` means the row is absent, which every caller reports as its own error. +fn missing(absent: Error) -> impl FnOnce(diesel::result::Error) -> Error { + move |e| match e { + diesel::result::Error::NotFound => absent, + other => Error::DatabaseQuery(other), + } +} + +#[async_trait] +impl ProposalStore for DieselProposalStore { + async fn create( + &self, + kind: ProposalKind, + initiator_id: OperatorIdentityId, + expires_at: SqliteTimestamp, + ) -> Result { + let id = self + .db + .get() + .await? + .transaction(async |conn| { + let id: ProposalId = diesel::insert_into(schema::proposal::table) + .values(&NewProposal { + kind: kind.discriminant(), + initiator_id, + expires_at, + }) + .returning(schema::proposal::id) + .get_result(conn) + .await?; + db::proposal::insert_kind(conn, id, &kind).await?; + Ok::<_, diesel::result::Error>(id) + }) + .await?; + + Ok(id) + } + + async fn load(&self, id: ProposalId) -> Result { + let mut conn = self.db.get().await?; + schema::proposal::table + .find(id) + .first(&mut conn) + .await + .map_err(missing(Error::ProposalNotFound)) + } + + async fn load_kind(&self, id: ProposalId, tag: ProposalKindTag) -> Result { + let mut conn = self.db.get().await?; + db::proposal::load_kind(&mut conn, id, tag) + .await + .map_err(Error::from) + } + + async fn has_voted( + &self, + id: ProposalId, + operator_id: OperatorIdentityId, + ) -> Result { + let mut conn = self.db.get().await?; + select(exists( + schema::proposal_vote::table + .filter(schema::proposal_vote::proposal_id.eq(id)) + .filter(schema::proposal_vote::operator_id.eq(operator_id)), + )) + .get_result(&mut conn) + .await + .map_err(Error::from) + } + + async fn has_recovery_voted( + &self, + id: ProposalId, + recovery_operator_id: RecoveryOperatorIdentityId, + ) -> Result { + let mut conn = self.db.get().await?; + select(exists( + schema::recovery_proposal_vote::table + .filter(schema::recovery_proposal_vote::proposal_id.eq(id)) + .filter( + schema::recovery_proposal_vote::recovery_operator_id.eq(recovery_operator_id), + ), + )) + .get_result(&mut conn) + .await + .map_err(Error::from) + } + + async fn operator_public_key(&self, id: OperatorIdentityId) -> Result, Error> { + let mut conn = self.db.get().await?; + schema::operator_identity::table + .find(id) + .select(schema::operator_identity::public_key) + .first(&mut conn) + .await + .map_err(missing(Error::OperatorNotFound)) + } + + async fn recovery_operator_public_key( + &self, + id: RecoveryOperatorIdentityId, + ) -> Result, Error> { + let mut conn = self.db.get().await?; + schema::recovery_operator_identity::table + .find(id) + .select(schema::recovery_operator_identity::public_key) + .first(&mut conn) + .await + .map_err(missing(Error::OperatorNotFound)) + } + + async fn record_vote(&self, vote: NewProposalVote) -> Result<(), Error> { + let mut conn = self.db.get().await?; + diesel::insert_into(schema::proposal_vote::table) + .values(&vote) + .execute(&mut conn) + .await?; + Ok(()) + } + + async fn record_recovery_vote(&self, vote: NewRecoveryProposalVote) -> Result<(), Error> { + let mut conn = self.db.get().await?; + diesel::insert_into(schema::recovery_proposal_vote::table) + .values(&vote) + .execute(&mut conn) + .await?; + Ok(()) + } + + async fn tally(&self, id: ProposalId) -> Result { + let mut conn = self.db.get().await?; + + let ordinary_approve: i64 = schema::proposal_vote::table + .filter(schema::proposal_vote::proposal_id.eq(id)) + .filter(schema::proposal_vote::approve.eq(true)) + .count() + .get_result(&mut conn) + .await?; + let recovery_approve: i64 = schema::recovery_proposal_vote::table + .filter(schema::recovery_proposal_vote::proposal_id.eq(id)) + .filter(schema::recovery_proposal_vote::approve.eq(true)) + .count() + .get_result(&mut conn) + .await?; + + let ordinary_reject: i64 = schema::proposal_vote::table + .filter(schema::proposal_vote::proposal_id.eq(id)) + .filter(schema::proposal_vote::approve.eq(false)) + .count() + .get_result(&mut conn) + .await?; + let recovery_reject: i64 = schema::recovery_proposal_vote::table + .filter(schema::recovery_proposal_vote::proposal_id.eq(id)) + .filter(schema::recovery_proposal_vote::approve.eq(false)) + .count() + .get_result(&mut conn) + .await?; + + let total_ordinary: i64 = schema::operator_identity::table + .count() + .get_result(&mut conn) + .await?; + let total_recovery: i64 = schema::recovery_operator_identity::table + .count() + .get_result(&mut conn) + .await?; + + Ok(Tally { + ordinary_approve, + ordinary_reject, + recovery_approve, + recovery_reject, + total_ordinary, + total_recovery, + }) + } + + async fn set_status(&self, id: ProposalId, status: ProposalStatus) -> Result<(), Error> { + let mut conn = self.db.get().await?; + diesel::update(schema::proposal::table.find(id)) + .set(schema::proposal::status.eq(status)) + .execute(&mut conn) + .await?; + Ok(()) + } + + async fn pending_for( + &self, + operator_id: OperatorIdentityId, + ) -> Result, Error> { + #[expect( + clippy::cast_possible_truncation, + clippy::as_conversions, + reason = "fixme! #84; this will break in 2038" + )] + let now_ts = Utc::now().timestamp() as i32; + + let mut conn = self.db.get().await?; + + let voted_ids: Vec = schema::proposal_vote::table + .filter(schema::proposal_vote::operator_id.eq(operator_id)) + .select(schema::proposal_vote::proposal_id) + .load(&mut conn) + .await?; + + let proposals: Vec = schema::proposal::table + .filter(schema::proposal::status.eq(ProposalStatus::Pending)) + .filter(schema::proposal::expires_at.gt(now_ts)) + .filter(diesel::dsl::not(schema::proposal::id.eq_any(&voted_ids))) + .load(&mut conn) + .await?; + + let ids: Vec = proposals.iter().map(|p| p.id).collect(); + let tallies: Vec<(ProposalId, bool, i64)> = schema::proposal_vote::table + .filter(schema::proposal_vote::proposal_id.eq_any(&ids)) + .group_by(( + schema::proposal_vote::proposal_id, + schema::proposal_vote::approve, + )) + .select(( + schema::proposal_vote::proposal_id, + schema::proposal_vote::approve, + diesel::dsl::count_star(), + )) + .load(&mut conn) + .await?; + + let mut by_proposal: HashMap = HashMap::new(); + for (proposal_id, approve, count) in tallies { + let entry = by_proposal.entry(proposal_id).or_insert((0, 0)); + if approve { + entry.0 += count; + } else { + entry.1 += count; + } + } + + Ok(proposals + .into_iter() + .map(|p| { + let (approve_count, reject_count) = + by_proposal.get(&p.id).copied().unwrap_or((0, 0)); + ProposalSummary { + id: p.id, + kind: p.kind, + initiator_id: p.initiator_id, + expires_at: p.expires_at, + approve_count, + reject_count, + } + }) + .collect()) + } + + async fn is_recovery_active(&self) -> Result { + let mut conn = self.db.get().await?; + db::recovery::is_active(&mut conn) + .await + .map_err(Error::from) + } + + async fn has_uncancelled_wakeup(&self) -> Result { + let mut conn = self.db.get().await?; + select(exists(schema::recovery_wakeup_request::table.filter( + schema::recovery_wakeup_request::cancelled_at.is_null(), + ))) + .get_result(&mut conn) + .await + .map_err(Error::from) + } + + async fn request_wakeup(&self, operator_id: OperatorIdentityId) -> Result<(), Error> { + let mut conn = self.db.get().await?; + diesel::insert_into(schema::recovery_wakeup_request::table) + .values(&NewRecoveryWakeupRequest { + requested_by: operator_id, + }) + .execute(&mut conn) + .await?; + Ok(()) + } + + async fn cancel_wakeup(&self, operator_id: OperatorIdentityId) -> Result { + let mut conn = self.db.get().await?; + let rows = diesel::update(schema::recovery_wakeup_request::table) + .filter(schema::recovery_wakeup_request::cancelled_at.is_null()) + .set(( + schema::recovery_wakeup_request::cancelled_by.eq(Some(operator_id)), + schema::recovery_wakeup_request::cancelled_at.eq(Some(SqliteTimestamp::now())), + )) + .execute(&mut conn) + .await?; + Ok(rows > 0) + } +} diff --git a/server/crates/arbiter-server/src/actors/proposal_manager/tests.rs b/server/crates/arbiter-server/src/actors/proposal_manager/tests.rs new file mode 100644 index 0000000..632f8eb --- /dev/null +++ b/server/crates/arbiter-server/src/actors/proposal_manager/tests.rs @@ -0,0 +1,395 @@ +//! The quorum rules, exercised without a database. +//! +//! These assertions are the point of [`super::store::ProposalStore`]: until the actor took +//! its data through a trait, checking that two of three operators carry an ordinary +//! proposal meant opening SQLite and registering operators first. + +use super::{ + ProposalManager, VoteOutcome, + store::{MockProposalStore, Tally}, +}; +use crate::{ + actors::GlobalActors, + crypto::governance::vote_message, + db::{ + models::{OperatorIdentityId, Proposal, ProposalId, ProposalStatus, SqliteTimestamp}, + proposal::ProposalKindTag, + }, +}; +use arbiter_crypto::authn::{SigningContext, SigningKey}; +use chrono::{Duration, Utc}; +use std::sync::Arc; + +/// A tally where every vote came from the ordinary committee. +const fn tally(approve: i64, reject: i64, ordinary: i64, recovery: i64) -> Tally { + Tally { + ordinary_approve: approve, + ordinary_reject: reject, + recovery_approve: 0, + recovery_reject: 0, + total_ordinary: ordinary, + total_recovery: recovery, + } +} + +/// A tally with votes from both committees, in the order approve/reject per committee. +const fn mixed_tally( + ordinary_approve: i64, + ordinary_reject: i64, + recovery_approve: i64, + recovery_reject: i64, + total_ordinary: i64, + total_recovery: i64, +) -> Tally { + Tally { + ordinary_approve, + ordinary_reject, + recovery_approve, + recovery_reject, + total_ordinary, + total_recovery, + } +} + +#[test] +fn simple_majority_approves_at_two_of_three() { + assert_eq!( + ProposalManager::evaluate_quorum(&tally(2, 0, 3, 0), false), + VoteOutcome::Approved + ); +} + +#[test] +fn one_of_three_is_not_yet_a_majority() { + assert_eq!( + ProposalManager::evaluate_quorum(&tally(1, 0, 3, 0), false), + VoteOutcome::Pending + ); +} + +#[test] +fn full_quorum_kind_needs_every_voter() { + assert_eq!( + ProposalManager::evaluate_quorum(&tally(2, 0, 3, 0), true), + VoteOutcome::Pending, + "two of three must not carry a key-rotation proposal" + ); +} + +#[test] +fn recovery_voters_count_towards_full_quorum() { + assert_eq!( + ProposalManager::evaluate_quorum(&mixed_tally(2, 0, 1, 0, 2, 1), true), + VoteOutcome::Approved + ); + assert_eq!( + ProposalManager::evaluate_quorum(&mixed_tally(2, 0, 0, 0, 2, 1), true), + VoteOutcome::Pending, + "the sleeping recovery operator still owes a vote" + ); +} + +/// An empty committee cannot approve anything. Both arms have to say so: the full-quorum arm +/// derives its threshold from the electorate, so with nobody eligible it would compare 0 +/// approvals against a threshold of 0 and call that unanimous. +#[test] +fn an_empty_electorate_settles_nothing() { + assert_eq!( + ProposalManager::evaluate_quorum(&tally(0, 0, 0, 0), true), + VoteOutcome::Pending, + "a full-quorum proposal must not pass with no eligible voters" + ); + assert_eq!( + ProposalManager::evaluate_quorum(&tally(0, 0, 0, 0), false), + VoteOutcome::Pending + ); +} + +#[test] +fn rejection_is_decided_once_approval_is_unreachable() { + // Threshold is 2 of 3, so two rejections leave at most one approval available. + assert_eq!( + ProposalManager::evaluate_quorum(&tally(0, 2, 3, 0), false), + VoteOutcome::Rejected + ); + assert_eq!( + ProposalManager::evaluate_quorum(&tally(0, 1, 3, 0), false), + VoteOutcome::Pending, + "one rejection still leaves two approvals reachable" + ); +} + +#[test] +fn a_single_rejection_sinks_a_full_quorum_proposal() { + assert_eq!( + ProposalManager::evaluate_quorum(&tally(2, 1, 3, 0), true), + VoteOutcome::Rejected + ); +} + +fn pending_proposal(id: ProposalId, kind: ProposalKindTag) -> Proposal { + let now = Utc::now(); + Proposal { + id, + kind, + initiator_id: OperatorIdentityId::from_raw(1), + created_at: SqliteTimestamp::from(now), + expires_at: SqliteTimestamp::from(now + Duration::days(1)), + status: ProposalStatus::Pending, + } +} + +/// The mock earns its keep here: reaching quorum must flip the stored status to +/// `Approved` exactly once. Signature verification stays real -- only the database is +/// stubbed out. +#[tokio::test] +async fn reaching_quorum_marks_the_proposal_approved() { + let id = ProposalId::from_raw(1); + let voter = OperatorIdentityId::from_raw(1); + let key = SigningKey::generate(); + let signature = key + .sign_message(&vote_message(id, true), SigningContext::GovernanceVote) + .expect("signing a vote must succeed"); + let public_key = key.public_key().to_bytes(); + + let mut store = MockProposalStore::new(); + store + .expect_load() + .returning(move |id| Ok(pending_proposal(id, ProposalKindTag::TriggerRekey))); + store.expect_has_voted().returning(|_, _| Ok(false)); + store + .expect_operator_public_key() + .returning(move |_| Ok(public_key.clone())); + store.expect_record_vote().returning(|_| Ok(())); + store.expect_is_recovery_active().returning(|| Ok(false)); + store.expect_tally().returning(|_| Ok(tally(1, 0, 1, 0))); + store + .expect_set_status() + .withf(move |got, status| *got == id && *status == ProposalStatus::Approved) + .times(1) + .returning(|_, _| Ok(())); + store + .expect_load_kind() + .returning(|_, _| Ok(crate::db::proposal::ProposalKind::TriggerRekey)); + + let mut manager = + ProposalManager::with_store(Arc::new(store), GlobalActors::spawn_message_bus()); + + let outcome = manager + .cast_vote(id, voter, true, signature.to_bytes()) + .await + .expect("a valid vote must be accepted"); + + assert_eq!(outcome, VoteOutcome::Approved); +} + +/// A vote that does not reach the threshold must leave the stored status alone. +#[tokio::test] +async fn a_vote_short_of_quorum_does_not_touch_the_status() { + let id = ProposalId::from_raw(7); + let voter = OperatorIdentityId::from_raw(2); + let key = SigningKey::generate(); + let signature = key + .sign_message(&vote_message(id, true), SigningContext::GovernanceVote) + .expect("signing a vote must succeed"); + let public_key = key.public_key().to_bytes(); + + let mut store = MockProposalStore::new(); + store + .expect_load() + .returning(move |id| Ok(pending_proposal(id, ProposalKindTag::ApproveSdkClient))); + store.expect_has_voted().returning(|_, _| Ok(false)); + store + .expect_operator_public_key() + .returning(move |_| Ok(public_key.clone())); + store.expect_record_vote().returning(|_| Ok(())); + store.expect_is_recovery_active().returning(|| Ok(false)); + store.expect_tally().returning(|_| Ok(tally(1, 0, 3, 0))); + store.expect_set_status().never(); + + let mut manager = + ProposalManager::with_store(Arc::new(store), GlobalActors::spawn_message_bus()); + + let outcome = manager + .cast_vote(id, voter, true, signature.to_bytes()) + .await + .expect("a valid vote must be accepted"); + + assert_eq!(outcome, VoteOutcome::Pending); +} + +/// Drives one `cast_vote` on a proposal of the given `kind` through a mocked store and +/// returns the outcome. `recovery_active` decides what `is_recovery_active` reports; +/// `expected_status` is the status a settled outcome must be persisted under, or `None` for +/// a caller that expects the vote to leave the proposal pending. +/// +/// `set_status` carries an argument matcher but no `.times()`, and `None` relaxes even the +/// matcher: the caller's own `assert_eq!` on the outcome is what pins the behaviour, so a +/// regression fails on a readable diff rather than on a mockall cardinality panic that hides +/// what the actor actually computed. +async fn settle_vote_with( + kind: ProposalKindTag, + tally: Tally, + recovery_active: bool, + expected_status: Option, +) -> VoteOutcome { + let id = ProposalId::from_raw(11); + let voter = OperatorIdentityId::from_raw(1); + let key = SigningKey::generate(); + let signature = key + .sign_message(&vote_message(id, true), SigningContext::GovernanceVote) + .expect("signing a vote must succeed"); + let public_key = key.public_key().to_bytes(); + + let mut store = MockProposalStore::new(); + store + .expect_load() + .returning(move |id| Ok(pending_proposal(id, kind))); + store.expect_has_voted().returning(|_, _| Ok(false)); + store + .expect_operator_public_key() + .returning(move |_| Ok(public_key.clone())); + store.expect_record_vote().returning(|_| Ok(())); + store + .expect_is_recovery_active() + .returning(move || Ok(recovery_active)); + store.expect_tally().returning(move |_| Ok(tally)); + store + .expect_set_status() + .withf(move |_, status| { + expected_status + .as_ref() + .is_none_or(|expected| status == expected) + }) + .returning(|_, _| Ok(())); + store.expect_load_kind().returning(move |_, _| { + Ok(match kind { + ProposalKindTag::TriggerRekey => crate::db::proposal::ProposalKind::TriggerRekey, + ProposalKindTag::ApproveSdkClient => { + crate::db::proposal::ProposalKind::ApproveSdkClient( + crate::db::proposal::approve_sdk_client::Settings { client_id: 1 }, + ) + } + ProposalKindTag::ReplaceOperator => crate::db::proposal::ProposalKind::ReplaceOperator( + crate::db::proposal::replace_operator::Settings { + old_operator_id: OperatorIdentityId::from_raw(1), + new_pubkey: vec![0u8; 32], + }, + ), + other => unreachable!("settle_vote_with has no load_kind fixture for {other:?}"), + }) + }); + + let mut manager = + ProposalManager::with_store(Arc::new(store), GlobalActors::spawn_message_bus()); + + manager + .cast_vote(id, voter, true, signature.to_bytes()) + .await + .expect("a valid vote must be accepted") +} + +/// The full-quorum rejection path is insensitive to electorate size by construction: +/// `threshold == total_eligible` there, so `total_eligible - threshold` is always 0 and any +/// single rejection settles the proposal, whether or not recovery operators are (wrongly) +/// counted. This does not exercise the electorate-narrowing fix -- see +/// `unanimous_ordinary_rejection_rejects_a_non_full_quorum_proposal_while_recovery_is_awake` +/// below for the test that does -- it just pins that `cast_vote` still writes `Rejected` +/// through `settle` for a full-quorum kind. +#[tokio::test] +async fn unanimous_rejection_settles_a_full_quorum_rekey_via_cast_vote() { + let outcome = settle_vote_with( + ProposalKindTag::TriggerRekey, + tally(0, 3, 3, 2), + /* recovery_active */ true, + Some(ProposalStatus::Rejected), + ) + .await; + + assert_eq!(outcome, VoteOutcome::Rejected); +} + +/// §3.3 full quorum for a rekey means every *ordinary* operator, not every identity on file. +#[tokio::test] +async fn unanimous_ordinary_approval_approves_a_rekey_while_recovery_is_awake() { + let outcome = settle_vote_with( + ProposalKindTag::TriggerRekey, + tally(3, 0, 3, 2), + /* recovery_active */ true, + Some(ProposalStatus::Approved), + ) + .await; + + assert_eq!(outcome, VoteOutcome::Approved); +} + +/// §3.5: recovery operators do not vote on `ApproveSdkClient`, so they must not inflate its +/// electorate. Before the fix, `total_eligible` counted them anyway (5, not 3), so the +/// rejection test `reject > total_eligible - threshold` became `3 > 5 - 2 = 3`, which is +/// false -- three unanimous rejections left the proposal `Pending` forever, since a fourth +/// vote could never arrive. After the fix, `total_eligible` is 3 and the same test becomes +/// `3 > 3 - 2 = 1`, which settles it. +#[tokio::test] +async fn unanimous_ordinary_rejection_rejects_a_non_full_quorum_proposal_while_recovery_is_awake() { + let outcome = settle_vote_with( + ProposalKindTag::ApproveSdkClient, + tally(0, 3, 3, 2), + /* recovery_active */ true, + Some(ProposalStatus::Rejected), + ) + .await; + + assert_eq!(outcome, VoteOutcome::Rejected); +} + +/// §3.5/§3.6: `ReplaceOperator` is the one kind recovery may vote on, so it is the only kind +/// where whether recovery is awake is observable at all -- for every other kind +/// `narrow_electorate` zeroes `total_recovery` regardless of `is_recovery_active`, short- +/// circuiting before that call. A sleeping recovery electorate must not raise the bar here: +/// with 1 ordinary operator and 2 (asleep) recovery operators, the lone ordinary approval +/// must already reach full quorum. +#[tokio::test] +async fn sleeping_recovery_operators_do_not_count_towards_quorum() { + let outcome = settle_vote_with( + ProposalKindTag::ReplaceOperator, + tally(1, 0, 1, 2), + /* recovery_active */ false, + Some(ProposalStatus::Approved), + ) + .await; + + assert_eq!(outcome, VoteOutcome::Approved); +} + +/// The sequence the whole-branch review worked through, on a `ReplaceOperator` with 3 +/// ordinary and 2 recovery operators (§3.3: full quorum). Both recovery operators approve +/// while awake; one ordinary operator approves; another ordinary operator then cancels the +/// wake-up -- `cancel_wakeup` cancels an uncancelled request whether or not its window has +/// elapsed, so the committee goes straight back to sleep with its votes on the record; a +/// second ordinary operator approves. +/// +/// The store now reports 4 approvals, 2 of them from a committee that is no longer eligible. +/// Narrowing the electorate has to drop those votes along with the voters: what is left is 2 +/// of 3 ordinary approvals, and a full quorum needs all three. Counting the electorate down +/// to 3 while keeping all 4 votes would replace an operator on two ordinary approvals. +#[tokio::test] +async fn recovery_votes_leave_with_the_committee_that_cast_them() { + let outcome = settle_vote_with( + ProposalKindTag::ReplaceOperator, + mixed_tally( + /* ordinary_approve */ 2, /* ordinary_reject */ 0, + /* recovery_approve */ 2, /* recovery_reject */ 0, + /* total_ordinary */ 3, /* total_recovery */ 2, + ), + /* recovery_active */ false, + None, + ) + .await; + + assert_eq!( + outcome, + VoteOutcome::Pending, + "two of three ordinary approvals must not carry a full-quorum proposal, whatever a \ + sleeping recovery committee voted earlier" + ); +} diff --git a/server/crates/arbiter-server/src/actors/vault/mod.rs b/server/crates/arbiter-server/src/actors/vault/mod.rs index 51b7e33..f994f72 100644 --- a/server/crates/arbiter-server/src/actors/vault/mod.rs +++ b/server/crates/arbiter-server/src/actors/vault/mod.rs @@ -1,13 +1,15 @@ use crate::{ + actors::proposal_manager::events::ProposalApproved, crypto::{ - KeyCell, derive_key, + KeyCell, encryption::v1::{self, Nonce}, - integrity::v1::HmacSha256, + integrity::{self, v1::HmacSha256}, }, db::{ self, models::{self, RootKeyHistory, RootKeyHistoryId}, - schema::{self}, + proposal::ProposalKind, + schema, }, }; use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; @@ -19,7 +21,7 @@ use diesel::{ }; use diesel_async::{AsyncConnection, RunQueryDsl}; use hmac::{KeyInit as _, Mac as _}; -use kameo::{Actor, Reply, actor::ActorRef, messages}; +use kameo::{Actor, Reply, actor::ActorRef, messages, prelude::Message}; use kameo_actors::message_bus::{MessageBus, Publish}; use strum::{EnumDiscriminants, IntoDiscriminant}; use tracing::{error, info}; @@ -90,7 +92,6 @@ pub struct Vault { events: ActorRef, } -#[messages] impl Vault { pub async fn new(db: db::DatabasePool, events: ActorRef) -> Result { let state = { @@ -113,7 +114,7 @@ impl Vault { Ok(Self { db, state, events }) } - // Exclusive transaction to avoid race condtions if multiple vaults write + // Exclusive transaction to avoid race conditions if multiple vaults write // additional layer of protection against nonce-reuse async fn get_new_nonce( pool: &db::DatabasePool, @@ -158,33 +159,37 @@ impl Vault { State::Sealed { .. } => Err(Error::Sealed), } } +} +#[messages] +impl Vault { #[message] - pub async fn bootstrap(&mut self, seal_key_raw: SafeCell>) -> Result<(), Error> { - if !matches!(self.state, State::Unbootstrapped) { + pub async fn bootstrap(&mut self, mut seal_key: KeyCell) -> Result<(), Error> { + if !matches!(&self.state, State::Unbootstrapped) { return Err(Error::AlreadyBootstrapped); } - let salt = v1::generate_salt(); - let mut seal_key = derive_key(seal_key_raw, &salt); + let mut root_key = KeyCell::new_secure_random(); // Zero nonces are fine because they are one-time let root_key_nonce = Nonce::default(); let data_encryption_nonce = Nonce::default(); - let root_key_ciphertext: Vec = root_key.0.read_inline(|reader| { - let root_key_reader = reader.as_slice(); + // Generate salt (kept for schema compat) + let root_key_salt = v1::generate_salt(); + + let root_key_ciphertext: Vec = root_key.0.read_inline(|rk| { seal_key - .encrypt(&root_key_nonce, v1::ROOT_KEY_TAG, root_key_reader) + .encrypt(&root_key_nonce, v1::ROOT_KEY_TAG, rk.as_slice()) .map_err(|err| { error!(?err, "Fatal bootstrap error"); Error::Encryption(err) }) })?; + let data_encryption_nonce_bytes = data_encryption_nonce.to_vec(); let mut conn = self.db.get().await?; - let data_encryption_nonce_bytes = data_encryption_nonce.to_vec(); let root_key_history_id = conn .transaction(async |conn| { let root_key_history_id = insert_into(schema::root_key_history::table) @@ -194,7 +199,7 @@ impl Vault { root_key_encryption_nonce: root_key_nonce.to_vec(), data_encryption_nonce: data_encryption_nonce_bytes.clone(), schema_version: 1, - salt: salt.to_vec(), + salt: root_key_salt.to_vec(), }) .returning(schema::root_key_history::id) .get_result(&mut *conn) @@ -217,58 +222,55 @@ impl Vault { }); info!("Vault bootstrapped successfully"); - let _ = self.events.tell(Publish(events::Bootstrapped)).await; + if let Err(err) = self.events.tell(Publish(events::Bootstrapped)).await { + error!(?err, "Failed to publish Bootstrapped event"); + } Ok(()) } #[message] - pub async fn try_unseal(&mut self, seal_key_raw: SafeCell>) -> Result<(), Error> { + pub async fn try_unseal(&mut self, mut seal_key: KeyCell) -> Result<(), Error> { let State::Sealed { root_key_history_id, } = &self.state else { return Err(Error::NotBootstrapped); }; + let root_key_history_id = *root_key_history_id; - // We don't want to hold connection while doing expensive KDF work + // We don't want to hold connection while doing expensive work let current_key = { let mut conn = self.db.get().await?; schema::root_key_history::table - .filter(schema::root_key_history::id.eq(*root_key_history_id)) + .filter(schema::root_key_history::id.eq(root_key_history_id)) .select(RootKeyHistory::as_select()) .first(&mut conn) .await? }; - let salt = ¤t_key.salt; - let salt = v1::Salt::try_from(salt.as_slice()).map_err(|_| { - error!("Broken database: invalid salt for root key"); - Error::BrokenDatabase - })?; - let mut seal_key = derive_key(seal_key_raw, &salt); - - let mut root_key = SafeCell::new(current_key.ciphertext.clone()); - let nonce = Nonce::try_from(current_key.root_key_encryption_nonce.as_slice()).map_err(|()| { error!("Broken database: invalid nonce for root key"); Error::BrokenDatabase })?; + let mut root_key_bytes = SafeCell::new(current_key.ciphertext.clone()); seal_key - .decrypt_in_place(&nonce, v1::ROOT_KEY_TAG, &mut root_key) + .decrypt_in_place(&nonce, v1::ROOT_KEY_TAG, &mut root_key_bytes) .map_err(|err| { error!(?err, "Failed to unseal root key: invalid seal key"); Error::InvalidKey })?; + let root_key = KeyCell::try_from(root_key_bytes).map_err(|()| { + error!("Broken database: invalid encryption key size"); + Error::BrokenDatabase + })?; + self.state = State::Unsealed(Unsealed { root_key_history_id: current_key.id, - root_key: KeyCell::try_from(root_key).map_err(|err| { - error!(?err, "Broken database: invalid encryption key size"); - Error::BrokenDatabase - })?, + root_key, }); info!("Vault unsealed successfully"); @@ -277,6 +279,76 @@ impl Vault { Ok(()) } + /// Re-encrypts the root key with `new_seal_key`, updating its `root_key_history` row in + /// place. Called after a Shamir re-key, so the old seal key is no longer sufficient to + /// unseal. The root key itself does not change, so its row identity (and the nonce counter + /// and integrity envelopes bound to it) must not change either. + #[message] + pub async fn rekey_root_key(&mut self, mut new_seal_key: KeyCell) -> Result<(), Error> { + let Unsealed { + root_key, + root_key_history_id, + } = Self::expect_unsealed(&mut self.state)?; + + let new_nonce = Nonce::default(); + let new_salt = v1::generate_salt(); + + let new_ciphertext: Vec = root_key.0.read_inline(|rk| { + new_seal_key + .encrypt(&new_nonce, v1::ROOT_KEY_TAG, rk.as_slice()) + .map_err(|err| { + error!(?err, "Fatal rekey error"); + Error::Encryption(err) + }) + })?; + + let mut conn = self.db.get().await?; + + // The root key is unchanged, so its row keeps its identity: `data_encryption_nonce` + // keeps counting up, and every integrity envelope stays bound to the same key version. + // Only the seal-key material is replaced, retiring the previous one. `tag` and + // `schema_version` are deliberately left untouched: the seal-key encryption scheme + // itself is unchanged by a re-key, so there is nothing new for them to describe. + let rows_updated = update(schema::root_key_history::table) + .filter(schema::root_key_history::id.eq(*root_key_history_id)) + .set(( + schema::root_key_history::ciphertext.eq(new_ciphertext), + schema::root_key_history::root_key_encryption_nonce.eq(new_nonce.to_vec()), + schema::root_key_history::salt.eq(new_salt.to_vec()), + )) + .execute(&mut conn) + .await?; + + if rows_updated == 0 { + error!( + "Broken database: rekey matched no root_key_history row id={:#?}", + root_key_history_id + ); + return Err(Error::BrokenDatabase); + } + + info!("Vault root key rekeyed successfully"); + Ok(()) + } + + #[message] + pub async fn seal(&mut self) -> Result<(), Error> { + let Unsealed { + root_key_history_id, + .. + } = Self::expect_unsealed(&mut self.state)?; + + self.state = State::Sealed { + root_key_history_id: *root_key_history_id, + }; + let _ = self.events.tell(Publish(events::VaultResealed)).await; + Ok(()) + } +} + +// Server-side cryptographic operations +#[messages] +impl Vault { #[message] pub async fn decrypt(&mut self, aead_id: i32) -> Result>, Error> { let Unsealed { root_key, .. } = Self::expect_unsealed(&mut self.state)?; @@ -354,12 +426,10 @@ impl Vault { root_key_history_id, } = Self::expect_unsealed(&mut self.state)?; - let mut hmac = root_key - .0 - .read_inline(|k| match HmacSha256::new_from_slice(k) { - Ok(v) => v, - Err(_) => unreachable!("HMAC accepts keys of any size"), - }); + let mut hmac = root_key.0.read_inline(|k| { + HmacSha256::new_from_slice(k) + .unwrap_or_else(|_| unreachable!("HMAC accepts keys of any size")) + }); hmac.update(&root_key_history_id.to_raw().to_be_bytes()); hmac.update(&mac_input); @@ -383,29 +453,69 @@ impl Vault { return Ok(false); } - let mut hmac = root_key - .0 - .read_inline(|k| match HmacSha256::new_from_slice(k) { - Ok(v) => v, - Err(_) => unreachable!("HMAC accepts keys of any size"), - }); + let mut hmac = root_key.0.read_inline(|k| { + HmacSha256::new_from_slice(k) + .unwrap_or_else(|_| unreachable!("HMAC accepts keys of any size")) + }); hmac.update(&key_version.to_raw().to_be_bytes()); hmac.update(&mac_input); Ok(hmac.verify_slice(&expected_mac).is_ok()) } +} - #[message] - pub async fn seal(&mut self) -> Result<(), Error> { - let Unsealed { - root_key_history_id, - .. - } = Self::expect_unsealed(&mut self.state)?; +impl Message for Vault { + type Reply = (); - self.state = State::Sealed { - root_key_history_id: *root_key_history_id, + /// Every subscriber sees every approval and acts only on the kinds it owns. + async fn handle( + &mut self, + msg: ProposalApproved, + _ctx: &mut kameo::prelude::Context, + ) -> Self::Reply { + let ProposalKind::ApproveSdkClient(settings) = msg.kind else { + return; }; - let _ = self.events.tell(Publish(events::VaultResealed)).await; + + if let Err(error) = self.approve_sdk_client(settings.client_id).await { + error!( + ?error, + proposal_id = msg.id.to_raw(), + "Failed to execute an approved proposal" + ); + } + } +} + +impl Vault { + /// Attests an approved SDK client with the root key. + /// + /// Builds the envelope from its parts rather than calling `integrity::sign_entity`, + /// which would have this actor ask itself for a signature and deadlock. + async fn approve_sdk_client(&mut self, client_id: i32) -> Result<(), Error> { + use crate::peers::client::ClientCredentials; + use arbiter_crypto::authn; + + // Cloned so the connection does not hold a borrow of `self` across `sign_integrity`. + let db = self.db.clone(); + let mut conn = db.get().await?; + + let pubkey_bytes: Vec = schema::program_client::table + .find(client_id) + .select(schema::program_client::public_key) + .first(&mut conn) + .await?; + + let pubkey = + authn::PublicKey::try_from(pubkey_bytes.as_slice()).map_err(|()| Error::InvalidKey)?; + let credentials = ClientCredentials { pubkey }; + + let (entity_id, mac_input) = integrity::envelope_input(&credentials, client_id); + let (key_version, mac) = self.sign_integrity(mac_input)?; + + integrity::store_envelope::(&mut conn, entity_id, key_version, mac) + .await?; + Ok(()) } } @@ -413,8 +523,6 @@ impl Vault { #[cfg(test)] mod tests { use crate::actors::GlobalActors; - use crate::db::models::RootKeyHistory; - use arbiter_crypto::safecell::SafeCellHandle as _; use super::*; @@ -422,8 +530,7 @@ mod tests { let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus()) .await .unwrap(); - let seal_key = SafeCell::new(b"test-seal-key".to_vec()); - actor.bootstrap(seal_key).await.unwrap(); + actor.bootstrap(KeyCell::from([0u8; 32])).await.unwrap(); actor } @@ -432,12 +539,12 @@ mod tests { async fn nonce_monotonic_even_when_nonce_allocation_interleaves() { let db = db::create_test_pool().await; let mut actor = bootstrapped_actor(&db).await; - let root_key_history_id = match actor.state { - State::Unsealed(Unsealed { - root_key_history_id, - .. - }) => root_key_history_id, - _ => panic!("expected unsealed state"), + let State::Unsealed(Unsealed { + root_key_history_id, + .. + }) = actor.state + else { + panic!("expected unsealed state"); }; let n1 = Vault::get_new_nonce(&db, root_key_history_id) @@ -471,4 +578,96 @@ mod tests { "next write must advance nonce" ); } + + #[tokio::test] + #[test_log::test] + async fn rekey_does_not_restart_the_data_nonce_counter() { + let db = db::create_test_pool().await; + let mut actor = bootstrapped_actor(&db).await; + + let before = actor + .create_new(SafeCell::new(b"before-rekey".to_vec())) + .await + .unwrap(); + + actor.rekey_root_key(KeyCell::from([7u8; 32])).await.unwrap(); + + let after = actor + .create_new(SafeCell::new(b"after-rekey".to_vec())) + .await + .unwrap(); + + let mut conn = db.get().await.unwrap(); + + // One root key, one row: the root key never changed, so its history did not fork. + let rows: i64 = schema::root_key_history::table + .count() + .get_result(&mut conn) + .await + .unwrap(); + assert_eq!(rows, 1, "a seal-key re-key must not append a root key row"); + + // Fetch each nonce by its own id, rather than `eq_any` (whose row order is + // unspecified), and assert the counter strictly advanced. A weaker `assert_ne!` would + // still pass if the counter reset, as long as the two nonces happened to differ. + let before_nonce: Vec = schema::aead_encrypted::table + .find(before) + .select(schema::aead_encrypted::current_nonce) + .first(&mut conn) + .await + .unwrap(); + let after_nonce: Vec = schema::aead_encrypted::table + .find(after) + .select(schema::aead_encrypted::current_nonce) + .first(&mut conn) + .await + .unwrap(); + assert!( + after_nonce > before_nonce, + "nonce counter must keep advancing across a rekey, not reset" + ); + } + + #[tokio::test] + #[test_log::test] + async fn rekey_invalidates_the_old_seal_key() { + let db = db::create_test_pool().await; + let mut actor = bootstrapped_actor(&db).await; + + actor.rekey_root_key(KeyCell::from([7u8; 32])).await.unwrap(); + actor.seal().await.unwrap(); + + // A no-op rekey would leave the old seal key working; it must not. + let err = actor + .try_unseal(KeyCell::from([0u8; 32])) + .await + .unwrap_err(); + assert!( + matches!(err, Error::InvalidKey), + "old seal key must no longer unseal after a rekey, got {err:?}" + ); + + // A failed unseal must leave the sealed state intact: the new seal key must still be + // able to unseal on the next attempt. + actor.try_unseal(KeyCell::from([7u8; 32])).await.unwrap(); + } + + #[tokio::test] + #[test_log::test] + async fn integrity_envelopes_survive_a_rekey() { + let db = db::create_test_pool().await; + let mut actor = bootstrapped_actor(&db).await; + + let mac_input = b"operator_credentials/1".to_vec(); + let (key_version, mac) = actor.sign_integrity(mac_input.clone()).unwrap(); + + actor.rekey_root_key(KeyCell::from([9u8; 32])).await.unwrap(); + + assert!( + actor + .verify_integrity(mac_input, mac, key_version) + .unwrap(), + "a seal-key re-key must not invalidate existing attestations" + ); + } } diff --git a/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs b/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs new file mode 100644 index 0000000..240d99b --- /dev/null +++ b/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs @@ -0,0 +1,923 @@ +use std::collections::HashMap; + +use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; +use diesel::{ExpressionMethods as _, QueryDsl}; +use diesel_async::RunQueryDsl; +use kameo::{Actor, actor::ActorRef, messages, prelude::Message}; +use rand_core::{OsRng, RngCore as _}; +use tracing::error; + +use crate::{ + actors::{ + proposal_manager::events::ProposalApproved, + vault::{Bootstrap, RekeyRootKey, TryUnseal, Vault}, + }, + crypto::{KeyCell, derive_key, encryption::v1::Nonce, shamir, shamir::shamir_threshold}, + db::{ + self, models, + proposal::{ProposalKind, replace_operator}, + schema, + }, +}; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Already coordinating a bootstrap")] + AlreadyBootstrapping, + #[error("Already coordinating an unseal")] + AlreadyUnsealing, + #[error("Rekey not in progress")] + NotRekeying, + #[error("Bootstrap not in progress")] + NotBootstrapping, + #[error("Unseal not in progress")] + NotUnsealing, + #[error("Operator already contributed")] + DuplicateContribution, + #[error("Operator not found in database")] + OperatorNotFound, + #[error("Invalid passphrase (decryption failed)")] + InvalidPassphrase, + #[error("Shamir error: {0}")] + Shamir(String), + #[error("Database connection error: {0}")] + DatabaseConnection(#[from] db::PoolError), + #[error("Database query error: {0}")] + DatabaseQuery(#[from] diesel::result::Error), + #[error("Encryption error")] + Encryption, + #[error("Vault error")] + VaultError, + #[error("Two-operator vaults require at least one recovery share")] + TwoOperatorsRequireRecovery, + #[error("Broken database")] + BrokenDatabase, + #[error("A committee must have at least one ordinary operator")] + EmptyCommittee, + #[error("Recovery operators are sleeping")] + RecoveryNotActive, +} + +// Passphrases stored as plain Vec (not SafeCell) so CoordinatorState is Sync. +// They are ephemeral and dropped immediately after use. +enum CoordinatorState { + Idle, + Bootstrapping { + declared_count: usize, + recovery_count: usize, + passphrases: HashMap>, + recovery_passphrases: HashMap>, + }, + Unsealing { + threshold: usize, + ordinary_passphrases: HashMap>, + recovery_passphrases: HashMap>, + }, + /// Shamir re-key after `replace_operator` or `trigger_rekey` is approved (§3.3). + /// Collects new passphrases from all current operators, then generates a fresh seal key, + /// re-splits it, and re-encrypts the vault root key. + Rekeying { + ordinary_count: usize, + recovery_count: usize, + passphrases: HashMap>, + recovery_passphrases: HashMap>, + }, +} + +#[derive(Actor)] +pub struct VaultCoordinator { + db: db::DatabasePool, + vault: ActorRef, + state: CoordinatorState, +} + +impl VaultCoordinator { + pub const fn new(db: db::DatabasePool, vault: ActorRef) -> Self { + Self { + db, + vault, + state: CoordinatorState::Idle, + } + } +} + +const SHARE_AAD: &[u8] = b"arbiter/shamir-share/v1"; + +fn encrypt_share( + passphrase_bytes: Vec, + share: &[u8], +) -> Result<(Vec, Vec, Vec), Error> { + let mut share_salt = vec![0u8; 32]; + OsRng.fill_bytes(&mut share_salt); + + let mut passphrase_cell = SafeCell::new(passphrase_bytes); + let mut share_seal_key = derive_key(&mut passphrase_cell, &share_salt); + + let nonce = Nonce::default(); + let encrypted_share = share_seal_key + .encrypt(&nonce, SHARE_AAD, share) + .map_err(|_| Error::Encryption)?; + + Ok((encrypted_share, nonce.to_vec(), share_salt)) +} + +fn decrypt_share( + passphrase_bytes: Vec, + encrypted_share: Vec, + share_nonce_bytes: &[u8], + share_salt: &[u8], + operator_id: i32, +) -> Result, Error> { + let nonce = Nonce::try_from(share_nonce_bytes).map_err(|()| { + error!(operator_id, "Invalid nonce in DB"); + Error::BrokenDatabase + })?; + + let mut passphrase_cell = SafeCell::new(passphrase_bytes); + let mut share_seal_key = derive_key(&mut passphrase_cell, share_salt); + + let mut share_buffer = SafeCell::new(encrypted_share); + share_seal_key + .decrypt_in_place(&nonce, SHARE_AAD, &mut share_buffer) + .map_err(|_| Error::InvalidPassphrase)?; + + Ok(share_buffer.read().clone()) +} + +/// Records the threshold of the split that produced the stored shares. +async fn store_threshold(conn: &mut db::DatabaseConnection, threshold: usize) -> Result<(), Error> { + // A threshold that doesn't fit in the column is a bug, not a real empty-committee refusal. + let threshold = i32::try_from(threshold).map_err(|_| Error::BrokenDatabase)?; + let rows_updated = diesel::update(schema::arbiter_settings::table) + .set(schema::arbiter_settings::shamir_threshold.eq(Some(threshold))) + .execute(conn) + .await?; + // The singleton row always exists (up.sql seeds it), so anything else means the update did + // not land -- bootstrap would then report success while the threshold stays NULL forever. + if rows_updated != 1 { + return Err(Error::BrokenDatabase); + } + Ok(()) +} + +/// Reads back the threshold recorded at bootstrap or re-key time. +async fn load_threshold(conn: &mut db::DatabaseConnection) -> Result { + let stored: Option = schema::arbiter_settings::table + .select(schema::arbiter_settings::shamir_threshold) + .first(conn) + .await?; + + // A missing or out-of-range value here means the recorded threshold is corrupt, not that the + // committee is genuinely empty -- `EmptyCommittee` is reserved for the real domain refusal. + stored + .and_then(|threshold| usize::try_from(threshold).ok()) + .ok_or(Error::BrokenDatabase) +} + +/// §3.4: Split the seal key across ordinary + recovery operators. +/// Threshold = `shamir_threshold(ordinary_count)`; total shares = ordinary + recovery. +/// When `ordinary_count` == 1 (threshold = 1), vsss-rs does not support a proper split, +/// so each share is the seal key itself — any single participant can reconstruct. +async fn finalize_bootstrap( + db: db::DatabasePool, + vault: ActorRef, + ordinary_passphrases: HashMap>, + recovery_passphrases: HashMap>, +) -> Result<(), Error> { + let ordinary_count = ordinary_passphrases.len(); + let recovery_count = recovery_passphrases.len(); + let total = ordinary_count + recovery_count; + let threshold = shamir_threshold(ordinary_count).ok_or(Error::EmptyCommittee)?; + + let mut seal_key_bytes = [0u8; 32]; + OsRng.fill_bytes(&mut seal_key_bytes); + + // threshold == 1 means any single share reconstructs the key (degenerate split). + // vsss-rs requires threshold >= 2, so we store the key directly in this case. + let shares: Vec> = if threshold >= 2 { + shamir::split_key(threshold, total, &seal_key_bytes, OsRng) + .map_err(|e| Error::Shamir(e.to_string()))? + } else { + std::iter::repeat_with(|| seal_key_bytes.to_vec()).take(total).collect() + }; + + let seal_key = KeyCell::from(seal_key_bytes); + + let mut conn = db.get().await?; + let mut shares_iter = shares.into_iter(); + + for (operator_id_raw, passphrase_bytes) in ordinary_passphrases { + let share = shares_iter + .next() + .expect("split_key returned enough shares"); + let (encrypted_share, nonce_bytes, share_salt) = encrypt_share(passphrase_bytes, &share)?; + + diesel::replace_into(schema::operator::table) + .values(( + schema::operator::id.eq(Some(operator_id_raw)), + schema::operator::share.eq(&encrypted_share), + schema::operator::share_nonce.eq(&nonce_bytes), + schema::operator::share_salt.eq(&share_salt), + schema::operator::created_at.eq(models::SqliteTimestamp::now()), + schema::operator::updated_at.eq(models::SqliteTimestamp::now()), + )) + .execute(&mut conn) + .await?; + } + + for (recovery_id_raw, passphrase_bytes) in recovery_passphrases { + let share = shares_iter + .next() + .expect("split_key returned enough shares"); + let (encrypted_share, nonce_bytes, share_salt) = encrypt_share(passphrase_bytes, &share)?; + + diesel::replace_into(schema::recovery_operator::table) + .values(( + schema::recovery_operator::id.eq(recovery_id_raw), + schema::recovery_operator::share.eq(&encrypted_share), + schema::recovery_operator::share_nonce.eq(&nonce_bytes), + schema::recovery_operator::share_salt.eq(&share_salt), + schema::recovery_operator::created_at.eq(models::SqliteTimestamp::now()), + schema::recovery_operator::updated_at.eq(models::SqliteTimestamp::now()), + )) + .execute(&mut conn) + .await?; + } + + store_threshold(&mut conn, threshold).await?; + + vault.ask(Bootstrap { seal_key }).await.map_err(|err| { + error!(?err, "Vault bootstrap failed"); + Error::VaultError + })?; + + Ok(()) +} + +/// §3.5: Unseal using any threshold-sized mix of ordinary + recovery shares. +async fn finalize_unseal( + db: db::DatabasePool, + vault: ActorRef, + ordinary_passphrases: HashMap>, + recovery_passphrases: HashMap>, +) -> Result<(), Error> { + let mut conn = db.get().await?; + + // Determine whether shares were stored as raw keys (threshold=1) or vsss-rs splits (threshold>=2). + let threshold = load_threshold(&mut conn).await?; + + let mut shares: Vec> = Vec::new(); + + for (operator_id_raw, passphrase_bytes) in ordinary_passphrases { + let (encrypted_share, share_nonce_bytes, share_salt): (Vec, Vec, Vec) = + schema::operator::table + .filter(schema::operator::id.eq(Some(operator_id_raw))) + .select(( + schema::operator::share, + schema::operator::share_nonce, + schema::operator::share_salt, + )) + .first(&mut conn) + .await + .map_err(|_| Error::OperatorNotFound)?; + + shares.push(decrypt_share( + passphrase_bytes, + encrypted_share, + &share_nonce_bytes, + &share_salt, + operator_id_raw, + )?); + } + + for (recovery_id_raw, passphrase_bytes) in recovery_passphrases { + let (encrypted_share, share_nonce_bytes, share_salt): (Vec, Vec, Vec) = + schema::recovery_operator::table + .find(recovery_id_raw) + .select(( + schema::recovery_operator::share, + schema::recovery_operator::share_nonce, + schema::recovery_operator::share_salt, + )) + .first(&mut conn) + .await + .map_err(|_| Error::OperatorNotFound)?; + + shares.push(decrypt_share( + passphrase_bytes, + encrypted_share, + &share_nonce_bytes, + &share_salt, + recovery_id_raw, + )?); + } + + // When threshold==1, shares are raw 32-byte seal keys (vsss-rs cannot split 1-of-N). + // Any single decrypted share is the key itself. + let seal_key_bytes: [u8; 32] = if threshold <= 1 { + let raw = shares + .into_iter() + .next() + .ok_or_else(|| Error::Shamir("No shares available".into()))?; + raw.try_into() + .map_err(|_| Error::Shamir("Invalid share length".into()))? + } else { + shamir::combine_shares(&shares).map_err(|e| Error::Shamir(e.to_string()))? + }; + + let seal_key = KeyCell::from(seal_key_bytes); + + vault.ask(TryUnseal { seal_key }).await.map_err(|err| { + error!(?err, "Vault unseal failed"); + Error::VaultError + })?; + + Ok(()) +} + +/// §3.3: Generate a fresh seal key, split across current operators, re-encrypt the vault root key. +/// Called after `replace_operator` or `trigger_rekey` is approved and all contributors submit. +async fn finalize_rekey( + db: db::DatabasePool, + vault: ActorRef, + ordinary_passphrases: HashMap>, + recovery_passphrases: HashMap>, +) -> Result<(), Error> { + let ordinary_count = ordinary_passphrases.len(); + let recovery_count = recovery_passphrases.len(); + let total = ordinary_count + recovery_count; + let threshold = shamir_threshold(ordinary_count).ok_or(Error::EmptyCommittee)?; + + let mut new_seal_key_bytes = [0u8; 32]; + OsRng.fill_bytes(&mut new_seal_key_bytes); + + let shares: Vec> = if threshold >= 2 { + shamir::split_key(threshold, total, &new_seal_key_bytes, OsRng) + .map_err(|e| Error::Shamir(e.to_string()))? + } else { + std::iter::repeat_with(|| new_seal_key_bytes.to_vec()) + .take(total) + .collect() + }; + + let mut conn = db.get().await?; + let mut shares_iter = shares.into_iter(); + + for (operator_id_raw, passphrase_bytes) in ordinary_passphrases { + let share = shares_iter + .next() + .expect("split_key returned enough shares"); + let (encrypted_share, nonce_bytes, share_salt) = encrypt_share(passphrase_bytes, &share)?; + + diesel::replace_into(schema::operator::table) + .values(( + schema::operator::id.eq(Some(operator_id_raw)), + schema::operator::share.eq(&encrypted_share), + schema::operator::share_nonce.eq(&nonce_bytes), + schema::operator::share_salt.eq(&share_salt), + schema::operator::created_at.eq(models::SqliteTimestamp::now()), + schema::operator::updated_at.eq(models::SqliteTimestamp::now()), + )) + .execute(&mut conn) + .await?; + } + + for (recovery_id_raw, passphrase_bytes) in recovery_passphrases { + let share = shares_iter + .next() + .expect("split_key returned enough shares"); + let (encrypted_share, nonce_bytes, share_salt) = encrypt_share(passphrase_bytes, &share)?; + + diesel::replace_into(schema::recovery_operator::table) + .values(( + schema::recovery_operator::id.eq(recovery_id_raw), + schema::recovery_operator::share.eq(&encrypted_share), + schema::recovery_operator::share_nonce.eq(&nonce_bytes), + schema::recovery_operator::share_salt.eq(&share_salt), + schema::recovery_operator::created_at.eq(models::SqliteTimestamp::now()), + schema::recovery_operator::updated_at.eq(models::SqliteTimestamp::now()), + )) + .execute(&mut conn) + .await?; + } + + store_threshold(&mut conn, threshold).await?; + + drop(conn); + + let new_seal_key = KeyCell::from(new_seal_key_bytes); + vault + .ask(RekeyRootKey { new_seal_key }) + .await + .map_err(|err| { + error!(?err, "Vault rekey failed"); + Error::VaultError + })?; + + Ok(()) +} + +#[messages] +impl VaultCoordinator { + /// Phase 1 of multi-operator bootstrap: declare the committee size. + #[message] + #[expect(clippy::unused_async, reason = "kameo requires messages to be async")] + pub async fn start_bootstrap( + &mut self, + operator_id: i32, + declared_count: usize, + recovery_count: usize, + ) -> Result<(), Error> { + let _ = operator_id; // fixme!: any authenticated operator may announce the committee size. the first call wins + if !matches!(self.state, CoordinatorState::Idle) { + return Err(Error::AlreadyBootstrapping); + } + if declared_count == 0 { + return Err(Error::EmptyCommittee); + } + if declared_count == 2 && recovery_count == 0 { + return Err(Error::TwoOperatorsRequireRecovery); + } + self.state = CoordinatorState::Bootstrapping { + declared_count, + recovery_count, + passphrases: HashMap::new(), + recovery_passphrases: HashMap::new(), + }; + Ok(()) + } + + /// Phase 2 of multi-operator bootstrap: ordinary operator contributes a passphrase. + /// Returns Ok(true) when all ordinary + recovery operators contributed and bootstrap finalized. + #[message] + pub async fn contribute_bootstrap( + &mut self, + operator_id: i32, + mut passphrase: SafeCell>, + ) -> Result { + let CoordinatorState::Bootstrapping { + declared_count, + recovery_count, + passphrases, + recovery_passphrases, + } = &mut self.state + else { + return Err(Error::NotBootstrapping); + }; + + if passphrases.contains_key(&operator_id) { + return Err(Error::DuplicateContribution); + } + + let passphrase_bytes = passphrase.read().to_vec(); + passphrases.insert(operator_id, passphrase_bytes); + + if passphrases.len() < *declared_count || recovery_passphrases.len() < *recovery_count { + return Ok(false); + } + + let CoordinatorState::Bootstrapping { + passphrases, + recovery_passphrases, + .. + } = std::mem::replace(&mut self.state, CoordinatorState::Idle) + else { + unreachable!() + }; + + finalize_bootstrap( + self.db.clone(), + self.vault.clone(), + passphrases, + recovery_passphrases, + ) + .await?; + Ok(true) + } + + /// Phase 2 of multi-operator bootstrap: recovery operator contributes a passphrase. + /// Returns Ok(true) when all contributors are in and bootstrap finalized. + #[message] + pub async fn contribute_recovery_bootstrap( + &mut self, + recovery_operator_id: i32, + mut passphrase: SafeCell>, + ) -> Result { + let CoordinatorState::Bootstrapping { + declared_count, + recovery_count, + passphrases, + recovery_passphrases, + } = &mut self.state + else { + return Err(Error::NotBootstrapping); + }; + + if recovery_passphrases.contains_key(&recovery_operator_id) { + return Err(Error::DuplicateContribution); + } + + let passphrase_bytes = passphrase.read().to_vec(); + recovery_passphrases.insert(recovery_operator_id, passphrase_bytes); + + if passphrases.len() < *declared_count || recovery_passphrases.len() < *recovery_count { + return Ok(false); + } + + let CoordinatorState::Bootstrapping { + passphrases, + recovery_passphrases, + .. + } = std::mem::replace(&mut self.state, CoordinatorState::Idle) + else { + unreachable!() + }; + + finalize_bootstrap( + self.db.clone(), + self.vault.clone(), + passphrases, + recovery_passphrases, + ) + .await?; + Ok(true) + } + + /// Contribute a passphrase for vault unseal (ordinary operator). + /// Returns Ok(true) when threshold reached and vault is unsealed. + #[message] + pub async fn contribute_unseal( + &mut self, + operator_id: i32, + mut passphrase: SafeCell>, + ) -> Result { + self.ensure_unsealing_state().await?; + + let CoordinatorState::Unsealing { + threshold, + ordinary_passphrases, + recovery_passphrases, + } = &mut self.state + else { + return Err(Error::NotUnsealing); + }; + + if ordinary_passphrases.contains_key(&operator_id) { + return Err(Error::DuplicateContribution); + } + + let passphrase_bytes = passphrase.read().to_vec(); + ordinary_passphrases.insert(operator_id, passphrase_bytes); + + if ordinary_passphrases.len() + recovery_passphrases.len() < *threshold { + return Ok(false); + } + + self.do_finalize_unseal().await + } + + /// Contribute a passphrase for vault unseal (recovery operator, §3.5). + /// Recovery operators may contribute during unseal when recovery is active. + /// Returns Ok(true) when threshold reached and vault is unsealed. + #[message] + pub async fn contribute_recovery_unseal( + &mut self, + recovery_operator_id: i32, + mut passphrase: SafeCell>, + ) -> Result { + { + let mut conn = self.db.get().await?; + if !db::recovery::is_active(&mut conn).await? { + return Err(Error::RecoveryNotActive); + } + } + + self.ensure_unsealing_state().await?; + + let CoordinatorState::Unsealing { + threshold, + ordinary_passphrases, + recovery_passphrases, + } = &mut self.state + else { + return Err(Error::NotUnsealing); + }; + + if recovery_passphrases.contains_key(&recovery_operator_id) { + return Err(Error::DuplicateContribution); + } + + let passphrase_bytes = passphrase.read().to_vec(); + recovery_passphrases.insert(recovery_operator_id, passphrase_bytes); + + if ordinary_passphrases.len() + recovery_passphrases.len() < *threshold { + return Ok(false); + } + + self.do_finalize_unseal().await + } +} + +impl VaultCoordinator { + /// Initializes `CoordinatorState::Unsealing` on first call if still `Idle`. + /// Threshold comes from the recorded split parameters (§3.4), not from a live row count. + async fn ensure_unsealing_state(&mut self) -> Result<(), Error> { + if matches!(self.state, CoordinatorState::Idle) { + let mut conn = self.db.get().await?; + let threshold = load_threshold(&mut conn).await?; + drop(conn); + self.state = CoordinatorState::Unsealing { + threshold, + ordinary_passphrases: HashMap::new(), + recovery_passphrases: HashMap::new(), + }; + } + Ok(()) + } + + /// Moves state back to Idle and calls finalize_unseal. + async fn do_finalize_unseal(&mut self) -> Result { + let CoordinatorState::Unsealing { + ordinary_passphrases, + recovery_passphrases, + .. + } = std::mem::replace(&mut self.state, CoordinatorState::Idle) + else { + unreachable!() + }; + + finalize_unseal( + self.db.clone(), + self.vault.clone(), + ordinary_passphrases, + recovery_passphrases, + ) + .await?; + Ok(true) + } + + async fn do_finalize_rekey(&mut self) -> Result { + let CoordinatorState::Rekeying { + passphrases, + recovery_passphrases, + .. + } = std::mem::replace(&mut self.state, CoordinatorState::Idle) + else { + unreachable!() + }; + + finalize_rekey( + self.db.clone(), + self.vault.clone(), + passphrases, + recovery_passphrases, + ) + .await?; + Ok(true) + } +} + +#[messages] +impl VaultCoordinator { + /// Begin Shamir re-key after a key-rotation proposal is approved (§3.3). + /// Queries the current operator and recovery operator counts from the DB, + /// then transitions to Rekeying state awaiting contributions from all of them. + #[message] + pub async fn start_rekey(&mut self) -> Result<(), Error> { + self.ensure_idle()?; + let mut conn = self.db.get().await?; + let ordinary_count: i64 = schema::operator_identity::table + .count() + .get_result(&mut conn) + .await?; + let recovery_count: i64 = schema::recovery_operator_identity::table + .count() + .get_result(&mut conn) + .await?; + self.state = CoordinatorState::Rekeying { + ordinary_count: ordinary_count as usize, + recovery_count: recovery_count as usize, + passphrases: HashMap::new(), + recovery_passphrases: HashMap::new(), + }; + Ok(()) + } + + /// Contribute an ordinary operator passphrase for the re-key. + /// Returns Ok(true) when all contributors have submitted and the re-key is complete. + #[message] + pub async fn contribute_rekey( + &mut self, + operator_id: i32, + mut passphrase: SafeCell>, + ) -> Result { + let CoordinatorState::Rekeying { + ordinary_count, + recovery_count, + passphrases, + recovery_passphrases, + } = &mut self.state + else { + return Err(Error::NotRekeying); + }; + + if passphrases.contains_key(&operator_id) { + return Err(Error::DuplicateContribution); + } + + passphrases.insert(operator_id, passphrase.read().to_vec()); + + if passphrases.len() < *ordinary_count || recovery_passphrases.len() < *recovery_count { + return Ok(false); + } + + self.do_finalize_rekey().await + } + + /// Contribute a recovery operator passphrase for the re-key. + /// Returns Ok(true) when all contributors have submitted and the re-key is complete. + #[message] + pub async fn contribute_recovery_rekey( + &mut self, + recovery_operator_id: i32, + mut passphrase: SafeCell>, + ) -> Result { + let CoordinatorState::Rekeying { + ordinary_count, + recovery_count, + passphrases, + recovery_passphrases, + } = &mut self.state + else { + return Err(Error::NotRekeying); + }; + + if recovery_passphrases.contains_key(&recovery_operator_id) { + return Err(Error::DuplicateContribution); + } + + recovery_passphrases.insert(recovery_operator_id, passphrase.read().to_vec()); + + if passphrases.len() < *ordinary_count || recovery_passphrases.len() < *recovery_count { + return Ok(false); + } + + self.do_finalize_rekey().await + } +} + +impl Message for VaultCoordinator { + type Reply = (); + + /// Every subscriber sees every approval and acts only on the kinds it owns. + async fn handle( + &mut self, + msg: ProposalApproved, + _ctx: &mut kameo::prelude::Context, + ) -> Self::Reply { + let result = match msg.kind { + ProposalKind::ReplaceOperator(settings) => self.replace_operator(&settings).await, + ProposalKind::TriggerRekey => self.start_rekey().await, + _ => return, + }; + + if let Err(error) = result { + error!( + ?error, + proposal_id = msg.id.to_raw(), + "Failed to execute an approved proposal" + ); + } + } +} + +impl VaultCoordinator { + /// The coordinator runs one ceremony at a time; anything that starts a new one has to say + /// so before it changes any state the ceremony depends on. + const fn ensure_idle(&self) -> Result<(), Error> { + if matches!(self.state, CoordinatorState::Idle) { + Ok(()) + } else { + Err(Error::AlreadyBootstrapping) + } + } + + /// Replaces the operator's public key in place, keeping their id and history, drops the + /// share that key no longer matches, then begins a coordinated re-key (§3.3). + async fn replace_operator( + &mut self, + settings: &replace_operator::Settings, + ) -> Result<(), Error> { + // Checked before anything is written. The re-key is what gives the replaced operator + // a share they can use; if the coordinator is mid-ceremony, `start_rekey` refuses, and + // swapping the key and destroying the share first would leave that operator locked + // out with no re-key running and nothing to undo it -- the caller only logs the error. + self.ensure_idle()?; + + let mut conn = self.db.get().await?; + + diesel::update(schema::operator_identity::table) + .filter(schema::operator_identity::id.eq(settings.old_operator_id)) + .set(schema::operator_identity::public_key.eq(&settings.new_pubkey)) + .execute(&mut conn) + .await?; + + // Drop the stale Shamir share; finalize_rekey stores a fresh one. + diesel::delete(schema::operator::table) + .filter(schema::operator::id.eq(Some(settings.old_operator_id))) + .execute(&mut conn) + .await?; + + drop(conn); + + self.start_rekey().await + } +} + +#[cfg(test)] +mod tests { + use super::{CoordinatorState, Error, VaultCoordinator}; + use crate::{ + actors::{GlobalActors, vault::Vault}, + db::{self, models::OperatorIdentityId, proposal::replace_operator, schema}, + }; + + use diesel::{ExpressionMethods as _, QueryDsl as _, dsl::insert_into}; + use diesel_async::RunQueryDsl; + use kameo::actor::Spawn as _; + use std::collections::HashMap; + + /// An approved `ReplaceOperator` that arrives while another ceremony is running must + /// change nothing. Swapping the public key and deleting the share are only safe because a + /// re-key follows and hands the operator a share for the new key; when `start_rekey` + /// refuses, the operator would otherwise be left holding a key with no share, and the + /// caller does nothing with the error but log it. + #[tokio::test] + async fn a_refused_rekey_leaves_the_operator_untouched() { + let pool = db::create_test_pool().await; + let mut conn = pool.get().await.unwrap(); + + let old_key = rand::random::<[u8; 32]>().to_vec(); + let operator_id: OperatorIdentityId = insert_into(schema::operator_identity::table) + .values(schema::operator_identity::public_key.eq(&old_key)) + .returning(schema::operator_identity::id) + .get_result(&mut conn) + .await + .unwrap(); + insert_into(schema::operator::table) + .values(( + schema::operator::id.eq(Some(operator_id)), + schema::operator::share.eq(vec![1u8; 32]), + schema::operator::share_nonce.eq(vec![2u8; 24]), + schema::operator::share_salt.eq(vec![3u8; 32]), + )) + .execute(&mut conn) + .await + .unwrap(); + drop(conn); + + let vault = Vault::spawn( + Vault::new(pool.clone(), GlobalActors::spawn_message_bus()) + .await + .unwrap(), + ); + let mut coordinator = VaultCoordinator::new(pool.clone(), vault); + coordinator.state = CoordinatorState::Rekeying { + ordinary_count: 2, + recovery_count: 0, + passphrases: HashMap::new(), + recovery_passphrases: HashMap::new(), + }; + + let result = coordinator + .replace_operator(&replace_operator::Settings { + old_operator_id: operator_id, + new_pubkey: vec![9u8; 32], + }) + .await; + assert!( + matches!(result, Err(Error::AlreadyBootstrapping)), + "a busy coordinator must refuse the replacement, got {result:?}" + ); + + let mut conn = pool.get().await.unwrap(); + let stored_key: Vec = schema::operator_identity::table + .find(operator_id) + .select(schema::operator_identity::public_key) + .first(&mut conn) + .await + .unwrap(); + assert_eq!( + stored_key, old_key, + "the public key must not be swapped when no re-key can follow" + ); + + let shares: i64 = schema::operator::table + .filter(schema::operator::id.eq(Some(operator_id))) + .count() + .get_result(&mut conn) + .await + .unwrap(); + assert_eq!(shares, 1, "the operator's share must not be destroyed"); + } +} diff --git a/server/crates/arbiter-server/src/crypto/encryption/v1.rs b/server/crates/arbiter-server/src/crypto/encryption/v1.rs index 0dac366..bc921c5 100644 --- a/server/crates/arbiter-server/src/crypto/encryption/v1.rs +++ b/server/crates/arbiter-server/src/crypto/encryption/v1.rs @@ -61,12 +61,12 @@ mod tests { #[test] fn derive_seal_key_deterministic() { static PASSWORD: &[u8] = b"password"; - let password = SafeCell::new(PASSWORD.to_vec()); - let password2 = SafeCell::new(PASSWORD.to_vec()); + let mut password = SafeCell::new(PASSWORD.to_vec()); + let mut password2 = SafeCell::new(PASSWORD.to_vec()); let salt = generate_salt(); - let mut key1 = derive_key(password, &salt); - let mut key2 = derive_key(password2, &salt); + let mut key1 = derive_key(&mut password, &salt); + let mut key2 = derive_key(&mut password2, &salt); let key1_reader = key1.0.read(); let key2_reader = key2.0.read(); @@ -77,10 +77,10 @@ mod tests { #[test] fn successful_derive() { static PASSWORD: &[u8] = b"password"; - let password = SafeCell::new(PASSWORD.to_vec()); + let mut password = SafeCell::new(PASSWORD.to_vec()); let salt = generate_salt(); - let mut key = derive_key(password, &salt); + let mut key = derive_key(&mut password, &salt); let key_reader = key.0.read(); assert_ne!(key_reader.as_slice(), &[0u8; 32][..]); diff --git a/server/crates/arbiter-server/src/crypto/governance.rs b/server/crates/arbiter-server/src/crypto/governance.rs new file mode 100644 index 0000000..9d67902 --- /dev/null +++ b/server/crates/arbiter-server/src/crypto/governance.rs @@ -0,0 +1,99 @@ +//! Canonical encoding and verification of governance vote signatures (§3.3). + +use crate::db::models::ProposalId; +use arbiter_crypto::authn::{self, SigningContext}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum VerifyError { + #[error("Malformed operator public key")] + PublicKey, + #[error("Malformed vote signature")] + Signature, + #[error("Signature does not match this vote")] + Mismatch, +} + +/// Canonical bytes an operator signs when voting: `proposal_id` as i64 big-endian, +/// followed by the approve flag as one byte. +/// +/// The flag is part of the message on purpose: without it an approval could be +/// replayed as a rejection of the same proposal. +#[must_use] +pub fn vote_message(proposal_id: ProposalId, approve: bool) -> Vec { + let mut message = Vec::with_capacity(9); + message.extend_from_slice(&i64::from(proposal_id.to_raw()).to_be_bytes()); + message.push(u8::from(approve)); + message +} + +/// Verifies a vote signature against an operator's stored public key. +pub fn verify_vote( + public_key: &[u8], + proposal_id: ProposalId, + approve: bool, + signature: &[u8], +) -> Result<(), VerifyError> { + let public_key = authn::PublicKey::try_from(public_key).map_err(|()| VerifyError::PublicKey)?; + let signature = authn::Signature::try_from(signature).map_err(|()| VerifyError::Signature)?; + + if public_key.verify_message( + &vote_message(proposal_id, approve), + SigningContext::GovernanceVote, + &signature, + ) { + Ok(()) + } else { + Err(VerifyError::Mismatch) + } +} + +#[cfg(test)] +mod tests { + use super::{VerifyError, verify_vote, vote_message}; + use crate::db::models::ProposalId; + use arbiter_crypto::authn::{SigningContext, SigningKey}; + + #[test] + fn vote_message_is_the_id_then_the_approve_flag() { + let message = vote_message(ProposalId::from_raw(0x0102), true); + assert_eq!(message, vec![0, 0, 0, 0, 0, 0, 1, 2, 1]); + } + + #[test] + fn verify_vote_accepts_a_matching_signature() { + let key = SigningKey::generate(); + let id = ProposalId::from_raw(42); + let signature = key + .sign_message(&vote_message(id, true), SigningContext::GovernanceVote) + .unwrap(); + + verify_vote( + &key.public_key().to_bytes(), + id, + true, + &signature.to_bytes(), + ) + .expect("a signature over this exact vote must verify"); + } + + /// The decisive one: an approval must not verify as a rejection of the same + /// proposal, or a captured vote could be replayed with its meaning flipped. + #[test] + fn verify_vote_rejects_a_flipped_approve_flag() { + let key = SigningKey::generate(); + let id = ProposalId::from_raw(42); + let signature = key + .sign_message(&vote_message(id, true), SigningContext::GovernanceVote) + .unwrap(); + + assert!(matches!( + verify_vote( + &key.public_key().to_bytes(), + id, + false, + &signature.to_bytes() + ), + Err(VerifyError::Mismatch) + )); + } +} diff --git a/server/crates/arbiter-server/src/crypto/integrity/v1.rs b/server/crates/arbiter-server/src/crypto/integrity/v1.rs index edb2274..9154552 100644 --- a/server/crates/arbiter-server/src/crypto/integrity/v1.rs +++ b/server/crates/arbiter-server/src/crypto/integrity/v1.rs @@ -2,7 +2,7 @@ use crate::{ actors::vault::{self, GetState, SignIntegrity, Vault, VerifyIntegrity}, db::{ self, - models::{IntegrityEnvelope, NewIntegrityEnvelope}, + models::{IntegrityEnvelope, NewIntegrityEnvelope, RootKeyHistoryId}, schema::integrity_envelope, }, }; @@ -109,11 +109,7 @@ pub async fn sign_entity( entity: &E, entity_id: impl IntoId, ) -> Result<(), Error> { - let payload_hash = payload_hash(&entity); - - let entity_id = entity_id.into_id(); - - let mac_input = build_mac_input(E::KIND, &entity_id, E::VERSION, &payload_hash); + let (entity_id, mac_input) = envelope_input::(entity, entity_id); let (key_version, mac) = vault @@ -124,6 +120,31 @@ pub async fn sign_entity( _ => Error::VaultSend, })?; + store_envelope::(conn, entity_id, key_version, mac) + .await + .map_err(db::DatabaseError::from)?; + + Ok(()) +} + +/// The entity id and the bytes the root key covers, as a pair. +/// +/// Split out of [`sign_entity`] so the `Vault` actor can build an envelope from inside a +/// message handler, where asking itself for a signature would deadlock. +pub fn envelope_input(entity: &E, entity_id: impl IntoId) -> (Vec, Vec) { + let payload_hash = payload_hash(entity); + let entity_id = entity_id.into_id(); + let mac_input = build_mac_input(E::KIND, &entity_id, E::VERSION, &payload_hash); + (entity_id, mac_input) +} + +/// Stores the integrity envelope for one entity, replacing any envelope it already has. +pub async fn store_envelope( + conn: &mut impl AsyncConnection, + entity_id: Vec, + key_version: RootKeyHistoryId, + mac: Vec, +) -> Result<(), diesel::result::Error> { insert_into(integrity_envelope::table) .values(NewIntegrityEnvelope { entity_kind: E::KIND.to_owned(), @@ -143,8 +164,7 @@ pub async fn sign_entity( integrity_envelope::mac.eq(mac), )) .execute(conn) - .await - .map_err(db::DatabaseError::from)?; + .await?; Ok(()) } @@ -215,8 +235,6 @@ mod tests { }, db::{self, schema}, }; - use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; - use super::{Error, Integrable, sign_entity, verify_entity}; #[derive(Clone, arbiter_macros::Hashable)] struct DummyEntity { @@ -235,7 +253,7 @@ mod tests { ); actor .ask(Bootstrap { - seal_key_raw: SafeCell::new(b"integrity-test-seal-key".to_vec()), + seal_key: crate::crypto::KeyCell::from([0u8; 32]), }) .await .unwrap(); diff --git a/server/crates/arbiter-server/src/crypto/mod.rs b/server/crates/arbiter-server/src/crypto/mod.rs index 440cb24..338986f 100644 --- a/server/crates/arbiter-server/src/crypto/mod.rs +++ b/server/crates/arbiter-server/src/crypto/mod.rs @@ -1,5 +1,5 @@ use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; -use encryption::v1::{Nonce, Salt}; +use encryption::v1::Nonce; use argon2::{Algorithm, Argon2}; use chacha20poly1305::{ @@ -12,7 +12,9 @@ use rand::{ }; pub mod encryption; +pub mod governance; pub mod integrity; +pub mod shamir; pub struct KeyCell(pub SafeCell); impl From> for KeyCell { @@ -20,6 +22,15 @@ impl From> for KeyCell { Self(value) } } +impl From<[u8; 32]> for KeyCell { + fn from(bytes: [u8; 32]) -> Self { + let cell = SafeCell::new_inline_default(|key: &mut Key| { + key.copy_from_slice(&bytes); + }); + Self(cell) + } +} + impl TryFrom>> for KeyCell { type Error = (); @@ -28,7 +39,7 @@ impl TryFrom>> for KeyCell { if value.len() != size_of::() { return Err(()); } - let cell = SafeCell::new_inline(|cell_write: &mut Key| { + let cell = SafeCell::new_inline_default(|cell_write: &mut Key| { cell_write.copy_from_slice(&value); }); Ok(Self(cell)) @@ -37,7 +48,7 @@ impl TryFrom>> for KeyCell { impl KeyCell { pub fn new_secure_random() -> Self { - let key = SafeCell::new_inline(|key_buffer: &mut Key| { + let key = SafeCell::new_inline_default(|key_buffer: &mut Key| { let mut rng = StdRng::try_from_rng(&mut SysRng) .expect("Rng failure is unrecoverable and should panic"); rng.fill_bytes(key_buffer); @@ -94,7 +105,7 @@ impl KeyCell { } /// Derive a fixed-length key from the password using Argon2id, which is designed for password hashing and key derivation. -pub fn derive_key(mut password: SafeCell>, salt: &Salt) -> KeyCell { +pub fn derive_key(password: &mut SafeCell>, salt: &[u8]) -> KeyCell { let params = { #[cfg(debug_assertions)] { @@ -132,10 +143,10 @@ mod tests { #[test] fn encrypt_decrypt() { static PASSWORD: &[u8] = b"password"; - let password = SafeCell::new(PASSWORD.to_vec()); + let mut password = SafeCell::new(PASSWORD.to_vec()); let salt = generate_salt(); - let mut key = derive_key(password, &salt); + let mut key = derive_key(&mut password, &salt); let nonce = Nonce(*b"unique nonce 123 1231233"); // 24 bytes for XChaCha20Poly1305 let associated_data = b"associated data"; let mut buffer = b"secret data".to_vec(); diff --git a/server/crates/arbiter-server/src/crypto/shamir.rs b/server/crates/arbiter-server/src/crypto/shamir.rs new file mode 100644 index 0000000..4600f99 --- /dev/null +++ b/server/crates/arbiter-server/src/crypto/shamir.rs @@ -0,0 +1,60 @@ +use vsss_rs::Gf256; + +#[derive(Debug, thiserror::Error)] +pub enum ShamirError { + #[error("Failed to split key: {0}")] + Split(String), + #[error("Failed to combine shares: {0}")] + Combine(String), +} + +/// Split `key` into `total` shares where any `threshold` shares can reconstruct it. +/// Each returned Vec is a share with format [`identifier_byte`, `value_bytes`...]. +pub fn split_key( + threshold: usize, + total: usize, + key: &[u8; 32], + rng: impl rand_core::RngCore + rand_core::CryptoRng, +) -> Result>, ShamirError> { + Gf256::split_array(threshold, total, key.as_slice(), rng) + .map_err(|e| ShamirError::Split(format!("{e:?}"))) +} + +/// Returns the minimum number of shares required to reconstruct the secret +/// for a committee of `n` operators, or `None` for an empty committee. +#[must_use] +pub const fn shamir_threshold(n: usize) -> Option { + match n { + 0 => None, + 1 => Some(1), + 2 => Some(2), + n => Some(n / 2 + 1), + } +} + +/// Reconstruct the secret from `threshold` or more shares. +pub fn combine_shares(shares: &[Vec]) -> Result<[u8; 32], ShamirError> { + let bytes = Gf256::combine_array(shares) + .map_err(|e| ShamirError::Combine(format!("{e:?}")))?; + <[u8; 32]>::try_from(bytes.as_slice()) + .map_err(|_| ShamirError::Combine("unexpected reconstructed key length".to_owned())) +} + +#[cfg(test)] +mod tests { + use super::shamir_threshold; + + #[test] + fn empty_committee_has_no_threshold() { + assert_eq!(shamir_threshold(0), None); + } + + #[test] + fn threshold_follows_the_ordinary_quorum() { + // ARCHITECTURE.md §3.1/§3.4: 1 decides alone, 2 need consensus, N needs N/2 + 1. + assert_eq!(shamir_threshold(1), Some(1)); + assert_eq!(shamir_threshold(2), Some(2)); + assert_eq!(shamir_threshold(3), Some(2)); + assert_eq!(shamir_threshold(4), Some(3)); + } +} diff --git a/server/crates/arbiter-server/src/db/functions.rs b/server/crates/arbiter-server/src/db/functions.rs new file mode 100644 index 0000000..3b5d119 --- /dev/null +++ b/server/crates/arbiter-server/src/db/functions.rs @@ -0,0 +1,12 @@ +//! Typed bindings for the SQLite scalar functions used in Diesel expressions. + +use diesel::sql_types::Text; + +diesel::define_sql_function! { + /// SQLite `unixepoch(modifier)` -- seconds since the Unix epoch. + /// + /// Declared so timestamp comparisons are built by the query DSL instead of by + /// `format!`-ing a SQL fragment: the argument becomes a bind parameter and the + /// result type is checked against the column it is compared with. + fn unixepoch(modifier: Text) -> Integer; +} diff --git a/server/crates/arbiter-server/src/db/mod.rs b/server/crates/arbiter-server/src/db/mod.rs index ef7cb56..10b0f38 100644 --- a/server/crates/arbiter-server/src/db/mod.rs +++ b/server/crates/arbiter-server/src/db/mod.rs @@ -8,7 +8,10 @@ use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations}; use thiserror::Error; use tracing::info; +pub mod functions; pub mod models; +pub mod proposal; +pub mod recovery; pub mod schema; pub type DatabaseConnection = SyncConnectionWrapper; @@ -56,25 +59,39 @@ fn database_path() -> Result { Ok(db_path) } +/// The pragmas `SQLite` scopes to one connection. They are defined once and run on every +/// connection that reaches the database -- the migration connection below and each pooled +/// connection in `create_pool` -- because a value set on one connection is invisible to the +/// next, and every real write happens on a pooled one. +const CONNECTION_PRAGMAS: &str = " + -- sleep if the database is busy; this corresponds to up to 9 seconds sleeping time. + -- see https://fractaledmind.github.io/2023/09/07/enhancing-rails-sqlite-fine-tuning/ + PRAGMA busy_timeout = 9000; + -- fsync only in critical moments + PRAGMA synchronous = NORMAL; + -- write WAL changes back every 1000 pages, for an in average 1MB WAL file. + -- May affect readers if number is increased + PRAGMA wal_autocheckpoint = 1000; + -- sqlite foreign keys are disabled by default, enable them for safety + PRAGMA foreign_keys = ON; + -- overwrite freed pages instead of leaving encrypted shares, nonces and salts + -- readable in the file + PRAGMA secure_delete = ON; +"; + #[tracing::instrument(level = "info", skip(conn))] fn db_config(conn: &mut SqliteConnection) -> Result<(), diesel::result::Error> { - // fsync only in critical moments - conn.batch_execute("PRAGMA synchronous = NORMAL;")?; - // write WAL changes back every 1000 pages, for an in average 1MB WAL file. - // May affect readers if number is increased - conn.batch_execute("PRAGMA wal_autocheckpoint = 1000;")?; + conn.batch_execute(CONNECTION_PRAGMAS)?; + + // The rest belong to the database file rather than the connection, so the one-shot + // migration connection is the right and only place for them. + // free some space by truncating possibly massive WAL files from the last run conn.batch_execute("PRAGMA wal_checkpoint(TRUNCATE);")?; - // sqlite foreign keys are disabled by default, enable them for safety - conn.batch_execute("PRAGMA foreign_keys = ON;")?; - // better space reclamation conn.batch_execute("PRAGMA auto_vacuum = FULL;")?; - // secure delete, overwrite deleted content with zeros to prevent recovery - conn.batch_execute("PRAGMA secure_delete = ON;")?; - Ok(()) } @@ -98,12 +115,17 @@ fn initialize_database(url: &str) -> Result<(), DatabaseSetupError> { /// # Panics /// Panics if the database path is not valid UTF-8. pub async fn create_pool(url: Option<&str>) -> Result { - let database_url = url.map(String::from).unwrap_or( - database_path()? + // Matched rather than `unwrap_or`, whose argument is evaluated even when `url` is `Some`: + // `database_path` resolves the real home directory and creates `~/.arbiter` as a side + // effect, so an eager call reaches the developer's home from every test that passes an + // explicit temp path, and fails outright wherever no home directory is writable. + let database_url = match url { + Some(url) => url.to_owned(), + None => database_path()? .to_str() .expect("database path is not valid UTF-8") .to_owned(), - ); + }; initialize_database(&database_url)?; @@ -112,13 +134,13 @@ pub async fn create_pool(url: Option<&str>) -> Result DatabasePool { .await .expect("Failed to create test database pool") } + +#[cfg(test)] +mod tests { + use super::*; + use diesel::{ + ExpressionMethods as _, + dsl::insert_into, + result::{DatabaseErrorKind, Error as DieselError}, + }; + use diesel_async::RunQueryDsl; + + /// `operator.id` references `operator_identity(id)`. Without `PRAGMA foreign_keys = ON` + /// on the pooled connection, SQLite accepts a share row for an operator that does not exist. + #[tokio::test] + async fn pooled_connections_enforce_foreign_keys() { + let pool = create_test_pool().await; + let mut conn = pool.get().await.unwrap(); + + let result = insert_into(schema::operator::table) + .values(( + schema::operator::id.eq(4242), + schema::operator::share.eq(vec![0u8; 32]), + schema::operator::share_nonce.eq(vec![0u8; 24]), + schema::operator::share_salt.eq(vec![0u8; 32]), + )) + .execute(&mut conn) + .await; + + // Specifically a foreign-key violation, not any error: a `NOT NULL` failure or a + // renamed column would also make `result.is_err()` true without proving the pragma + // is what rejected the insert. + assert!( + matches!( + result, + Err(DieselError::DatabaseError( + DatabaseErrorKind::ForeignKeyViolation, + _ + )) + ), + "expected a foreign-key violation for a dangling operator_identity reference, got {result:?}" + ); + } + + #[derive(diesel::QueryableByName)] + struct PragmaValue { + #[diesel(sql_type = diesel::sql_types::Integer)] + value: i32, + } + + async fn pragma(conn: &mut DatabaseConnection, name: &str) -> i32 { + diesel::sql_query(format!("select {name} as value from pragma_{name}()")) + .get_result::(conn) + .await + .unwrap() + .value + } + + /// `foreign_keys` had to be repeated on the pooled connection because `SQLite` scopes it + /// there; its siblings in `CONNECTION_PRAGMAS` are scoped the same way and were being + /// left behind on the migration connection. `secure_delete` is the one that matters in a + /// key-custody database: off by default, it leaves freed pages holding encrypted shares, + /// nonces and salts readable in the file. + #[tokio::test] + async fn pooled_connections_carry_the_shared_pragmas() { + let pool = create_test_pool().await; + let mut conn = pool.get().await.unwrap(); + + assert_eq!( + pragma(&mut conn, "secure_delete").await, + 1, + "freed pages must be overwritten on the connection that does the writing" + ); + assert_eq!( + pragma(&mut conn, "synchronous").await, + 1, + "synchronous must be NORMAL (1), not the default FULL (2)" + ); + assert_eq!(pragma(&mut conn, "foreign_keys").await, 1); + } +} diff --git a/server/crates/arbiter-server/src/db/models.rs b/server/crates/arbiter-server/src/db/models.rs index 557d579..4bd004b 100644 --- a/server/crates/arbiter-server/src/db/models.rs +++ b/server/crates/arbiter-server/src/db/models.rs @@ -9,16 +9,18 @@ use crate::db::schema::{ integrity_envelope, root_key_history, tls_history, }; +use crate::db::proposal::ProposalKindTag; use diesel::{prelude::*, sqlite::Sqlite}; use restructed::Models; pub mod types { use chrono::{DateTime, Utc}; use diesel::{ + backend::Backend, deserialize::{FromSql, FromSqlRow}, expression::AsExpression, serialize::{IsNull, ToSql}, - sql_types::Integer, + sql_types::{Integer, Text}, sqlite::{Sqlite, SqliteType}, }; @@ -61,7 +63,7 @@ pub mod types { impl FromSql for SqliteTimestamp { fn from_sql( - mut bytes: ::RawValue<'_>, + mut bytes: ::RawValue<'_>, ) -> diesel::deserialize::Result { let Some(SqliteType::Long) = bytes.value_type() else { return Err(format!( @@ -141,6 +143,42 @@ pub mod types { declare_id!(TlsHistoryId); declare_id!(EvmWalletId); declare_id!(ClientId); + declare_id!(ProposalId); + declare_id!(RecoveryOperatorIdentityId); + + #[derive(Debug, Clone, PartialEq, Eq, AsExpression, FromSqlRow)] + #[diesel(sql_type = Text)] + pub enum ProposalStatus { + Pending, + Approved, + Rejected, + } + + impl ToSql for ProposalStatus { + fn to_sql<'b>( + &'b self, + out: &mut diesel::serialize::Output<'b, '_, Sqlite>, + ) -> diesel::serialize::Result { + let s: &str = match self { + Self::Pending => "pending", + Self::Approved => "approved", + Self::Rejected => "rejected", + }; + >::to_sql(s, out) + } + } + + impl FromSql for ProposalStatus { + fn from_sql(bytes: ::RawValue<'_>) -> diesel::deserialize::Result { + let s = >::from_sql(bytes)?; + match s.as_str() { + "pending" => Ok(Self::Pending), + "approved" => Ok(Self::Approved), + "rejected" => Ok(Self::Rejected), + other => Err(format!("Unknown proposal status: {other}").into()), + } + } + } } pub use types::*; @@ -225,19 +263,22 @@ pub struct EvmWallet { #[view( NewEvmWalletAccess, derive(Insertable), - omit(id, created_at), + omit(id, created_at, revoked_at), attributes_with = "deriveless" )] #[view( CoreEvmWalletAccess, derive(Insertable), - omit(created_at), + omit(created_at, revoked_at), attributes_with = "deriveless" )] pub struct EvmWalletAccess { pub id: i32, pub wallet_id: EvmWalletId, pub client_id: i32, + // Grants, transaction logs, and persistent-grant proposals reference this row + // `on delete restrict`, so revocation cannot delete it -- it marks it revoked instead. + pub revoked_at: Option, pub created_at: SqliteTimestamp, } @@ -285,6 +326,7 @@ pub struct Operator { pub id: OperatorId, pub share: Vec, pub share_nonce: Vec, + pub share_salt: Vec, pub created_at: SqliteTimestamp, pub updated_at: SqliteTimestamp, } @@ -437,3 +479,58 @@ pub struct IntegrityEnvelope { pub signed_at: SqliteTimestamp, pub created_at: SqliteTimestamp, } + +#[derive(Debug, Queryable, Selectable, Identifiable)] +#[diesel(table_name = schema::proposal, check_for_backend(Sqlite))] +pub struct Proposal { + pub id: ProposalId, + pub kind: ProposalKindTag, + pub initiator_id: OperatorIdentityId, + pub created_at: SqliteTimestamp, + pub expires_at: SqliteTimestamp, + pub status: ProposalStatus, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = schema::proposal, check_for_backend(Sqlite))] +pub struct NewProposal { + pub kind: ProposalKindTag, + pub initiator_id: OperatorIdentityId, + // status defaults to 'pending' at the DB layer + pub expires_at: SqliteTimestamp, +} + +#[derive(Debug, Queryable, Selectable, Identifiable)] +#[diesel(table_name = schema::proposal_vote, check_for_backend(Sqlite))] +pub struct ProposalVote { + pub id: i32, + pub proposal_id: ProposalId, + pub operator_id: OperatorIdentityId, + pub approve: bool, + pub signature: Vec, + pub voted_at: SqliteTimestamp, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = schema::proposal_vote, check_for_backend(Sqlite))] +pub struct NewProposalVote { + pub proposal_id: ProposalId, + pub operator_id: OperatorIdentityId, + pub approve: bool, + pub signature: Vec, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = schema::recovery_proposal_vote, check_for_backend(Sqlite))] +pub struct NewRecoveryProposalVote { + pub proposal_id: ProposalId, + pub recovery_operator_id: RecoveryOperatorIdentityId, + pub approve: bool, + pub signature: Vec, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = schema::recovery_wakeup_request, check_for_backend(Sqlite))] +pub struct NewRecoveryWakeupRequest { + pub requested_by: OperatorIdentityId, +} diff --git a/server/crates/arbiter-server/src/db/proposal/approve_sdk_client.rs b/server/crates/arbiter-server/src/db/proposal/approve_sdk_client.rs new file mode 100644 index 0000000..cf4ef59 --- /dev/null +++ b/server/crates/arbiter-server/src/db/proposal/approve_sdk_client.rs @@ -0,0 +1,48 @@ +//! Approving an SDK client so it may authenticate against the vault. + +use super::{Proposal, ProposalKindTag}; +use crate::db::{ + DatabaseConnection, models::ProposalId, schema::proposal_approve_sdk_client as table, +}; +use diesel::{ + ExpressionMethods as _, Insertable, QueryDsl as _, QueryResult, Queryable, Selectable, + SelectableHelper as _, sqlite::Sqlite, +}; +use diesel_async::RunQueryDsl as _; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Queryable, Selectable, Insertable)] +#[diesel(table_name = table, check_for_backend(Sqlite))] +pub struct Settings { + pub client_id: i32, +} + +pub struct ApproveSdkClient; + +impl Proposal for ApproveSdkClient { + const KIND: ProposalKindTag = ProposalKindTag::ApproveSdkClient; + + type Settings = Settings; + + async fn insert( + proposal_id: ProposalId, + settings: &Self::Settings, + conn: &mut DatabaseConnection, + ) -> QueryResult<()> { + diesel::insert_into(table::table) + .values((table::proposal_id.eq(proposal_id), settings)) + .execute(conn) + .await + .map(drop) + } + + async fn load( + proposal_id: ProposalId, + conn: &mut DatabaseConnection, + ) -> QueryResult { + table::table + .find(proposal_id) + .select(Settings::as_select()) + .first(conn) + .await + } +} diff --git a/server/crates/arbiter-server/src/db/proposal/grant_wallet_access.rs b/server/crates/arbiter-server/src/db/proposal/grant_wallet_access.rs new file mode 100644 index 0000000..b54b1e3 --- /dev/null +++ b/server/crates/arbiter-server/src/db/proposal/grant_wallet_access.rs @@ -0,0 +1,49 @@ +//! Granting an SDK client visibility of a wallet. + +use super::{Proposal, ProposalKindTag}; +use crate::db::{ + DatabaseConnection, models::ProposalId, schema::proposal_grant_wallet_access as table, +}; +use diesel::{ + ExpressionMethods as _, Insertable, QueryDsl as _, QueryResult, Queryable, Selectable, + SelectableHelper as _, sqlite::Sqlite, +}; +use diesel_async::RunQueryDsl as _; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Queryable, Selectable, Insertable)] +#[diesel(table_name = table, check_for_backend(Sqlite))] +pub struct Settings { + pub wallet_id: i32, + pub client_id: i32, +} + +pub struct GrantWalletAccess; + +impl Proposal for GrantWalletAccess { + const KIND: ProposalKindTag = ProposalKindTag::GrantWalletAccess; + + type Settings = Settings; + + async fn insert( + proposal_id: ProposalId, + settings: &Self::Settings, + conn: &mut DatabaseConnection, + ) -> QueryResult<()> { + diesel::insert_into(table::table) + .values((table::proposal_id.eq(proposal_id), settings)) + .execute(conn) + .await + .map(drop) + } + + async fn load( + proposal_id: ProposalId, + conn: &mut DatabaseConnection, + ) -> QueryResult { + table::table + .find(proposal_id) + .select(Settings::as_select()) + .first(conn) + .await + } +} diff --git a/server/crates/arbiter-server/src/db/proposal/mod.rs b/server/crates/arbiter-server/src/db/proposal/mod.rs new file mode 100644 index 0000000..b03dbbd --- /dev/null +++ b/server/crates/arbiter-server/src/db/proposal/mod.rs @@ -0,0 +1,249 @@ +//! Governed actions and the parameters they carry. +//! +//! Laid out the way [`crate::evm::policies::Policy`] is: a unit type per kind, its +//! parameters as an associated `Settings`, and the persistence for those parameters +//! implemented next to them. Everything downstream is generic over [`Proposal`], so a +//! new kind is a new module plus one arm in each dispatcher -- nothing else in the +//! codebase has to learn about it. + +use crate::db::{DatabaseConnection, models::ProposalId}; +use diesel::{ + QueryResult, + backend::Backend, + deserialize::{FromSql, FromSqlRow}, + expression::AsExpression, + serialize::ToSql, + sql_types::Text, + sqlite::Sqlite, +}; +use strum::{Display, EnumDiscriminants, EnumString, IntoStaticStr}; + +pub mod approve_sdk_client; +pub mod grant_wallet_access; +pub mod one_off_transaction; +pub mod persistent_grant; +pub mod replace_operator; +pub mod trigger_rekey; + +pub use approve_sdk_client::ApproveSdkClient; +pub use grant_wallet_access::GrantWalletAccess; +pub use one_off_transaction::OneOffTransaction; +pub use persistent_grant::PersistentGrant; +pub use replace_operator::ReplaceOperator; +pub use trigger_rekey::TriggerRekey; + +/// A governed action that owns the child table holding its parameters. +pub trait Proposal: Sized { + /// The value stored in `proposal.kind` for this action. + const KIND: ProposalKindTag; + + /// Parameters the action is voted on with. + type Settings: Send + Sync + 'static; + + /// Writes the child row carrying `settings`. + fn insert( + proposal_id: ProposalId, + settings: &Self::Settings, + conn: &mut DatabaseConnection, + ) -> impl Future> + Send; + + /// Reads the child row back. A missing row surfaces as [`diesel::result::Error::NotFound`], + /// which is what a proposal without its parameters is. + fn load( + proposal_id: ProposalId, + conn: &mut DatabaseConnection, + ) -> impl Future> + Send; +} + +/// Parameters of a proposal, in the one shape that can cross the actor boundary. +/// +/// Every variant holds the `Settings` of the matching [`Proposal`] implementation, so +/// the two cannot drift. +#[derive(Debug, Clone, EnumDiscriminants)] +#[strum_discriminants( + name(ProposalKindTag), + vis(pub), + derive(Display, EnumString, IntoStaticStr, AsExpression, FromSqlRow), + diesel(sql_type = Text), + strum(serialize_all = "snake_case") +)] +pub enum ProposalKind { + ApproveSdkClient(approve_sdk_client::Settings), + GrantWalletAccess(grant_wallet_access::Settings), + ReplaceOperator(replace_operator::Settings), + TriggerRekey, + ApprovePersistentGrant(Box), + ApproveOneOffTransaction(Box), +} + +impl ProposalKindTag { + /// Key-rotation proposals require every operator to approve (§3.3). + #[must_use] + pub const fn requires_full_quorum(self) -> bool { + matches!(self, Self::ReplaceOperator | Self::TriggerRekey) + } + + /// §3.5: recovery operators weigh in on operator replacement and nothing else. + #[must_use] + pub const fn recovery_may_vote(self) -> bool { + matches!(self, Self::ReplaceOperator) + } +} + +/// Pins every implementation to the variant it is dispatched from. Without this a +/// mistyped `KIND` would compile and only show up as a proposal stored under the +/// wrong `proposal.kind`. +const _: () = { + assert!( + matches!(ApproveSdkClient::KIND, ProposalKindTag::ApproveSdkClient), + "ApproveSdkClient::KIND must be ProposalKindTag::ApproveSdkClient" + ); + assert!( + matches!(GrantWalletAccess::KIND, ProposalKindTag::GrantWalletAccess), + "GrantWalletAccess::KIND must be ProposalKindTag::GrantWalletAccess" + ); + assert!( + matches!(ReplaceOperator::KIND, ProposalKindTag::ReplaceOperator), + "ReplaceOperator::KIND must be ProposalKindTag::ReplaceOperator" + ); + assert!( + matches!(TriggerRekey::KIND, ProposalKindTag::TriggerRekey), + "TriggerRekey::KIND must be ProposalKindTag::TriggerRekey" + ); + assert!( + matches!( + PersistentGrant::KIND, + ProposalKindTag::ApprovePersistentGrant + ), + "PersistentGrant::KIND must be ProposalKindTag::ApprovePersistentGrant" + ); + assert!( + matches!( + OneOffTransaction::KIND, + ProposalKindTag::ApproveOneOffTransaction + ), + "OneOffTransaction::KIND must be ProposalKindTag::ApproveOneOffTransaction" + ); +}; + +/// Writes the child row carrying this proposal's parameters. +/// +/// The only place the create path has to know every kind; each arm hands straight off +/// to the implementation that owns the table. +pub async fn insert_kind( + conn: &mut DatabaseConnection, + proposal_id: ProposalId, + kind: &ProposalKind, +) -> QueryResult<()> { + match kind { + ProposalKind::ApproveSdkClient(s) => ApproveSdkClient::insert(proposal_id, s, conn).await, + ProposalKind::GrantWalletAccess(s) => GrantWalletAccess::insert(proposal_id, s, conn).await, + ProposalKind::ReplaceOperator(s) => ReplaceOperator::insert(proposal_id, s, conn).await, + ProposalKind::TriggerRekey => TriggerRekey::insert(proposal_id, &(), conn).await, + ProposalKind::ApprovePersistentGrant(s) => { + PersistentGrant::insert(proposal_id, s, conn).await + } + ProposalKind::ApproveOneOffTransaction(s) => { + OneOffTransaction::insert(proposal_id, s, conn).await + } + } +} + +/// Reads the parameters back for a `proposal.kind` that is only known at runtime. +pub async fn load_kind( + conn: &mut DatabaseConnection, + proposal_id: ProposalId, + tag: ProposalKindTag, +) -> QueryResult { + Ok(match tag { + ProposalKindTag::ApproveSdkClient => { + ProposalKind::ApproveSdkClient(ApproveSdkClient::load(proposal_id, conn).await?) + } + ProposalKindTag::GrantWalletAccess => { + ProposalKind::GrantWalletAccess(GrantWalletAccess::load(proposal_id, conn).await?) + } + ProposalKindTag::ReplaceOperator => { + ProposalKind::ReplaceOperator(ReplaceOperator::load(proposal_id, conn).await?) + } + ProposalKindTag::TriggerRekey => { + TriggerRekey::load(proposal_id, conn).await?; + ProposalKind::TriggerRekey + } + ProposalKindTag::ApprovePersistentGrant => ProposalKind::ApprovePersistentGrant(Box::new( + PersistentGrant::load(proposal_id, conn).await?, + )), + ProposalKindTag::ApproveOneOffTransaction => ProposalKind::ApproveOneOffTransaction( + Box::new(OneOffTransaction::load(proposal_id, conn).await?), + ), + }) +} + +impl ToSql for ProposalKindTag { + fn to_sql<'b>( + &'b self, + out: &mut diesel::serialize::Output<'b, '_, Sqlite>, + ) -> diesel::serialize::Result { + >::to_sql(<&'static str>::from(*self), out) + } +} + +impl FromSql for ProposalKindTag { + fn from_sql(bytes: ::RawValue<'_>) -> diesel::deserialize::Result { + let s = >::from_sql(bytes)?; + s.parse() + .map_err(|_| format!("Unknown proposal kind: {s}").into()) + } +} + +/// SQLite has no unsigned integers; the column is `BigInt`, so a value that does not +/// round-trip is a corrupt row rather than something to silently wrap. +pub(crate) fn as_i64(value: u64) -> QueryResult { + i64::try_from(value).map_err(|_| diesel::result::Error::SerializationError(Box::new(Overflow))) +} + +pub(crate) fn as_u64(value: i64) -> QueryResult { + u64::try_from(value) + .map_err(|_| diesel::result::Error::DeserializationError(Box::new(Overflow))) +} + +pub(crate) fn fixed_bytes( + bytes: &[u8], + column: &'static str, +) -> QueryResult<[u8; N]> { + <[u8; N]>::try_from(bytes) + .map_err(|_| diesel::result::Error::DeserializationError(Box::new(WrongLength(column)))) +} + +/// Reads a fixed-width column into an array, labelling failures with the column it came +/// from. +/// +/// The label is taken from the field itself, so renaming a column cannot leave a stale +/// name behind in the error -- which is the whole reason this is a macro and not a +/// second argument. +/// +/// - `fixed!(row.column)` for a `Vec` field +/// - `fixed!(opt row.column)` for a `Option>` one +/// - `fixed!(binding)` for a local +macro_rules! fixed { + (opt $src:ident.$field:ident) => { + $src.$field + .as_deref() + .map(|value| $crate::db::proposal::fixed_bytes(value, stringify!($field))) + .transpose() + }; + ($src:ident.$field:ident) => { + $crate::db::proposal::fixed_bytes(&$src.$field, stringify!($field)) + }; + ($binding:ident) => { + $crate::db::proposal::fixed_bytes(&$binding, stringify!($binding)) + }; +} +pub(crate) use fixed; + +#[derive(Debug, thiserror::Error)] +#[error("value does not fit a SQLite integer")] +struct Overflow; + +#[derive(Debug, thiserror::Error)] +#[error("column {0} has the wrong byte length")] +struct WrongLength(&'static str); diff --git a/server/crates/arbiter-server/src/db/proposal/one_off_transaction.rs b/server/crates/arbiter-server/src/db/proposal/one_off_transaction.rs new file mode 100644 index 0000000..4c694c6 --- /dev/null +++ b/server/crates/arbiter-server/src/db/proposal/one_off_transaction.rs @@ -0,0 +1,138 @@ +//! Signing a single EIP-1559 transaction. + +use super::{Proposal, ProposalKindTag, as_i64, as_u64, fixed}; +use crate::db::{ + DatabaseConnection, + models::ProposalId, + schema::{proposal_one_off_transaction, proposal_one_off_transaction_result}, +}; +use diesel::{ + Insertable, QueryDsl as _, QueryResult, Queryable, Selectable, SelectableHelper as _, + sqlite::Sqlite, +}; +use diesel_async::RunQueryDsl as _; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Settings { + pub client_id: i32, + pub wallet_address: [u8; 20], + pub chain_id: u64, + pub nonce: u64, + pub gas_limit: u64, + pub max_fee_per_gas: u128, + pub max_priority_fee_per_gas: u128, + pub to: [u8; 20], + pub value: [u8; 32], + pub input: Vec, +} + +#[derive(Debug, Queryable, Selectable, Insertable)] +#[diesel(table_name = proposal_one_off_transaction, check_for_backend(Sqlite))] +struct Row { + proposal_id: ProposalId, + client_id: i32, + wallet_address: Vec, + chain_id: i64, + nonce: i64, + gas_limit: i64, + max_fee_per_gas: Vec, + max_priority_fee_per_gas: Vec, + to_address: Vec, + value: Vec, + input: Vec, +} + +impl Row { + fn new(proposal_id: ProposalId, settings: &Settings) -> QueryResult { + Ok(Self { + proposal_id, + client_id: settings.client_id, + wallet_address: settings.wallet_address.to_vec(), + chain_id: as_i64(settings.chain_id)?, + nonce: as_i64(settings.nonce)?, + gas_limit: as_i64(settings.gas_limit)?, + max_fee_per_gas: settings.max_fee_per_gas.to_be_bytes().to_vec(), + max_priority_fee_per_gas: settings.max_priority_fee_per_gas.to_be_bytes().to_vec(), + to_address: settings.to.to_vec(), + value: settings.value.to_vec(), + input: settings.input.clone(), + }) + } + + fn into_settings(self) -> QueryResult { + Ok(Settings { + client_id: self.client_id, + wallet_address: fixed!(self.wallet_address)?, + chain_id: as_u64(self.chain_id)?, + nonce: as_u64(self.nonce)?, + gas_limit: as_u64(self.gas_limit)?, + max_fee_per_gas: u128::from_be_bytes(fixed!(self.max_fee_per_gas)?), + max_priority_fee_per_gas: u128::from_be_bytes(fixed!(self.max_priority_fee_per_gas)?), + to: fixed!(self.to_address)?, + value: fixed!(self.value)?, + input: self.input, + }) + } +} + +pub struct OneOffTransaction; + +impl Proposal for OneOffTransaction { + const KIND: ProposalKindTag = ProposalKindTag::ApproveOneOffTransaction; + + type Settings = Settings; + + async fn insert( + proposal_id: ProposalId, + settings: &Self::Settings, + conn: &mut DatabaseConnection, + ) -> QueryResult<()> { + diesel::insert_into(proposal_one_off_transaction::table) + .values(&Row::new(proposal_id, settings)?) + .execute(conn) + .await + .map(drop) + } + + async fn load( + proposal_id: ProposalId, + conn: &mut DatabaseConnection, + ) -> QueryResult { + let row: Row = proposal_one_off_transaction::table + .find(proposal_id) + .select(Row::as_select()) + .first(conn) + .await?; + + row.into_settings() + } +} + +/// The signature the vault produced for an approved transaction. +#[derive(Debug, Insertable)] +#[diesel(table_name = proposal_one_off_transaction_result, check_for_backend(Sqlite))] +struct SignatureRow { + proposal_id: ProposalId, + r: Vec, + s: Vec, + y_parity: i32, +} + +/// Records the signature produced for an approved transaction, by component, so what +/// came back is as readable as what was signed. +pub async fn store_signature( + proposal_id: ProposalId, + signature: &alloy::signers::Signature, + conn: &mut DatabaseConnection, +) -> QueryResult<()> { + diesel::insert_into(proposal_one_off_transaction_result::table) + .values(&SignatureRow { + proposal_id, + r: signature.r().to_be_bytes::<32>().to_vec(), + s: signature.s().to_be_bytes::<32>().to_vec(), + y_parity: i32::from(signature.v()), + }) + .execute(conn) + .await + .map(drop) +} diff --git a/server/crates/arbiter-server/src/db/proposal/persistent_grant.rs b/server/crates/arbiter-server/src/db/proposal/persistent_grant.rs new file mode 100644 index 0000000..57f3376 --- /dev/null +++ b/server/crates/arbiter-server/src/db/proposal/persistent_grant.rs @@ -0,0 +1,271 @@ +//! Creating a standing EVM grant. +use super::{Proposal, ProposalKindTag, as_i64, as_u64, fixed}; +use crate::db::{ + DatabaseConnection, + models::ProposalId, + schema::{ + proposal_persistent_grant, proposal_persistent_grant_ether, + proposal_persistent_grant_ether_target, proposal_persistent_grant_token, + proposal_persistent_grant_token_limit, + }, +}; +use diesel::{ + ExpressionMethods as _, Insertable, OptionalExtension as _, QueryDsl as _, QueryResult, + Queryable, Selectable, SelectableHelper as _, sqlite::Sqlite, +}; +use diesel_async::RunQueryDsl as _; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Settings { + pub wallet_access_id: i32, + pub chain_id: u64, + pub valid_from_secs: Option, + pub valid_until_secs: Option, + pub max_gas_fee_per_gas: Option<[u8; 32]>, + pub max_priority_fee_per_gas: Option<[u8; 32]>, + pub rate_limit: Option, + pub specific: Specific, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RateLimit { + pub count: u32, + pub window_secs: i64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VolumeLimit { + pub max_volume: [u8; 32], + pub window_secs: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Specific { + EtherTransfer { + targets: Vec<[u8; 20]>, + limit: VolumeLimit, + }, + TokenTransfer { + token_contract: [u8; 20], + receiver: Option<[u8; 20]>, + volume_limits: Vec, + }, +} + +/// Shared settings, mirroring `evm_basic_grant`. +#[derive(Debug, Queryable, Selectable, Insertable)] +#[diesel(table_name = proposal_persistent_grant, check_for_backend(Sqlite))] +struct BaseRow { + proposal_id: ProposalId, + wallet_access_id: i32, + chain_id: i64, + valid_from: Option, + valid_until: Option, + max_gas_fee_per_gas: Option>, + max_priority_fee_per_gas: Option>, + rate_limit_count: Option, + rate_limit_window_secs: Option, +} + +#[derive(Debug, Queryable, Selectable, Insertable)] +#[diesel(table_name = proposal_persistent_grant_ether, check_for_backend(Sqlite))] +struct EtherRow { + proposal_id: ProposalId, + window_secs: i64, + max_volume: Vec, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = proposal_persistent_grant_ether_target, check_for_backend(Sqlite))] +struct NewEtherTarget { + proposal_id: ProposalId, + address: Vec, +} + +#[derive(Debug, Queryable, Selectable, Insertable)] +#[diesel(table_name = proposal_persistent_grant_token, check_for_backend(Sqlite))] +struct TokenRow { + proposal_id: ProposalId, + token_contract: Vec, + receiver: Option>, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = proposal_persistent_grant_token_limit, check_for_backend(Sqlite))] +struct NewTokenLimit { + proposal_id: ProposalId, + window_secs: i64, + max_volume: Vec, +} + +impl BaseRow { + fn new(proposal_id: ProposalId, settings: &Settings) -> QueryResult { + Ok(Self { + proposal_id, + wallet_access_id: settings.wallet_access_id, + chain_id: as_i64(settings.chain_id)?, + valid_from: settings.valid_from_secs, + valid_until: settings.valid_until_secs, + max_gas_fee_per_gas: settings.max_gas_fee_per_gas.map(|v| v.to_vec()), + max_priority_fee_per_gas: settings.max_priority_fee_per_gas.map(|v| v.to_vec()), + // SQLite stores integers signed; a rate-limit count is a `u32`, so it + // round-trips through the bit pattern rather than a fallible range check. + rate_limit_count: settings.rate_limit.map(|r| r.count.cast_signed()), + rate_limit_window_secs: settings.rate_limit.map(|r| r.window_secs), + }) + } + + fn into_settings(self, specific: Specific) -> QueryResult { + Ok(Settings { + wallet_access_id: self.wallet_access_id, + chain_id: as_u64(self.chain_id)?, + valid_from_secs: self.valid_from, + valid_until_secs: self.valid_until, + max_gas_fee_per_gas: fixed!(opt self.max_gas_fee_per_gas)?, + max_priority_fee_per_gas: fixed!(opt self.max_priority_fee_per_gas)?, + rate_limit: self.rate_limit_count.zip(self.rate_limit_window_secs).map( + |(count, window_secs)| RateLimit { + count: count.cast_unsigned(), + window_secs, + }, + ), + specific, + }) + } +} + +pub struct PersistentGrant; + +impl Proposal for PersistentGrant { + const KIND: ProposalKindTag = ProposalKindTag::ApprovePersistentGrant; + + type Settings = Settings; + + async fn insert( + proposal_id: ProposalId, + settings: &Self::Settings, + conn: &mut DatabaseConnection, + ) -> QueryResult<()> { + diesel::insert_into(proposal_persistent_grant::table) + .values(&BaseRow::new(proposal_id, settings)?) + .execute(conn) + .await?; + + match &settings.specific { + Specific::EtherTransfer { targets, limit } => { + diesel::insert_into(proposal_persistent_grant_ether::table) + .values(&EtherRow { + proposal_id, + window_secs: limit.window_secs, + max_volume: limit.max_volume.to_vec(), + }) + .execute(conn) + .await?; + + // Row at a time: SQLite has no multi-row VALUES clause in diesel-async. + for address in targets { + diesel::insert_into(proposal_persistent_grant_ether_target::table) + .values(&NewEtherTarget { + proposal_id, + address: address.to_vec(), + }) + .execute(conn) + .await?; + } + } + Specific::TokenTransfer { + token_contract, + receiver, + volume_limits, + } => { + diesel::insert_into(proposal_persistent_grant_token::table) + .values(&TokenRow { + proposal_id, + token_contract: token_contract.to_vec(), + receiver: receiver.map(|r| r.to_vec()), + }) + .execute(conn) + .await?; + + for limit in volume_limits { + diesel::insert_into(proposal_persistent_grant_token_limit::table) + .values(&NewTokenLimit { + proposal_id, + window_secs: limit.window_secs, + max_volume: limit.max_volume.to_vec(), + }) + .execute(conn) + .await?; + } + } + } + Ok(()) + } + + async fn load( + proposal_id: ProposalId, + conn: &mut DatabaseConnection, + ) -> QueryResult { + let base: BaseRow = proposal_persistent_grant::table + .find(proposal_id) + .select(BaseRow::as_select()) + .first(conn) + .await?; + + let ether: Option = proposal_persistent_grant_ether::table + .find(proposal_id) + .select(EtherRow::as_select()) + .first(conn) + .await + .optional()?; + + let specific = if let Some(ether) = ether { + let addresses: Vec> = proposal_persistent_grant_ether_target::table + .filter(proposal_persistent_grant_ether_target::proposal_id.eq(proposal_id)) + .select(proposal_persistent_grant_ether_target::address) + .load(conn) + .await?; + let targets = addresses + .iter() + .map(|address| fixed!(address)) + .collect::>>()?; + Specific::EtherTransfer { + targets, + limit: VolumeLimit { + max_volume: fixed!(ether.max_volume)?, + window_secs: ether.window_secs, + }, + } + } else { + let token: TokenRow = proposal_persistent_grant_token::table + .find(proposal_id) + .select(TokenRow::as_select()) + .first(conn) + .await?; + let rows: Vec<(i64, Vec)> = proposal_persistent_grant_token_limit::table + .filter(proposal_persistent_grant_token_limit::proposal_id.eq(proposal_id)) + .select(( + proposal_persistent_grant_token_limit::window_secs, + proposal_persistent_grant_token_limit::max_volume, + )) + .load(conn) + .await?; + let volume_limits = rows + .into_iter() + .map(|(window_secs, max_volume)| { + Ok(VolumeLimit { + max_volume: fixed!(max_volume)?, + window_secs, + }) + }) + .collect::>>()?; + Specific::TokenTransfer { + token_contract: fixed!(token.token_contract)?, + receiver: fixed!(opt token.receiver)?, + volume_limits, + } + }; + + base.into_settings(specific) + } +} diff --git a/server/crates/arbiter-server/src/db/proposal/replace_operator.rs b/server/crates/arbiter-server/src/db/proposal/replace_operator.rs new file mode 100644 index 0000000..1074084 --- /dev/null +++ b/server/crates/arbiter-server/src/db/proposal/replace_operator.rs @@ -0,0 +1,51 @@ +//! Replacing an operator's key, which also triggers a Shamir re-key (§3.3). + +use super::{Proposal, ProposalKindTag}; +use crate::db::{ + DatabaseConnection, + models::{OperatorIdentityId, ProposalId}, + schema::proposal_replace_operator as table, +}; +use diesel::{ + ExpressionMethods as _, Insertable, QueryDsl as _, QueryResult, Queryable, Selectable, + SelectableHelper as _, sqlite::Sqlite, +}; +use diesel_async::RunQueryDsl as _; + +#[derive(Debug, Clone, PartialEq, Eq, Queryable, Selectable, Insertable)] +#[diesel(table_name = table, check_for_backend(Sqlite))] +pub struct Settings { + pub old_operator_id: OperatorIdentityId, + pub new_pubkey: Vec, +} + +pub struct ReplaceOperator; + +impl Proposal for ReplaceOperator { + const KIND: ProposalKindTag = ProposalKindTag::ReplaceOperator; + + type Settings = Settings; + + async fn insert( + proposal_id: ProposalId, + settings: &Self::Settings, + conn: &mut DatabaseConnection, + ) -> QueryResult<()> { + diesel::insert_into(table::table) + .values((table::proposal_id.eq(proposal_id), settings)) + .execute(conn) + .await + .map(drop) + } + + async fn load( + proposal_id: ProposalId, + conn: &mut DatabaseConnection, + ) -> QueryResult { + table::table + .find(proposal_id) + .select(Settings::as_select()) + .first(conn) + .await + } +} diff --git a/server/crates/arbiter-server/src/db/proposal/trigger_rekey.rs b/server/crates/arbiter-server/src/db/proposal/trigger_rekey.rs new file mode 100644 index 0000000..b132f74 --- /dev/null +++ b/server/crates/arbiter-server/src/db/proposal/trigger_rekey.rs @@ -0,0 +1,28 @@ +//! A Shamir re-key over the current operator set (§3.3). + +use super::{Proposal, ProposalKindTag}; +use crate::db::{DatabaseConnection, models::ProposalId}; +use diesel::QueryResult; + +pub struct TriggerRekey; + +impl Proposal for TriggerRekey { + const KIND: ProposalKindTag = ProposalKindTag::TriggerRekey; + + type Settings = (); + + async fn insert( + _proposal_id: ProposalId, + _settings: &Self::Settings, + _conn: &mut DatabaseConnection, + ) -> QueryResult<()> { + Ok(()) + } + + async fn load( + _proposal_id: ProposalId, + _conn: &mut DatabaseConnection, + ) -> QueryResult { + Ok(()) + } +} diff --git a/server/crates/arbiter-server/src/db/recovery.rs b/server/crates/arbiter-server/src/db/recovery.rs new file mode 100644 index 0000000..638331d --- /dev/null +++ b/server/crates/arbiter-server/src/db/recovery.rs @@ -0,0 +1,102 @@ +//! Whether the recovery committee is awake. +//! +//! §3.6: a wake-up request opens a dispute window; recovery powers only become active once +//! that window has elapsed without cancellation. Both the proposal manager (for voting) and +//! the vault coordinator (for unsealing) gate on this, so the rule lives in one place. + +use crate::db::{functions::unixepoch, schema}; + +use diesel::{ + ExpressionMethods as _, QueryDsl as _, + dsl::{exists, select}, +}; +use diesel_async::RunQueryDsl; + +/// Recovery operators stay asleep for this long after a wake-up is requested, so the other +/// operators have time to dispute it (§3.6). +pub const WAKEUP_DELAY_SECS: i32 = 14 * 24 * 60 * 60; + +/// True when an uncancelled wake-up request is older than the dispute window. +pub async fn is_active( + conn: &mut crate::db::DatabaseConnection, +) -> Result { + select(exists( + schema::recovery_wakeup_request::table + .filter(schema::recovery_wakeup_request::cancelled_at.is_null()) + .filter( + schema::recovery_wakeup_request::requested_at + .le(unixepoch("now") - WAKEUP_DELAY_SECS), + ), + )) + .get_result(conn) + .await +} + +#[cfg(test)] +mod tests { + use super::{WAKEUP_DELAY_SECS, is_active}; + use crate::db::{self, schema}; + + use diesel::{ExpressionMethods as _, insert_into}; + use diesel_async::RunQueryDsl; + + /// `recovery_wakeup_request.requested_by` references `operator_identity(id)`, and pooled + /// connections enforce foreign keys, so every wake-up row needs a real identity behind it. + async fn insert_operator(pool: &db::DatabasePool) -> i32 { + let mut conn = pool.get().await.unwrap(); + insert_into(schema::operator_identity::table) + .values(schema::operator_identity::public_key.eq(vec![7u8; 32])) + .returning(schema::operator_identity::id) + .get_result(&mut conn) + .await + .unwrap() + } + + /// Pins `.filter(requested_at.le(...))`: a wake-up requested moments ago must not be + /// active yet, even though nothing has cancelled it. Deleting that filter turns this + /// assertion false without touching any other test in the suite. + #[tokio::test] + async fn a_recent_wakeup_is_not_yet_active() { + let pool = db::create_test_pool().await; + let operator_id = insert_operator(&pool).await; + let mut conn = pool.get().await.unwrap(); + + diesel::sql_query(format!( + "INSERT INTO recovery_wakeup_request (requested_by, requested_at) \ + VALUES ({operator_id}, unixepoch('now'))" + )) + .execute(&mut conn) + .await + .unwrap(); + + assert!( + !is_active(&mut conn).await.unwrap(), + "a wake-up requested moments ago must still be asleep" + ); + } + + /// Pins `.filter(cancelled_at.is_null())`: a cancelled wake-up must not count towards + /// activity even once its original request has outlived the dispute window. Deleting + /// that filter turns this assertion false without touching any other test in the suite. + #[tokio::test] + async fn a_cancelled_wakeup_is_not_active_even_past_the_window() { + let pool = db::create_test_pool().await; + let operator_id = insert_operator(&pool).await; + let mut conn = pool.get().await.unwrap(); + + diesel::sql_query(format!( + "INSERT INTO recovery_wakeup_request \ + (requested_by, requested_at, cancelled_by, cancelled_at) \ + VALUES ({operator_id}, unixepoch('now') - {WAKEUP_DELAY_SECS} - 1, \ + {operator_id}, unixepoch('now'))" + )) + .execute(&mut conn) + .await + .unwrap(); + + assert!( + !is_active(&mut conn).await.unwrap(), + "a cancelled wake-up must not activate recovery even past the window" + ); + } +} diff --git a/server/crates/arbiter-server/src/db/schema.rs b/server/crates/arbiter-server/src/db/schema.rs index c2f9869..9922733 100644 --- a/server/crates/arbiter-server/src/db/schema.rs +++ b/server/crates/arbiter-server/src/db/schema.rs @@ -17,6 +17,7 @@ diesel::table! { id -> Integer, root_key_id -> Nullable, tls_id -> Nullable, + shamir_threshold -> Nullable, } } @@ -135,6 +136,7 @@ diesel::table! { id -> Integer, wallet_id -> Integer, client_id -> Integer, + revoked_at -> Nullable, created_at -> Integer, } } @@ -157,6 +159,7 @@ diesel::table! { id -> Nullable, share -> Binary, share_nonce -> Binary, + share_salt -> Binary, created_at -> Integer, updated_at -> Integer, } @@ -171,6 +174,165 @@ diesel::table! { } } +diesel::table! { + proposal (id) { + id -> Integer, + kind -> Text, + initiator_id -> Integer, + created_at -> Integer, + expires_at -> Integer, + status -> Text, + } +} + +diesel::table! { + proposal_approve_sdk_client (proposal_id) { + proposal_id -> Integer, + client_id -> Integer, + } +} + +diesel::table! { + proposal_grant_wallet_access (proposal_id) { + proposal_id -> Integer, + wallet_id -> Integer, + client_id -> Integer, + } +} + +diesel::table! { + proposal_replace_operator (proposal_id) { + proposal_id -> Integer, + old_operator_id -> Integer, + new_pubkey -> Binary, + } +} + +diesel::table! { + proposal_one_off_transaction (proposal_id) { + proposal_id -> Integer, + client_id -> Integer, + wallet_address -> Binary, + chain_id -> BigInt, + nonce -> BigInt, + gas_limit -> BigInt, + max_fee_per_gas -> Binary, + max_priority_fee_per_gas -> Binary, + to_address -> Binary, + value -> Binary, + input -> Binary, + } +} + +diesel::table! { + proposal_persistent_grant (proposal_id) { + proposal_id -> Integer, + wallet_access_id -> Integer, + chain_id -> BigInt, + valid_from -> Nullable, + valid_until -> Nullable, + max_gas_fee_per_gas -> Nullable, + max_priority_fee_per_gas -> Nullable, + rate_limit_count -> Nullable, + rate_limit_window_secs -> Nullable, + } +} + +diesel::table! { + proposal_persistent_grant_ether (proposal_id) { + proposal_id -> Integer, + window_secs -> BigInt, + max_volume -> Binary, + } +} + +diesel::table! { + proposal_persistent_grant_ether_target (id) { + id -> Integer, + proposal_id -> Integer, + address -> Binary, + } +} + +diesel::table! { + proposal_persistent_grant_token (proposal_id) { + proposal_id -> Integer, + token_contract -> Binary, + receiver -> Nullable, + } +} + +diesel::table! { + proposal_persistent_grant_token_limit (id) { + id -> Integer, + proposal_id -> Integer, + window_secs -> BigInt, + max_volume -> Binary, + } +} + +diesel::table! { + proposal_one_off_transaction_result (proposal_id) { + proposal_id -> Integer, + r -> Binary, + s -> Binary, + y_parity -> Integer, + created_at -> Integer, + } +} + +diesel::table! { + recovery_operator (id) { + id -> Integer, + share -> Binary, + share_nonce -> Binary, + share_salt -> Binary, + created_at -> Integer, + updated_at -> Integer, + } +} + +diesel::table! { + recovery_operator_identity (id) { + id -> Integer, + public_key -> Binary, + created_at -> Integer, + updated_at -> Integer, + } +} + +diesel::table! { + recovery_wakeup_request (id) { + id -> Integer, + requested_by -> Integer, + requested_at -> Integer, + cancelled_by -> Nullable, + cancelled_at -> Nullable, + } +} + +diesel::table! { + recovery_proposal_vote (id) { + id -> Integer, + proposal_id -> Integer, + recovery_operator_id -> Integer, + approve -> Bool, + signature -> Binary, + voted_at -> Integer, + } +} + +diesel::table! { + proposal_vote (id) { + id -> Integer, + proposal_id -> Integer, + operator_id -> Integer, + approve -> Bool, + signature -> Binary, + voted_at -> Integer, + } +} + diesel::table! { program_client (id) { id -> Integer, @@ -224,9 +386,38 @@ diesel::joinable!(evm_wallet_access -> evm_wallet (wallet_id)); diesel::joinable!(evm_wallet_access -> program_client (client_id)); diesel::joinable!(operator -> operator_identity (id)); diesel::joinable!(program_client -> client_metadata (metadata_id)); +diesel::joinable!(proposal -> operator_identity (initiator_id)); +diesel::joinable!(proposal_one_off_transaction_result -> proposal_one_off_transaction (proposal_id)); +diesel::joinable!(proposal_approve_sdk_client -> proposal (proposal_id)); +diesel::joinable!(proposal_grant_wallet_access -> proposal (proposal_id)); +diesel::joinable!(proposal_replace_operator -> proposal (proposal_id)); +diesel::joinable!(proposal_one_off_transaction -> proposal (proposal_id)); +diesel::joinable!(proposal_persistent_grant -> proposal (proposal_id)); +diesel::joinable!(proposal_persistent_grant_ether -> proposal_persistent_grant (proposal_id)); +diesel::joinable!(proposal_persistent_grant_token -> proposal_persistent_grant (proposal_id)); +diesel::joinable!(proposal_vote -> proposal (proposal_id)); +diesel::joinable!(proposal_vote -> operator_identity (operator_id)); +diesel::joinable!(recovery_operator -> recovery_operator_identity (id)); +diesel::joinable!(recovery_proposal_vote -> proposal (proposal_id)); +diesel::joinable!(recovery_proposal_vote -> recovery_operator_identity (recovery_operator_id)); +diesel::joinable!(recovery_wakeup_request -> operator_identity (requested_by)); diesel::allow_tables_to_appear_in_same_query!( aead_encrypted, + proposal_one_off_transaction_result, + proposal_approve_sdk_client, + proposal_grant_wallet_access, + proposal_replace_operator, + proposal_one_off_transaction, + proposal_persistent_grant, + proposal_persistent_grant_ether, + proposal_persistent_grant_ether_target, + proposal_persistent_grant_token, + proposal_persistent_grant_token_limit, + recovery_operator, + recovery_operator_identity, + recovery_wakeup_request, + recovery_proposal_vote, arbiter_settings, client_metadata, client_metadata_history, @@ -244,6 +435,8 @@ diesel::allow_tables_to_appear_in_same_query!( operator, operator_identity, program_client, + proposal, + proposal_vote, root_key_history, tls_history, ); diff --git a/server/crates/arbiter-server/src/evm/mod.rs b/server/crates/arbiter-server/src/evm/mod.rs index 5d05ca4..f557270 100644 --- a/server/crates/arbiter-server/src/evm/mod.rs +++ b/server/crates/arbiter-server/src/evm/mod.rs @@ -352,16 +352,17 @@ impl Engine { mod tests { use alloy::primitives::{Address, Bytes, U256, address}; use chrono::{Duration, Utc}; - use diesel::{SelectableHelper, insert_into}; + use diesel::{ExpressionMethods as _, SelectableHelper, insert_into}; use diesel_async::RunQueryDsl; use rstest::rstest; use crate::db::{ - self, DatabaseConnection, + self, DatabaseConnection, models, models::{ EvmBasicGrant, EvmWalletAccess, EvmWalletId, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp, }, + schema, schema::{evm_basic_grant, evm_transaction_log}, }; use crate::evm::policies::{ @@ -380,6 +381,7 @@ mod tests { id: WALLET_ACCESS_ID, wallet_id: EvmWalletId::from_raw(5), client_id: 20, + revoked_at: None, created_at: SqliteTimestamp(Utc::now()), }, chain: CHAIN_ID, @@ -403,10 +405,85 @@ mod tests { } } + /// Creates the parent chain a fresh `evm_wallet_access` row needs under foreign-key + /// enforcement (a root key, an aead-encrypted secret, a wallet, and a client) and + /// returns the new access row's id. + async fn seed_wallet_access(conn: &mut DatabaseConnection) -> i32 { + let root_key_id: models::RootKeyHistoryId = insert_into(schema::root_key_history::table) + .values(&models::NewRootKeyHistory { + ciphertext: vec![0u8; 32], + tag: vec![0u8; 16], + root_key_encryption_nonce: vec![0u8; 24], + data_encryption_nonce: vec![0u8; 24], + schema_version: 1, + salt: vec![0u8; 16], + }) + .returning(schema::root_key_history::id) + .get_result(conn) + .await + .unwrap(); + + let aead_id: i32 = insert_into(schema::aead_encrypted::table) + .values(&models::NewAeadEncrypted { + ciphertext: vec![0u8; 32], + tag: vec![0u8; 16], + current_nonce: vec![0u8; 24], + schema_version: 1, + associated_root_key_id: root_key_id, + created_at: Utc::now().into(), + }) + .returning(schema::aead_encrypted::id) + .get_result(conn) + .await + .unwrap(); + + let wallet_id: EvmWalletId = insert_into(schema::evm_wallet::table) + .values(( + schema::evm_wallet::address.eq(rand::random::<[u8; 20]>().to_vec()), + schema::evm_wallet::aead_encrypted_id.eq(aead_id), + )) + .returning(schema::evm_wallet::id) + .get_result(conn) + .await + .unwrap(); + + let metadata_id: i32 = insert_into(schema::client_metadata::table) + .values(schema::client_metadata::name.eq("test")) + .returning(schema::client_metadata::id) + .get_result(conn) + .await + .unwrap(); + + let client_id: i32 = insert_into(schema::program_client::table) + .values(( + schema::program_client::public_key.eq(rand::random::<[u8; 32]>().to_vec()), + schema::program_client::metadata_id.eq(metadata_id), + )) + .returning(schema::program_client::id) + .get_result(conn) + .await + .unwrap(); + + insert_into(schema::evm_wallet_access::table) + .values(( + schema::evm_wallet_access::wallet_id.eq(wallet_id), + schema::evm_wallet_access::client_id.eq(client_id), + )) + .returning(schema::evm_wallet_access::id) + .get_result(conn) + .await + .unwrap() + } + async fn insert_basic_grant( conn: &mut DatabaseConnection, shared: &SharedGrantSettings, ) -> EvmBasicGrant { + // The seeded id deliberately wins over `shared.wallet_access_id`: every other field + // below is read from `shared`, but a caller-supplied access id would almost never + // reference a row that actually exists under foreign-key enforcement. + let wallet_access_id = seed_wallet_access(conn).await; + #[expect( clippy::cast_possible_truncation, clippy::cast_possible_wrap, @@ -415,7 +492,7 @@ mod tests { )] insert_into(evm_basic_grant::table) .values(NewEvmBasicGrant { - wallet_access_id: shared.wallet_access_id, + wallet_access_id, chain_id: shared.chain.into(), valid_from: shared.valid_from.map(SqliteTimestamp), valid_until: shared.valid_until.map(SqliteTimestamp), @@ -579,7 +656,7 @@ mod tests { insert_into(evm_transaction_log::table) .values(NewEvmTransactionLog { grant_id: basic_grant.id, - wallet_access_id: WALLET_ACCESS_ID, + wallet_access_id: basic_grant.wallet_access_id, chain_id: CHAIN_ID.into(), eth_value: super::utils::u256_to_bytes(U256::ZERO).to_vec(), signed_at: SqliteTimestamp(Utc::now()), diff --git a/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs b/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs index b9deb99..19ea8be 100644 --- a/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs +++ b/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs @@ -1,11 +1,12 @@ use super::{EtherTransfer, Settings}; use crate::{ db::{ - self, DatabaseConnection, + self, DatabaseConnection, models, models::{ EvmBasicGrant, EvmWalletAccess, EvmWalletId, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp, }, + schema, schema::{evm_basic_grant, evm_transaction_log}, }, evm::{ @@ -19,7 +20,7 @@ use crate::{ use alloy::primitives::{Address, Bytes, U256, address}; use chrono::{Duration, Utc}; -use diesel::{SelectableHelper, insert_into}; +use diesel::{ExpressionMethods as _, SelectableHelper, insert_into}; use diesel_async::RunQueryDsl; const WALLET_ACCESS_ID: i32 = 1; @@ -34,6 +35,7 @@ fn ctx(to: Address, value: U256) -> EvalContext { id: WALLET_ACCESS_ID, wallet_id: EvmWalletId::from_raw(10), client_id: 20, + revoked_at: None, created_at: SqliteTimestamp(Utc::now()), }, chain: CHAIN_ID, @@ -45,10 +47,82 @@ fn ctx(to: Address, value: U256) -> EvalContext { } } +/// Creates the parent chain a fresh `evm_wallet_access` row needs under foreign-key +/// enforcement (a root key, an aead-encrypted secret, a wallet, and a client) and returns +/// the new access row's id. +async fn seed_wallet_access(conn: &mut DatabaseConnection) -> i32 { + let root_key_id: models::RootKeyHistoryId = insert_into(schema::root_key_history::table) + .values(&models::NewRootKeyHistory { + ciphertext: vec![0u8; 32], + tag: vec![0u8; 16], + root_key_encryption_nonce: vec![0u8; 24], + data_encryption_nonce: vec![0u8; 24], + schema_version: 1, + salt: vec![0u8; 16], + }) + .returning(schema::root_key_history::id) + .get_result(conn) + .await + .unwrap(); + + let aead_id: i32 = insert_into(schema::aead_encrypted::table) + .values(&models::NewAeadEncrypted { + ciphertext: vec![0u8; 32], + tag: vec![0u8; 16], + current_nonce: vec![0u8; 24], + schema_version: 1, + associated_root_key_id: root_key_id, + created_at: Utc::now().into(), + }) + .returning(schema::aead_encrypted::id) + .get_result(conn) + .await + .unwrap(); + + let wallet_id: EvmWalletId = insert_into(schema::evm_wallet::table) + .values(( + schema::evm_wallet::address.eq(rand::random::<[u8; 20]>().to_vec()), + schema::evm_wallet::aead_encrypted_id.eq(aead_id), + )) + .returning(schema::evm_wallet::id) + .get_result(conn) + .await + .unwrap(); + + let metadata_id: i32 = insert_into(schema::client_metadata::table) + .values(schema::client_metadata::name.eq("test")) + .returning(schema::client_metadata::id) + .get_result(conn) + .await + .unwrap(); + + let client_id: i32 = insert_into(schema::program_client::table) + .values(( + schema::program_client::public_key.eq(rand::random::<[u8; 32]>().to_vec()), + schema::program_client::metadata_id.eq(metadata_id), + )) + .returning(schema::program_client::id) + .get_result(conn) + .await + .unwrap(); + + insert_into(schema::evm_wallet_access::table) + .values(( + schema::evm_wallet_access::wallet_id.eq(wallet_id), + schema::evm_wallet_access::client_id.eq(client_id), + )) + .returning(schema::evm_wallet_access::id) + .get_result(conn) + .await + .unwrap() +} + async fn insert_basic(conn: &mut DatabaseConnection, revoked: bool) -> EvmBasicGrant { + let wallet_access_id = seed_wallet_access(conn).await; + insert_into(evm_basic_grant::table) .values(NewEvmBasicGrant { - wallet_access_id: WALLET_ACCESS_ID, + wallet_access_id, chain_id: CHAIN_ID.into(), valid_from: None, valid_until: None, @@ -161,7 +235,7 @@ async fn evaluate_passes_when_volume_within_limit() { insert_into(evm_transaction_log::table) .values(NewEvmTransactionLog { grant_id, - wallet_access_id: WALLET_ACCESS_ID, + wallet_access_id: basic.wallet_access_id, chain_id: CHAIN_ID.into(), eth_value: utils::u256_to_bytes(U256::from(500u64)).to_vec(), signed_at: SqliteTimestamp(Utc::now()), @@ -203,7 +277,7 @@ async fn evaluate_rejects_volume_over_limit() { insert_into(evm_transaction_log::table) .values(NewEvmTransactionLog { grant_id, - wallet_access_id: WALLET_ACCESS_ID, + wallet_access_id: basic.wallet_access_id, chain_id: CHAIN_ID.into(), eth_value: utils::u256_to_bytes(U256::from(1_000u64)).to_vec(), signed_at: SqliteTimestamp(Utc::now()), @@ -246,7 +320,7 @@ async fn evaluate_passes_at_exactly_volume_limit() { insert_into(evm_transaction_log::table) .values(NewEvmTransactionLog { grant_id, - wallet_access_id: WALLET_ACCESS_ID, + wallet_access_id: basic.wallet_access_id, chain_id: CHAIN_ID.into(), eth_value: utils::u256_to_bytes(U256::from(900u64)).to_vec(), signed_at: SqliteTimestamp(Utc::now()), diff --git a/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs b/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs index f2c02b3..a4a587e 100644 --- a/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs +++ b/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs @@ -1,8 +1,9 @@ use super::{Settings, TokenTransfer}; use crate::{ db::{ - self, DatabaseConnection, + self, DatabaseConnection, models, models::{EvmBasicGrant, EvmWalletAccess, EvmWalletId, NewEvmBasicGrant, SqliteTimestamp}, + schema, schema::evm_basic_grant, }, evm::{ @@ -20,7 +21,7 @@ use alloy::{ sol_types::SolCall, }; use chrono::{Duration, Utc}; -use diesel::{SelectableHelper, insert_into}; +use diesel::{ExpressionMethods as _, SelectableHelper, insert_into}; use diesel_async::RunQueryDsl; // DAI on Ethereum mainnet — present in the static token registry @@ -47,6 +48,7 @@ fn ctx(to: Address, calldata: Bytes) -> EvalContext { id: WALLET_ACCESS_ID, wallet_id: EvmWalletId::from_raw(10), client_id: 20, + revoked_at: None, created_at: SqliteTimestamp(Utc::now()), }, chain: CHAIN_ID, @@ -58,10 +60,82 @@ fn ctx(to: Address, calldata: Bytes) -> EvalContext { } } +/// Creates the parent chain a fresh `evm_wallet_access` row needs under foreign-key +/// enforcement (a root key, an aead-encrypted secret, a wallet, and a client) and returns +/// the new access row's id. +async fn seed_wallet_access(conn: &mut DatabaseConnection) -> i32 { + let root_key_id: models::RootKeyHistoryId = insert_into(schema::root_key_history::table) + .values(&models::NewRootKeyHistory { + ciphertext: vec![0u8; 32], + tag: vec![0u8; 16], + root_key_encryption_nonce: vec![0u8; 24], + data_encryption_nonce: vec![0u8; 24], + schema_version: 1, + salt: vec![0u8; 16], + }) + .returning(schema::root_key_history::id) + .get_result(conn) + .await + .unwrap(); + + let aead_id: i32 = insert_into(schema::aead_encrypted::table) + .values(&models::NewAeadEncrypted { + ciphertext: vec![0u8; 32], + tag: vec![0u8; 16], + current_nonce: vec![0u8; 24], + schema_version: 1, + associated_root_key_id: root_key_id, + created_at: Utc::now().into(), + }) + .returning(schema::aead_encrypted::id) + .get_result(conn) + .await + .unwrap(); + + let wallet_id: EvmWalletId = insert_into(schema::evm_wallet::table) + .values(( + schema::evm_wallet::address.eq(rand::random::<[u8; 20]>().to_vec()), + schema::evm_wallet::aead_encrypted_id.eq(aead_id), + )) + .returning(schema::evm_wallet::id) + .get_result(conn) + .await + .unwrap(); + + let metadata_id: i32 = insert_into(schema::client_metadata::table) + .values(schema::client_metadata::name.eq("test")) + .returning(schema::client_metadata::id) + .get_result(conn) + .await + .unwrap(); + + let client_id: i32 = insert_into(schema::program_client::table) + .values(( + schema::program_client::public_key.eq(rand::random::<[u8; 32]>().to_vec()), + schema::program_client::metadata_id.eq(metadata_id), + )) + .returning(schema::program_client::id) + .get_result(conn) + .await + .unwrap(); + + insert_into(schema::evm_wallet_access::table) + .values(( + schema::evm_wallet_access::wallet_id.eq(wallet_id), + schema::evm_wallet_access::client_id.eq(client_id), + )) + .returning(schema::evm_wallet_access::id) + .get_result(conn) + .await + .unwrap() +} + async fn insert_basic(conn: &mut DatabaseConnection, revoked: bool) -> EvmBasicGrant { + let wallet_access_id = seed_wallet_access(conn).await; + insert_into(evm_basic_grant::table) .values(NewEvmBasicGrant { - wallet_access_id: WALLET_ACCESS_ID, + wallet_access_id, chain_id: CHAIN_ID.into(), valid_from: None, valid_until: None, @@ -77,6 +151,27 @@ async fn insert_basic(conn: &mut DatabaseConnection, revoked: bool) -> EvmBasicG .unwrap() } +/// `evm_token_transfer_log.log_id` references the shared `evm_transaction_log` table, so +/// tests recording a token transfer need a real row there to point at. +async fn insert_transaction_log( + conn: &mut DatabaseConnection, + basic: &EvmBasicGrant, + eth_value: U256, +) -> i32 { + insert_into(schema::evm_transaction_log::table) + .values(models::NewEvmTransactionLog { + grant_id: basic.id, + wallet_access_id: basic.wallet_access_id, + chain_id: CHAIN_ID.into(), + eth_value: utils::u256_to_bytes(eth_value).to_vec(), + signed_at: SqliteTimestamp(Utc::now()), + }) + .returning(schema::evm_transaction_log::id) + .get_result(conn) + .await + .unwrap() +} + fn make_settings(target: Option
, max_volume: Option) -> Settings { Settings { token_contract: DAI, @@ -241,10 +336,11 @@ async fn evaluate_passes_volume_at_exact_limit() { .unwrap(); // Record a past transfer of 900, with current transfer 100 => exactly 1000 limit - insert_into(db::schema::evm_token_transfer_log::table) - .values(db::models::NewEvmTokenTransferLog { + let log_id = insert_transaction_log(&mut conn, &basic, U256::from(900u64)).await; + insert_into(schema::evm_token_transfer_log::table) + .values(models::NewEvmTokenTransferLog { grant_id, - log_id: 0, + log_id, chain_id: CHAIN_ID.into(), token_contract: DAI.to_vec(), recipient_address: RECIPIENT.to_vec(), @@ -285,10 +381,11 @@ async fn evaluate_rejects_volume_over_limit() { .await .unwrap(); - insert_into(db::schema::evm_token_transfer_log::table) - .values(db::models::NewEvmTokenTransferLog { + let log_id = insert_transaction_log(&mut conn, &basic, U256::from(1_000u64)).await; + insert_into(schema::evm_token_transfer_log::table) + .values(models::NewEvmTokenTransferLog { grant_id, - log_id: 0, + log_id, chain_id: CHAIN_ID.into(), token_contract: DAI.to_vec(), recipient_address: RECIPIENT.to_vec(), diff --git a/server/crates/arbiter-server/src/evm/safe_signer.rs b/server/crates/arbiter-server/src/evm/safe_signer.rs index 02597a8..300b142 100644 --- a/server/crates/arbiter-server/src/evm/safe_signer.rs +++ b/server/crates/arbiter-server/src/evm/safe_signer.rs @@ -44,7 +44,7 @@ impl std::fmt::Debug for SafeSigner { /// Returns the protected key bytes and the derived Ethereum address. pub fn generate(rng: &mut impl rand::Rng) -> (SafeCell<[u8; 32]>, Address) { loop { - let mut cell = SafeCell::new_inline(|w: &mut [u8; 32]| { + let mut cell = SafeCell::new_inline_default(|w: &mut [u8; 32]| { rng.fill_bytes(w); }); diff --git a/server/crates/arbiter-server/src/grpc/operator.rs b/server/crates/arbiter-server/src/grpc/operator.rs index cdd4e80..16a4e8a 100644 --- a/server/crates/arbiter-server/src/grpc/operator.rs +++ b/server/crates/arbiter-server/src/grpc/operator.rs @@ -19,6 +19,7 @@ use tracing::{error, info, warn}; mod auth; mod evm; +mod governance; mod inbound; mod outbound; mod sdk_client; @@ -115,6 +116,7 @@ async fn dispatch_inner( warn!("Unsupported post-auth operator auth request"); Err(Status::invalid_argument("Unsupported operator request")) } + OperatorRequestPayload::Governance(req) => governance::dispatch(actor, req).await, } } @@ -127,14 +129,23 @@ pub async fn start( let (oob_sender, oob_receiver) = mpsc::channel(16); let oob_adapter = OutOfBandAdapter(oob_sender); - let actor = { + let started = { let transport = auth::AuthTransportAdapter::new(&mut bi, &mut request_tracker); - match crate::peers::operator::start(&mut conn, transport, Box::new(oob_adapter)).await { - Ok(actor) => actor, - Err(e) => { - warn!(error = ?e, "Operator connection failed"); - return; - } + crate::peers::operator::start(&mut conn, transport, Box::new(oob_adapter)).await + }; + + let actor = match started { + Ok(actor) => actor, + // §3.5: a recovery operator is turned away from the session rather than failing. Say so + // on the stream, so it does not look like the server dropped the connection. + Err(e @ crate::peers::operator::Error::RecoveryOperatorHasNoSession) => { + info!("Recovery operator connection closed after the vault gate"); + let _ = bi.send(Err(Status::permission_denied(e.to_string()))).await; + return; + } + Err(e) => { + warn!(error = ?e, "Operator connection failed"); + return; } }; diff --git a/server/crates/arbiter-server/src/grpc/operator/governance.rs b/server/crates/arbiter-server/src/grpc/operator/governance.rs new file mode 100644 index 0000000..145f7ee --- /dev/null +++ b/server/crates/arbiter-server/src/grpc/operator/governance.rs @@ -0,0 +1,256 @@ +use crate::{ + actors::proposal_manager::{Error as ProposalError, VoteOutcome}, + db::models::{OperatorIdentityId, ProposalId}, + db::proposal::{ + ProposalKind, approve_sdk_client, grant_wallet_access, one_off_transaction, + persistent_grant, replace_operator, + }, + peers::operator::{ + OperatorSession, + session::handlers::{HandleCastVote, HandleCreateProposal, HandleQueryPending}, + }, +}; +use arbiter_proto::proto::operator::{ + governance::{ + self as proto_gov, CreateProposalRequest, QueryPendingRequest, QueryPendingResponse, + VoteOutcome as ProtoVoteOutcome, create_proposal_request::Kind as ProtoKind, + request::Payload as GovRequestPayload, response::Payload as GovResponsePayload, + }, + operator_response::Payload as OperatorResponsePayload, +}; +use kameo::actor::ActorRef; +use tonic::Status; +use tracing::warn; + +const fn wrap(payload: GovResponsePayload) -> OperatorResponsePayload { + OperatorResponsePayload::Governance(proto_gov::Response { + payload: Some(payload), + }) +} + +pub(super) async fn dispatch( + actor: &ActorRef, + req: proto_gov::Request, +) -> Result, Status> { + let Some(payload) = req.payload else { + return Err(Status::invalid_argument( + "Missing governance request payload", + )); + }; + + match payload { + GovRequestPayload::Create(req) => handle_create(actor, req).await, + GovRequestPayload::Vote(req) => handle_vote(actor, req).await, + GovRequestPayload::Query(QueryPendingRequest {}) => handle_query(actor).await, + } +} + +async fn handle_create( + actor: &ActorRef, + req: CreateProposalRequest, +) -> Result, Status> { + let kind = match req.kind { + Some(ProtoKind::ApproveSdkClient(p)) => { + ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { + client_id: p.client_id, + }) + } + Some(ProtoKind::GrantWalletAccess(p)) => { + ProposalKind::GrantWalletAccess(grant_wallet_access::Settings { + wallet_id: p.wallet_id, + client_id: p.client_id, + }) + } + Some(ProtoKind::ReplaceOperator(p)) => { + ProposalKind::ReplaceOperator(replace_operator::Settings { + old_operator_id: OperatorIdentityId::from_raw(p.old_operator_id), + new_pubkey: p.new_pubkey, + }) + } + Some(ProtoKind::TriggerRekey(())) => ProposalKind::TriggerRekey, + Some(ProtoKind::ApprovePersistentGrant(p)) => { + ProposalKind::ApprovePersistentGrant(Box::new(parse_persistent_grant(p)?)) + } + Some(ProtoKind::ApproveOneOffTransaction(p)) => { + ProposalKind::ApproveOneOffTransaction(Box::new(parse_one_off_transaction(p)?)) + } + None => return Err(Status::invalid_argument("Missing proposal kind")), + }; + let proposal_id = actor + .ask(HandleCreateProposal { + kind, + ttl_secs: req.ttl_secs, + }) + .await + .map_err(|e| { + warn!(?e, "create_proposal failed"); + Status::internal("Failed to create proposal") + })?; + + Ok(Some(wrap(GovResponsePayload::Created( + proto_gov::CreateProposalResponse { + proposal_id: proposal_id.to_raw(), + }, + )))) +} + +/// Validates the grant where the request enters, so a malformed one is refused before +/// any operator votes on it instead of failing after quorum. +fn parse_persistent_grant( + p: proto_gov::ApprovePersistentGrantPayload, +) -> Result { + use proto_gov::approve_persistent_grant_payload::Specific; + + let volume = + |l: proto_gov::VolumeLimitProto| -> Result { + Ok(persistent_grant::VolumeLimit { + max_volume: fixed(&l.max_volume, "max_volume must be 32 bytes")?, + window_secs: l.window_secs, + }) + }; + + let specific = match p.specific { + Some(Specific::EtherTransfer(spec)) => { + let targets = spec + .targets + .iter() + .map(|target| fixed(target, "ether transfer target must be 20 bytes")) + .collect::, _>>()?; + let limit = spec + .limit + .ok_or_else(|| Status::invalid_argument("missing ether transfer limit"))?; + persistent_grant::Specific::EtherTransfer { + targets, + limit: volume(limit)?, + } + } + Some(Specific::TokenTransfer(spec)) => { + let volume_limits = spec + .volume_limits + .into_iter() + .map(volume) + .collect::, _>>()?; + persistent_grant::Specific::TokenTransfer { + token_contract: fixed(&spec.token_contract, "token_contract must be 20 bytes")?, + receiver: spec + .target + .map(|t| fixed(&t, "token transfer target must be 20 bytes")) + .transpose()?, + volume_limits, + } + } + None => return Err(Status::invalid_argument("missing grant specific")), + }; + + Ok(persistent_grant::Settings { + wallet_access_id: p.wallet_access_id, + chain_id: p.chain_id, + valid_from_secs: p.valid_from_secs, + valid_until_secs: p.valid_until_secs, + max_gas_fee_per_gas: p + .max_gas_fee_per_gas + .map(|v| fixed(&v, "max_gas_fee_per_gas must be 32 bytes")) + .transpose()?, + max_priority_fee_per_gas: p + .max_priority_fee_per_gas + .map(|v| fixed(&v, "max_priority_fee_per_gas must be 32 bytes")) + .transpose()?, + rate_limit: p.rate_limit.map(|r| persistent_grant::RateLimit { + count: r.count, + window_secs: r.window_secs, + }), + specific, + }) +} + +fn fixed(bytes: &[u8], message: &'static str) -> Result<[u8; N], Status> { + <[u8; N]>::try_from(bytes).map_err(|_| Status::invalid_argument(message)) +} + +/// Validates the transaction where the request enters, so a malformed one is refused +/// before any operator votes on it instead of failing after quorum. +fn parse_one_off_transaction( + p: proto_gov::ApproveOneOffTransactionPayload, +) -> Result { + Ok(one_off_transaction::Settings { + client_id: p.client_id, + wallet_address: fixed(&p.wallet_address, "wallet_address must be 20 bytes")?, + chain_id: p.chain_id, + nonce: p.nonce, + gas_limit: p.gas_limit, + max_fee_per_gas: u128::from_be_bytes(fixed( + &p.max_fee_per_gas, + "max_fee_per_gas must be 16 bytes", + )?), + max_priority_fee_per_gas: u128::from_be_bytes(fixed( + &p.max_priority_fee_per_gas, + "max_priority_fee_per_gas must be 16 bytes", + )?), + to: fixed(&p.to, "to must be 20 bytes")?, + value: fixed(&p.value, "value must be 32 bytes")?, + input: p.input, + }) +} + +async fn handle_vote( + actor: &ActorRef, + req: proto_gov::CastVoteRequest, +) -> Result, Status> { + let result = actor + .ask(HandleCastVote { + proposal_id: ProposalId::from_raw(req.proposal_id), + approve: req.approve, + signature: req.signature, + }) + .await; + + let outcome = match result { + Ok(VoteOutcome::Pending) => ProtoVoteOutcome::Pending, + Ok(VoteOutcome::Approved) => ProtoVoteOutcome::Approved, + Ok(VoteOutcome::Rejected) => ProtoVoteOutcome::Rejected, + Err(kameo::error::SendError::HandlerError(ProposalError::AlreadyVoted)) => { + return Err(Status::invalid_argument("Already voted on this proposal")); + } + Err(kameo::error::SendError::HandlerError(ProposalError::InvalidSignature)) => { + return Err(Status::invalid_argument("Invalid vote signature")); + } + Err(kameo::error::SendError::HandlerError(ProposalError::ProposalNotFound)) => { + return Err(Status::not_found("Proposal not found")); + } + Err(kameo::error::SendError::HandlerError(ProposalError::Unavailable)) => { + return Err(Status::unavailable("Proposal manager is unavailable")); + } + Err(e) => { + warn!(?e, "cast_vote failed"); + return Err(Status::internal("Failed to cast vote")); + } + }; + + Ok(Some(wrap(GovResponsePayload::Voted( + proto_gov::VoteResponse { + outcome: outcome.into(), + }, + )))) +} + +async fn handle_query( + actor: &ActorRef, +) -> Result, Status> { + let summaries = actor.ask(HandleQueryPending {}).await.unwrap_or_default(); + + let proposals = summaries + .into_iter() + .map(|s| proto_gov::ProposalSummary { + id: s.id.to_raw(), + kind: <&'static str>::from(s.kind).to_owned(), + initiator_id: s.initiator_id.to_raw(), + expires_at: s.expires_at.0.timestamp(), + approve_count: s.approve_count, + reject_count: s.reject_count, + }) + .collect(); + + Ok(Some(wrap(GovResponsePayload::Pending( + QueryPendingResponse { proposals }, + )))) +} diff --git a/server/crates/arbiter-server/src/grpc/operator/vault.rs b/server/crates/arbiter-server/src/grpc/operator/vault.rs index ac1c293..a49a306 100644 --- a/server/crates/arbiter-server/src/grpc/operator/vault.rs +++ b/server/crates/arbiter-server/src/grpc/operator/vault.rs @@ -1,19 +1,30 @@ use crate::{ actors::vault::VaultState, - peers::operator::{OperatorSession, session::handlers::HandleQueryVaultState}, -}; -use arbiter_proto::{ - proto::shared::VaultState as ProtoVaultState, - proto::operator::{ - operator_response::Payload as OperatorResponsePayload, - vault::{ - self as proto_vault, request::Payload as VaultRequestPayload, - response::Payload as VaultResponsePayload, + peers::operator::{ + OperatorSession, + session::{ + Error as SessionError, + handlers::{ + HandleContributeRecoveryRekeyPassphrase, HandleContributeRekeyPassphrase, + HandleQueryVaultState, + }, }, }, }; +use arbiter_proto::{ + proto::operator::{ + operator_response::Payload as OperatorResponsePayload, + vault::{ + self as proto_vault, + rekey::{self as proto_rekey, RekeyResult as ProtoRekeyResult}, + request::Payload as VaultRequestPayload, + response::Payload as VaultResponsePayload, + }, + }, + proto::shared::VaultState as ProtoVaultState, +}; -use kameo::actor::ActorRef; +use kameo::{actor::ActorRef, error::SendError}; use tonic::Status; use tracing::warn; @@ -33,6 +44,7 @@ pub(super) async fn dispatch( match payload { VaultRequestPayload::QueryState(()) => handle_query_vault_state(actor).await, + VaultRequestPayload::Rekey(req) => handle_rekey(actor, req).await, VaultRequestPayload::Unseal(_) | VaultRequestPayload::Bootstrap(_) => { Err(Status::permission_denied( "Vault is already unsealed; unseal/bootstrap not permitted in session", @@ -41,6 +53,58 @@ pub(super) async fn dispatch( } } +/// A re-key share belongs to exactly one role (§3.3), so a contribution from the wrong one is a +/// policy answer and must not reach the peer as an opaque `internal`. +fn rekey_status(err: SendError, context: &'static str) -> Status { + match err { + SendError::HandlerError(err @ SessionError::RoleNotPermitted) => { + Status::permission_denied(err.to_string()) + } + err => { + warn!(?err, "{context}"); + Status::internal(context) + } + } +} + +async fn handle_rekey( + actor: &ActorRef, + req: proto_rekey::Request, +) -> Result, Status> { + use arbiter_proto::proto::operator::vault::rekey::request::Payload as RekeyPayload; + + let payload = req + .payload + .ok_or_else(|| Status::invalid_argument("Missing rekey payload"))?; + + let done: bool = match payload { + RekeyPayload::ContributePassphrase(cp) => actor + .ask(HandleContributeRekeyPassphrase { + passphrase: cp.passphrase, + }) + .await + .map_err(|e| rekey_status(e, "Rekey contribution failed"))?, + RekeyPayload::ContributeRecoveryPassphrase(crp) => actor + .ask(HandleContributeRecoveryRekeyPassphrase { + passphrase: crp.passphrase, + }) + .await + .map_err(|e| rekey_status(e, "Rekey recovery contribution failed"))?, + }; + + let proto_result = if done { + ProtoRekeyResult::Success + } else { + ProtoRekeyResult::AwaitingContributions + }; + + Ok(Some(wrap_vault_response(VaultResponsePayload::Rekey( + proto_rekey::Response { + result: proto_result.into(), + }, + )))) +} + async fn handle_query_vault_state( actor: &ActorRef, ) -> Result, Status> { diff --git a/server/crates/arbiter-server/src/grpc/operator/vault_gate/inbound.rs b/server/crates/arbiter-server/src/grpc/operator/vault_gate/inbound.rs index 6a08235..919f0f1 100644 --- a/server/crates/arbiter-server/src/grpc/operator/vault_gate/inbound.rs +++ b/server/crates/arbiter-server/src/grpc/operator/vault_gate/inbound.rs @@ -1,14 +1,17 @@ use crate::{ grpc::{Convert, TryConvert}, peers::operator::vault_gate::{ - self as vault_gate, HandleBootstrapEncryptedKey, HandleHandshake, HandleUnsealEncryptedKey, + self as vault_gate, HandleBootstrapEncryptedKey, HandleContributeBootstrapPassphrase, + HandleContributeRecoveryBootstrapPassphrase, HandleContributeRecoveryUnsealPassphrase, + HandleContributeUnsealPassphrase, HandleDeclareCommittee, HandleHandshake, + HandleUnsealEncryptedKey, }, }; use arbiter_proto::proto::operator::{ operator_request::Payload as OperatorRequestPayload, vault::{ self as proto_vault, - bootstrap::{self as proto_bootstrap}, + bootstrap::{self as proto_bootstrap, request::Payload as BootstrapRequestPayload}, request::Payload as VaultRequestPayload, unseal::{self as proto_unseal, request::Payload as UnsealRequestPayload}, }, @@ -50,6 +53,9 @@ impl TryConvert for VaultRequestPayload { Self::QueryState(()) => Ok(vault_gate::Inbound::HandleVaultState), Self::Unseal(req) => req.try_convert(), Self::Bootstrap(req) => req.try_convert(), + Self::Rekey(_) => Err(Status::permission_denied( + "Rekey requires an authenticated session", + )), } } } @@ -73,6 +79,20 @@ impl TryConvert for UnsealRequestPayload { match self { Self::Start(start) => start.try_convert(), Self::EncryptedKey(key) => Ok(key.convert()), + Self::ContributePassphrase(cp) => Ok( + vault_gate::Inbound::HandleContributeUnsealPassphrase( + HandleContributeUnsealPassphrase { + passphrase: cp.passphrase, + }, + ), + ), + Self::ContributeRecoveryPassphrase(crp) => Ok( + vault_gate::Inbound::HandleContributeRecoveryUnsealPassphrase( + HandleContributeRecoveryUnsealPassphrase { + passphrase: crp.passphrase, + }, + ), + ), } } } @@ -107,12 +127,43 @@ impl TryConvert for proto_bootstrap::Request { type Error = Status; fn try_convert(self) -> Result { - self.encrypted_key - .ok_or_else(|| Status::invalid_argument("Missing bootstrap encrypted key"))? + self.payload + .ok_or_else(|| Status::invalid_argument("Missing bootstrap payload"))? .try_convert() } } +impl TryConvert for BootstrapRequestPayload { + type Output = vault_gate::Inbound; + type Error = Status; + + fn try_convert(self) -> Result { + match self { + Self::EncryptedKey(key) => key.try_convert(), + Self::DeclareCommittee(dc) => Ok( + vault_gate::Inbound::HandleDeclareCommittee(HandleDeclareCommittee { + count: dc.count as usize, + recovery_count: dc.recovery_count as usize, + }), + ), + Self::ContributePassphrase(cp) => Ok( + vault_gate::Inbound::HandleContributeBootstrapPassphrase( + HandleContributeBootstrapPassphrase { + passphrase: cp.passphrase, + }, + ), + ), + Self::ContributeRecoveryPassphrase(crp) => Ok( + vault_gate::Inbound::HandleContributeRecoveryBootstrapPassphrase( + HandleContributeRecoveryBootstrapPassphrase { + passphrase: crp.passphrase, + }, + ), + ), + } + } +} + impl TryConvert for proto_bootstrap::BootstrapEncryptedKey { type Output = vault_gate::Inbound; type Error = Status; diff --git a/server/crates/arbiter-server/src/grpc/operator/vault_gate/outbound.rs b/server/crates/arbiter-server/src/grpc/operator/vault_gate/outbound.rs index 4a2f072..7b5eb75 100644 --- a/server/crates/arbiter-server/src/grpc/operator/vault_gate/outbound.rs +++ b/server/crates/arbiter-server/src/grpc/operator/vault_gate/outbound.rs @@ -4,7 +4,6 @@ use crate::{ peers::operator::vault_gate::{self as vault_gate}, }; use arbiter_proto::proto::{ - shared::VaultState as ProtoVaultState, operator::{ operator_response::Payload as OperatorResponsePayload, vault::{ @@ -17,6 +16,7 @@ use arbiter_proto::proto::{ }, }, }, + shared::VaultState as ProtoVaultState, }; use tonic::Status; @@ -103,6 +103,9 @@ impl TryConvert for vault_gate::Outbound { Err(vault_gate::Error::AlreadyBootstrapped) => { ProtoBootstrapResult::AlreadyBootstrapped } + Err(err @ vault_gate::Error::RoleNotPermitted) => { + return Err(Status::permission_denied(err.to_string())); + } Err(err) => { warn!(?err, "bootstrap failed"); return Err(Status::internal("Failed to bootstrap vault")); @@ -110,6 +113,85 @@ impl TryConvert for vault_gate::Outbound { }; Ok(wrap_bootstrap_response(proto_result)) } + Self::HandleDeclareCommittee(result) => { + let proto_result = match result { + Ok(()) => ProtoBootstrapResult::Success, + // A role refusal is a policy answer, not a server fault, so it leaves the + // gate as `PERMISSION_DENIED` rather than as an opaque `internal`. + Err(err @ vault_gate::Error::RoleNotPermitted) => { + return Err(Status::permission_denied(err.to_string())); + } + Err(err) => { + warn!(?err, "declare committee failed"); + return Err(Status::internal("Failed to declare committee")); + } + }; + Ok(wrap_bootstrap_response(proto_result)) + } + Self::HandleContributeBootstrapPassphrase(result) => { + let proto_result = match result { + Ok(true) => ProtoBootstrapResult::Success, + Ok(false) => ProtoBootstrapResult::AwaitingContributions, + Err(err @ vault_gate::Error::RoleNotPermitted) => { + return Err(Status::permission_denied(err.to_string())); + } + Err(err) => { + warn!(?err, "contribute bootstrap passphrase failed"); + return Err(Status::internal("Failed to contribute bootstrap passphrase")); + } + }; + Ok(wrap_bootstrap_response(proto_result)) + } + Self::HandleContributeRecoveryBootstrapPassphrase(result) => { + let proto_result = match result { + Ok(true) => ProtoBootstrapResult::Success, + Ok(false) => ProtoBootstrapResult::AwaitingContributions, + Err(err @ vault_gate::Error::RoleNotPermitted) => { + return Err(Status::permission_denied(err.to_string())); + } + Err(err) => { + warn!(?err, "contribute recovery bootstrap passphrase failed"); + return Err(Status::internal( + "Failed to contribute recovery bootstrap passphrase", + )); + } + }; + Ok(wrap_bootstrap_response(proto_result)) + } + Self::HandleContributeUnsealPassphrase(result) => { + let proto_result = match result { + Ok(true) => ProtoUnsealResult::Success, + Ok(false) => ProtoUnsealResult::AwaitingContributions, + Err(err @ vault_gate::Error::RoleNotPermitted) => { + return Err(Status::permission_denied(err.to_string())); + } + Err(err) => { + warn!(?err, "contribute unseal passphrase failed"); + return Err(Status::internal("Failed to contribute unseal passphrase")); + } + }; + Ok(wrap_unseal_response(UnsealResponsePayload::Result( + proto_result.into(), + ))) + } + Self::HandleContributeRecoveryUnsealPassphrase(result) => { + let proto_result = match result { + Ok(true) => ProtoUnsealResult::Success, + Ok(false) => ProtoUnsealResult::AwaitingContributions, + Err(err @ vault_gate::Error::RoleNotPermitted) => { + return Err(Status::permission_denied(err.to_string())); + } + Err(err) => { + warn!(?err, "contribute recovery unseal passphrase failed"); + return Err(Status::internal( + "Failed to contribute recovery unseal passphrase", + )); + } + }; + Ok(wrap_unseal_response(UnsealResponsePayload::Result( + proto_result.into(), + ))) + } } } } diff --git a/server/crates/arbiter-server/src/peers/client/auth.rs b/server/crates/arbiter-server/src/peers/client/auth.rs index 3742f97..c27c2b1 100644 --- a/server/crates/arbiter-server/src/peers/client/auth.rs +++ b/server/crates/arbiter-server/src/peers/client/auth.rs @@ -12,7 +12,7 @@ use crate::{ schema::program_client, }, }; -use arbiter_crypto::authn::{self, AuthChallenge, CLIENT_CONTEXT}; +use arbiter_crypto::authn::{self, AuthChallenge, SigningContext}; use arbiter_proto::{ ClientMetadata, transport::{Bi, expect_message}, @@ -298,7 +298,7 @@ where let signature = expect_message(transport, |req: Inbound| match req { Inbound::AuthChallengeSolution { signature } => Some(signature), - _ => None, + Inbound::AuthChallengeRequest { .. } => None, }) .await .map_err(|e| { @@ -306,7 +306,7 @@ where Error::Transport })?; - if !pubkey.verify(&challenge, CLIENT_CONTEXT, &signature) { + if !pubkey.verify(&challenge, SigningContext::Client, &signature) { error!("Challenge solution verification failed"); return Err(Error::InvalidChallengeSolution); } diff --git a/server/crates/arbiter-server/src/peers/operator/auth/mod.rs b/server/crates/arbiter-server/src/peers/operator/auth/mod.rs index 8bea8a0..281c17e 100644 --- a/server/crates/arbiter-server/src/peers/operator/auth/mod.rs +++ b/server/crates/arbiter-server/src/peers/operator/auth/mod.rs @@ -1,4 +1,4 @@ -use super::{Credentials, OperatorConnection}; +use super::{AuthenticatedOperator, OperatorConnection}; use arbiter_crypto::authn::{self, AuthChallenge}; use arbiter_proto::transport::Bi; @@ -71,7 +71,7 @@ fn parse_auth_event(payload: Inbound) -> AuthEvents { pub async fn authenticate( props: &mut OperatorConnection, transport: &mut T, -) -> Result +) -> Result where T: Bi> + Send + ?Sized, { diff --git a/server/crates/arbiter-server/src/peers/operator/auth/state.rs b/server/crates/arbiter-server/src/peers/operator/auth/state.rs index 9862612..a38a33a 100644 --- a/server/crates/arbiter-server/src/peers/operator/auth/state.rs +++ b/server/crates/arbiter-server/src/peers/operator/auth/state.rs @@ -1,32 +1,35 @@ use super::{ - super::{Credentials, OperatorConnection}, + super::{AuthenticatedOperator, Credentials, OperatorConnection, RecoveryCredentials}, Error, }; use crate::{ - actors::bootstrap::ConsumeToken, - db::{DatabasePool, schema::operator_identity}, + actors::bootstrap::VerifyToken, + db::{ + DatabasePool, + schema::{arbiter_settings, operator_identity, recovery_operator_identity}, + }, peers::operator::auth::Outbound, }; -use arbiter_crypto::authn::{self, AuthChallenge, OPERATOR_CONTEXT}; +use arbiter_crypto::authn::{self, AuthChallenge, SigningContext}; use arbiter_proto::transport::Bi; use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl}; -use diesel_async::RunQueryDsl; +use diesel_async::{AsyncConnection as _, RunQueryDsl}; use tracing::error; -pub(super) struct ChallengeRequest { - pub(super) pubkey: authn::PublicKey, - pub(super) bootstrap_token: Option, +pub(crate) struct ChallengeRequest { + pub(crate) pubkey: authn::PublicKey, + pub(crate) bootstrap_token: Option, } -pub(super) struct ChallengeContext { - pub(super) challenge: AuthChallenge, - pub(super) pubkey: authn::PublicKey, - pub(super) bootstrap_token: Option, +pub struct ChallengeContext { + pub challenge: AuthChallenge, + pub pubkey: authn::PublicKey, + pub bootstrap_token: Option, } -pub(super) struct ChallengeSolution { - pub(super) solution: Vec, +pub(crate) struct ChallengeSolution { + pub(crate) solution: Vec, } smlang::statemachine!( @@ -34,7 +37,7 @@ smlang::statemachine!( custom_error: true, transitions: { *Init + AuthRequest(ChallengeRequest) / async prepare_challenge = SentChallenge(ChallengeContext), - SentChallenge(ChallengeContext) + ReceivedSolution(ChallengeSolution) / async verify_solution = AuthOk(Credentials), + SentChallenge(ChallengeContext) + ReceivedSolution(ChallengeSolution) / async verify_solution = AuthOk(AuthenticatedOperator), } ); @@ -56,6 +59,27 @@ async fn get_client_id(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result Result, Error> { + let mut conn = db.get().await.map_err(|e| { + error!(error = ?e, "Database pool error"); + Error::internal("Database unavailable") + })?; + + recovery_operator_identity::table + .filter(recovery_operator_identity::public_key.eq(pubkey.to_bytes())) + .select(recovery_operator_identity::id) + .first::(&mut conn) + .await + .optional() + .map_err(|e| { + error!(error = ?e, "Database error"); + Error::internal("Database operation failed") + }) +} + async fn register_key(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result { let pubkey_bytes = pubkey.to_bytes(); let mut conn = db.get().await.map_err(|e| { @@ -63,17 +87,32 @@ async fn register_key(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result>(&mut *conn) + .await? + .is_some(); - Ok(id) + if already_bootstrapped { + error!("Bootstrap token used to register after the vault was already bootstrapped"); + return Err(Error::InvalidBootstrapToken); + } + + let id: i32 = diesel::insert_into(operator_identity::table) + .values((operator_identity::public_key.eq(pubkey_bytes),)) + .returning(operator_identity::id) + .get_result(&mut *conn) + .await?; + + Ok(id) + }) + .await } pub(super) struct AuthContext<'a, T: ?Sized> { @@ -100,12 +139,14 @@ where bootstrap_token, }: ChallengeRequest, ) -> Result { - // Verify pubkey is registered (unless bootstrapping) - if bootstrap_token.is_none() { - let id = get_client_id(&self.conn.db, &pubkey).await?; - if id.is_none() { - return Err(Error::UnregisteredPublicKey); - } + // Verify pubkey is registered in either identity table (unless bootstrapping) + if bootstrap_token.is_none() + && get_client_id(&self.conn.db, &pubkey).await?.is_none() + && get_recovery_operator_id(&self.conn.db, &pubkey) + .await? + .is_none() + { + return Err(Error::UnregisteredPublicKey); } let challenge = AuthChallenge::generate(&mut rand::rng()); @@ -127,8 +168,6 @@ where }) } - #[allow(missing_docs)] - #[allow(clippy::unused_unit)] async fn verify_solution( &mut self, ChallengeContext { @@ -137,13 +176,13 @@ where bootstrap_token, }: &ChallengeContext, ChallengeSolution { solution }: ChallengeSolution, - ) -> Result { + ) -> Result { let signature = authn::Signature::try_from(solution.as_slice()).map_err(|()| { error!("Failed to decode signature in challenge solution"); Error::InvalidChallengeSolution })?; - let valid = pubkey.verify(challenge, OPERATOR_CONTEXT, &signature); + let valid = pubkey.verify(challenge, SigningContext::Operator, &signature); if !valid { self.transport @@ -153,20 +192,21 @@ where return Err(Error::InvalidChallengeSolution); } - // Resolve client id: bootstrap (consume token + register) or lookup - let id = match bootstrap_token { + // Resolve the peer's role: bootstrap (verify token, then register as an ordinary + // operator) or look the key up in whichever identity table holds it. + let authenticated = match bootstrap_token { Some(token) => { let token_ok: bool = self .conn .actors .bootstrapper - .ask(ConsumeToken { + .ask(VerifyToken { token: token.clone(), }) .await .map_err(|e| { - error!(?e, "Failed to consume bootstrap token"); - Error::internal("Failed to consume bootstrap token") + error!(?e, "Failed to verify bootstrap token"); + Error::internal("Failed to verify bootstrap token") })?; if !token_ok { @@ -178,11 +218,59 @@ where return Err(Error::InvalidBootstrapToken); } - register_key(&self.conn.db, pubkey).await? + let id = match register_key(&self.conn.db, pubkey).await { + Ok(id) => id, + // `register_key` refuses a token that verified here but lost the race + // against bootstrap. Reported to the peer exactly like the refusal above, + // so that operator sees a protocol error rather than a handshake that + // stops with nothing on the wire. + Err(Error::InvalidBootstrapToken) => { + self.transport + .send(Err(Error::InvalidBootstrapToken)) + .await + .map_err(|_| Error::Transport)?; + return Err(Error::InvalidBootstrapToken); + } + Err(err) => return Err(err), + }; + + AuthenticatedOperator::Ordinary(Credentials { + id, + pubkey: pubkey.clone(), + }) + } + None => { + // The tables are searched in this order, so a key registered in both resolves + // as `Ordinary` and could never submit its recovery share. Nothing enforces + // that the two sets are disjoint: `unique` is per table, and the only writer + // today is `register_key` above -- `recovery_operator_identity` has no + // registration path yet. Whoever builds one must refuse a key that + // `operator_identity` already holds, and vice versa, or §3.5's "separate peer + // type" holds only by convention. + if let Some(id) = get_client_id(&self.conn.db, pubkey).await? { + AuthenticatedOperator::Ordinary(Credentials { + id, + pubkey: pubkey.clone(), + }) + } else { + // `prepare_challenge` already found the key in one of the tables, so + // arriving here means it was removed mid-handshake. Reported to the peer + // for the same reason `InvalidBootstrapToken` is above: an operator that + // has sent its solution sees a protocol error rather than a handshake that + // stops with nothing on the wire. + let Some(id) = get_recovery_operator_id(&self.conn.db, pubkey).await? else { + self.transport + .send(Err(Error::UnregisteredPublicKey)) + .await + .map_err(|_| Error::Transport)?; + return Err(Error::UnregisteredPublicKey); + }; + AuthenticatedOperator::Recovery(RecoveryCredentials { + id, + pubkey: pubkey.clone(), + }) + } } - None => get_client_id(&self.conn.db, pubkey) - .await? - .ok_or(Error::UnregisteredPublicKey)?, }; self.transport @@ -190,9 +278,6 @@ where .await .map_err(|_| Error::Transport)?; - Ok(Credentials { - id, - pubkey: pubkey.clone(), - }) + Ok(authenticated) } } diff --git a/server/crates/arbiter-server/src/peers/operator/mod.rs b/server/crates/arbiter-server/src/peers/operator/mod.rs index 0869d51..f3a8759 100644 --- a/server/crates/arbiter-server/src/peers/operator/mod.rs +++ b/server/crates/arbiter-server/src/peers/operator/mod.rs @@ -33,6 +33,26 @@ impl Integrable for Credentials { const KIND: &'static str = "operator_credentials"; } +/// §3.5: recovery operators are a separate peer type with their own identity table. Their +/// attestation kind differs from an ordinary operator's so the two id spaces cannot collide. +#[derive(Debug, Clone, Hashable)] +pub struct RecoveryCredentials { + pub id: i32, + pub pubkey: authn::PublicKey, +} + +impl Integrable for RecoveryCredentials { + const KIND: &'static str = "recovery_operator_credentials"; +} + +/// The outcome of an operator handshake. The variant, not a field, decides what the peer may +/// do — so no call site can pass an unauthenticated recovery id. +#[derive(Debug, Clone)] +pub enum AuthenticatedOperator { + Ordinary(Credentials), + Recovery(RecoveryCredentials), +} + // Messages, sent by operator to connection client without having a request #[derive(Debug)] pub enum OutOfBand { @@ -62,6 +82,12 @@ pub enum Error { Transport, #[error("database error: {0}")] Database(DatabaseError), + /// §3.5: a recovery operator's authority stops at the vault gate. It has no operator + /// session, because a session is the whole ordinary-governance surface -- wallets, grants, + /// SDK clients, proposals -- which §3.5 puts out of a recovery operator's reach. Named + /// rather than folded into `Internal`, so a policy refusal is not logged as a fault. + #[error("recovery operators do not have an operator session")] + RecoveryOperatorHasNoSession, #[error("internal: {0}")] Internal(String), } @@ -108,7 +134,7 @@ async fn should_run_gate(vault: &ActorRef) -> Result { async fn run_vault_gate( props: &OperatorConnection, transport: &mut T, - auth_creds: Credentials, + auth_creds: AuthenticatedOperator, ) -> Result<(), Error> where T: Bi> + Send + ?Sized, @@ -168,18 +194,27 @@ where T: Bi> + Send, T: Bi> + Send, { - let creds = authenticate(props, &mut transport).await?; + let authenticated = authenticate(props, &mut transport).await?; // should run vault gate only if sealed / unbootstrapped if should_run_gate(&props.actors.vault).await? { - run_vault_gate(props, &mut transport, creds.clone()).await?; + // §3.5 lets a recovery operator take part in unsealing, and the gate is where that + // happens, so both roles run it. The gate decides per message which role may send it. + run_vault_gate(props, &mut transport, authenticated.clone()).await?; } + // Past the gate the connection turns into an ordinary operator session, which a recovery + // operator may not have. + let AuthenticatedOperator::Ordinary(creds) = &authenticated else { + return Err(Error::RecoveryOperatorHasNoSession); + }; + // checking the integrity - verify_integrity(&props.db, &props.actors.vault, &creds).await?; + verify_integrity(&props.db, &props.actors.vault, creds).await?; Ok(OperatorSession::spawn(OperatorSession::new( props.clone(), + authenticated.clone(), oob_sender, ))) } diff --git a/server/crates/arbiter-server/src/peers/operator/session/handlers.rs b/server/crates/arbiter-server/src/peers/operator/session/handlers.rs index 08a000d..451895f 100644 --- a/server/crates/arbiter-server/src/peers/operator/session/handlers.rs +++ b/server/crates/arbiter-server/src/peers/operator/session/handlers.rs @@ -1,9 +1,10 @@ use super::{Error, OperatorSession}; +use crate::db::models::{OperatorIdentityId, ProposalId}; use crate::{ actors::{ evm::{ - ClientSignTransaction, Generate, ListWallets, OperatorCreateGrant, OperatorListGrants, - SignTransactionError as EvmSignError, + ClientSignTransaction, Generate, ListWallets, OperatorCreateGrant, OperatorDeleteGrant, + OperatorListGrants, SignTransactionError as EvmSignError, }, flow_coordinator::client_connect_approval::ClientApprovalAnswer, vault::VaultState, @@ -122,22 +123,23 @@ impl OperatorSession { } #[message] - pub(crate) fn handle_grant_delete(&mut self, grant_id: i32) -> Result<(), GrantMutationError> { - // match self - // .props - // .actors - // .evm - // .ask(OperatorDeleteGrant { grant_id }) - // .await - // { - // Ok(()) => Ok(()), - // Err(err) => { - // error!(?err, "EVM grant delete failed"); - // Err(GrantMutationError::Internal) - // } - // } - let _ = grant_id; - todo!() + pub(crate) async fn handle_grant_delete( + &mut self, + grant_id: i32, + ) -> Result<(), GrantMutationError> { + match self + .props + .actors + .evm + .ask(OperatorDeleteGrant { grant_id }) + .await + { + Ok(()) => Ok(()), + Err(err) => { + error!(?err, "EVM grant delete failed"); + Err(GrantMutationError::Internal) + } + } } #[message] @@ -175,41 +177,27 @@ impl OperatorSession { entries: Vec, ) -> Result<(), Error> { let mut conn = self.props.db.get().await?; - conn.transaction(async |conn| { - use crate::db::schema::evm_wallet_access; - - for entry in entries { - diesel::insert_into(evm_wallet_access::table) - .values(&entry) - .on_conflict_do_nothing() - .execute(&mut *conn) - .await?; - } - - Result::<_, Error>::Ok(()) - }) - .await?; + grant_wallet_access(&mut conn, entries).await?; Ok(()) } + /// A revoke that matched fewer rows than it named did not do what the operator asked: + /// the id was never granted, or someone revoked it first. Answering `Ok` there tells the + /// operator access is cut off when nothing changed. The rows that did match stay revoked + /// -- rolling them back to report the shortfall would leave live access behind. #[message] pub(crate) async fn handle_revoke_evm_wallet_access( &mut self, entries: Vec, ) -> Result<(), Error> { let mut conn = self.props.db.get().await?; - conn.transaction(async |conn| { - use crate::db::schema::evm_wallet_access; - for entry in entries { - diesel::delete(evm_wallet_access::table) - .filter(evm_wallet_access::wallet_id.eq(entry)) - .execute(&mut *conn) - .await?; - } - - Result::<_, Error>::Ok(()) - }) - .await?; + let revoked = revoke_wallet_access(&mut conn, &entries).await?; + if revoked != entries.len() { + return Err(Error::PartialRevoke { + requested: entries.len(), + revoked, + }); + } Ok(()) } @@ -217,9 +205,10 @@ impl OperatorSession { pub(crate) async fn handle_list_wallet_access( &mut self, ) -> Result, Error> { - let mut conn = self.props.db.get().await?; use crate::db::schema::evm_wallet_access; + let mut conn = self.props.db.get().await?; let access_entries = evm_wallet_access::table + .filter(evm_wallet_access::revoked_at.is_null()) .select(EvmWalletAccess::as_select()) .load::<_>(&mut conn) .await?; @@ -227,6 +216,81 @@ impl OperatorSession { } } +/// Grants access, reviving a previously revoked row rather than leaving it shadowed: +/// `uniq_wallet_access` is a unique index on `(wallet_id, client_id)`, so a plain insert +/// would conflict forever on a row that was revoked but never deleted. +/// +/// Reviving restores visibility and nothing else: [`revoke_wallet_access`] closes the grants +/// that hung off the access, so a persistent grant takes its own vote again (§3.2). +pub(crate) async fn grant_wallet_access( + conn: &mut crate::db::DatabaseConnection, + entries: Vec, +) -> Result<(), diesel::result::Error> { + use crate::db::{models::SqliteTimestamp, schema::evm_wallet_access}; + + conn.transaction(async |conn| { + for entry in entries { + diesel::insert_into(evm_wallet_access::table) + .values(&entry) + .on_conflict((evm_wallet_access::wallet_id, evm_wallet_access::client_id)) + .do_update() + .set(evm_wallet_access::revoked_at.eq(None::)) + .execute(&mut *conn) + .await?; + } + + Ok(()) + }) + .await +} + +/// Marks access rows revoked by their own id rather than deleting them, and revokes every +/// grant that hangs off them. Returns how many access rows this call revoked. +/// +/// The wire carries `WalletAccessEntry.id` values, so filtering by `wallet_id` here would +/// revoke every client's access to that wallet. Deleting is not an option: `evm_basic_grant`, +/// `evm_transaction_log`, and `proposal_persistent_grant` all reference this row +/// `on delete restrict`, so an access that was ever granted, signed with, or proposed +/// against can never be deleted -- only marked revoked. +/// +/// The dependent grants have to go with it. `grant_wallet_access` revives a revoked row by +/// its id, and grant lookup keys on `wallet_access_id` alone, so leaving the grants live +/// would make a later re-grant restore every persistent grant the access ever held, with its +/// original volume and rate limits. §3.2 votes visibility and a persistent grant separately; +/// a committee that approves visibility must not silently hand back signing authority it did +/// not vote on. The filter names every requested id, not just the rows this call flipped, so +/// an access revoked before this fix has its orphaned grants closed too. +pub(crate) async fn revoke_wallet_access( + conn: &mut crate::db::DatabaseConnection, + ids: &[i32], +) -> Result { + use crate::db::{ + models::SqliteTimestamp, + schema::{evm_basic_grant, evm_wallet_access}, + }; + + conn.transaction(async |conn| { + let now = SqliteTimestamp::now(); + + let revoked = diesel::update(evm_wallet_access::table) + .filter(evm_wallet_access::id.eq_any(ids)) + .filter(evm_wallet_access::revoked_at.is_null()) + .set(evm_wallet_access::revoked_at.eq(now.clone())) + .execute(&mut *conn) + .await?; + + diesel::update(evm_basic_grant::table) + .filter(evm_basic_grant::wallet_access_id.eq_any(ids)) + .filter(evm_basic_grant::revoked_at.is_null()) + .set(evm_basic_grant::revoked_at.eq(now)) + .execute(&mut *conn) + .await?; + + Ok(revoked) + }) + .await +} + #[messages] impl OperatorSession { #[message(ctx)] @@ -278,3 +342,590 @@ impl OperatorSession { Ok(clients) } } + +#[messages] +impl OperatorSession { + #[message] + pub(crate) async fn handle_create_proposal( + &mut self, + kind: crate::db::proposal::ProposalKind, + ttl_secs: Option, + ) -> Result { + use crate::actors::proposal_manager::CreateProposal; + let initiator_id = OperatorIdentityId::from_raw(self.ordinary_id()?); + self.props + .actors + .proposal_manager + .ask(CreateProposal { kind, initiator_id, ttl_secs }) + .await + .map_err(|e| { + error!(?e, "create_proposal failed"); + Error::internal("Failed to create proposal") + }) + } + + #[message] + pub(crate) async fn handle_cast_vote( + &mut self, + proposal_id: ProposalId, + approve: bool, + signature: Vec, + ) -> Result { + use crate::actors::proposal_manager::CastVote; + let operator_id = OperatorIdentityId::from_raw( + self.ordinary_id() + .map_err(|_| crate::actors::proposal_manager::Error::NotAllowedForRecoveryOperator)?, + ); + self.props + .actors + .proposal_manager + .ask(CastVote { proposal_id, operator_id, approve, signature }) + .await + .map_err(|err| match err { + SendError::HandlerError(e) => e, + _ => crate::actors::proposal_manager::Error::Unavailable, + }) + } + + #[message] + pub(crate) async fn handle_query_pending( + &mut self, + ) -> Vec { + use crate::actors::proposal_manager::QueryPending; + let Ok(id) = self.ordinary_id() else { + // The pending list is per ordinary operator; a recovery operator has no view of it. + return Vec::new(); + }; + let operator_id = OperatorIdentityId::from_raw(id); + self.props + .actors + .proposal_manager + .ask(QueryPending { operator_id }) + .await + .unwrap_or_default() + } +} + +#[messages] +impl OperatorSession { + #[message] + pub(crate) async fn handle_contribute_rekey_passphrase( + &mut self, + passphrase: Vec, + ) -> Result { + use crate::actors::vault_coordinator::ContributeRekey; + use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; + + let operator_id = self.ordinary_id()?; + self.props + .actors + .vault_coordinator + .ask(ContributeRekey { + operator_id, + passphrase: SafeCell::new(passphrase), + }) + .await + .map_err(|_| Error::internal("VaultCoordinator unavailable")) + } + + /// §3.3: a re-key refreshes every share, recovery shares included, so a recovery operator + /// has one to contribute here. + /// + /// It cannot reach this handler yet: `peers::operator::start` refuses a recovery peer an + /// operator session, because a session carries the whole ordinary-governance surface that + /// §3.5 keeps out of a recovery operator's hands. Until a recovery-scoped session exists, + /// this refuses every caller -- which is the safe direction, and the id it would use comes + /// from the handshake either way. + #[message] + pub(crate) async fn handle_contribute_recovery_rekey_passphrase( + &mut self, + passphrase: Vec, + ) -> Result { + use crate::actors::vault_coordinator::ContributeRecoveryRekey; + use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; + + let recovery_operator_id = self.recovery_id()?; + self.props + .actors + .vault_coordinator + .ask(ContributeRecoveryRekey { + recovery_operator_id, + passphrase: SafeCell::new(passphrase), + }) + .await + .map_err(|_| Error::internal("VaultCoordinator unavailable")) + } +} + +#[cfg(test)] +mod tests { + use super::{grant_wallet_access, revoke_wallet_access}; + use crate::db::{self, models, schema}; + + use diesel::{ExpressionMethods as _, QueryDsl as _, dsl::insert_into}; + use diesel_async::RunQueryDsl; + + /// Inserts a fresh root key, an aead-encrypted wallet secret, and the wallet itself. + /// Returns the wallet's id and its 20-byte address. + async fn seed_wallet(conn: &mut db::DatabaseConnection) -> (models::EvmWalletId, Vec) { + let root_key_id: models::RootKeyHistoryId = insert_into(schema::root_key_history::table) + .values(&models::NewRootKeyHistory { + ciphertext: vec![0u8; 32], + tag: vec![0u8; 16], + root_key_encryption_nonce: vec![0u8; 24], + data_encryption_nonce: vec![0u8; 24], + schema_version: 1, + salt: vec![0u8; 16], + }) + .returning(schema::root_key_history::id) + .get_result(conn) + .await + .unwrap(); + + let aead_id: i32 = insert_into(schema::aead_encrypted::table) + .values(&models::NewAeadEncrypted { + ciphertext: vec![0u8; 32], + tag: vec![0u8; 16], + current_nonce: vec![0u8; 24], + schema_version: 1, + associated_root_key_id: root_key_id, + created_at: chrono::Utc::now().into(), + }) + .returning(schema::aead_encrypted::id) + .get_result(conn) + .await + .unwrap(); + + let address = rand::random::<[u8; 20]>().to_vec(); + let wallet_id: models::EvmWalletId = insert_into(schema::evm_wallet::table) + .values(( + schema::evm_wallet::address.eq(address.clone()), + schema::evm_wallet::aead_encrypted_id.eq(aead_id), + )) + .returning(schema::evm_wallet::id) + .get_result(conn) + .await + .unwrap(); + + (wallet_id, address) + } + + async fn seed_client_metadata(conn: &mut db::DatabaseConnection) -> i32 { + insert_into(schema::client_metadata::table) + .values(schema::client_metadata::name.eq("test")) + .returning(schema::client_metadata::id) + .get_result(conn) + .await + .unwrap() + } + + /// Inserts a `program_client` row under the given `client_metadata` row, keyed by its + /// own random public key. + async fn seed_client(conn: &mut db::DatabaseConnection, metadata_id: i32) -> i32 { + insert_into(schema::program_client::table) + .values(( + schema::program_client::public_key.eq(rand::random::<[u8; 32]>().to_vec()), + schema::program_client::metadata_id.eq(metadata_id), + )) + .returning(schema::program_client::id) + .get_result(conn) + .await + .unwrap() + } + + /// Inserts an access row directly, for fixtures that need one to already exist. + /// Production code grants access through [`grant_wallet_access`]. + async fn insert_wallet_access( + conn: &mut db::DatabaseConnection, + wallet_id: models::EvmWalletId, + client_id: i32, + ) -> i32 { + insert_into(schema::evm_wallet_access::table) + .values(( + schema::evm_wallet_access::wallet_id.eq(wallet_id), + schema::evm_wallet_access::client_id.eq(client_id), + )) + .returning(schema::evm_wallet_access::id) + .get_result(conn) + .await + .unwrap() + } + + /// The grants that grant lookup would treat as live for this access: the exact filter + /// `EtherTransfer::try_find_grant` and `TokenTransfer::try_find_grant` apply. + async fn live_grants_for(conn: &mut db::DatabaseConnection, access_id: i32) -> Vec { + schema::evm_basic_grant::table + .filter(schema::evm_basic_grant::wallet_access_id.eq(access_id)) + .filter(schema::evm_basic_grant::revoked_at.is_null()) + .select(schema::evm_basic_grant::id) + .load(conn) + .await + .unwrap() + } + + /// Two clients share one wallet. Revoking one access row must leave the other alone, + /// and must mark the row revoked rather than deleting it. + #[tokio::test] + async fn revoking_one_access_leaves_the_other_client_alone() { + let pool = db::create_test_pool().await; + let mut conn = pool.get().await.unwrap(); + + let (wallet_id, _address) = seed_wallet(&mut conn).await; + let metadata_id = seed_client_metadata(&mut conn).await; + let first_client = seed_client(&mut conn, metadata_id).await; + let second_client = seed_client(&mut conn, metadata_id).await; + + let first_access = insert_wallet_access(&mut conn, wallet_id, first_client).await; + let _second_access = insert_wallet_access(&mut conn, wallet_id, second_client).await; + + let removed = revoke_wallet_access(&mut conn, &[first_access]) + .await + .unwrap(); + assert_eq!(removed, 1); + + let active: Vec = schema::evm_wallet_access::table + .filter(schema::evm_wallet_access::revoked_at.is_null()) + .select(schema::evm_wallet_access::client_id) + .load(&mut conn) + .await + .unwrap(); + assert_eq!( + active, + vec![second_client], + "revoking one access row removed another client's access" + ); + + let total: i64 = schema::evm_wallet_access::table + .count() + .get_result(&mut conn) + .await + .unwrap(); + assert_eq!( + total, 2, + "revoking an access row must mark it revoked, not delete it" + ); + + // The count `handle_revoke_evm_wallet_access` answers on: a second revoke of the same + // id, like a revoke of an id that never existed, changes nothing and must say so. + let again = revoke_wallet_access(&mut conn, &[first_access]) + .await + .unwrap(); + assert_eq!(again, 0, "an already revoked access must report no rows"); + } + + /// The bug this round fixes: once an access has been used for a grant, a signed + /// transaction, or a proposed persistent grant, three tables reference + /// `evm_wallet_access` `on delete restrict`, so deleting the row is no longer possible + /// once foreign keys are enforced. Revoking must still succeed by marking it revoked. + #[tokio::test] + async fn revoking_an_access_with_grant_log_and_proposal_succeeds() { + use crate::db::proposal::{Proposal as _, persistent_grant, persistent_grant::PersistentGrant}; + + let pool = db::create_test_pool().await; + let mut conn = pool.get().await.unwrap(); + + let (wallet_id, _address) = seed_wallet(&mut conn).await; + let metadata_id = seed_client_metadata(&mut conn).await; + let client_id = seed_client(&mut conn, metadata_id).await; + let access_id = insert_wallet_access(&mut conn, wallet_id, client_id).await; + + // A grant against this access... + let grant_id: i32 = insert_into(schema::evm_basic_grant::table) + .values(models::NewEvmBasicGrant { + wallet_access_id: access_id, + chain_id: 1u64.into(), + valid_from: None, + valid_until: None, + max_gas_fee_per_gas: None, + max_priority_fee_per_gas: None, + rate_limit_count: None, + rate_limit_window_secs: None, + revoked_at: None, + }) + .returning(schema::evm_basic_grant::id) + .get_result(&mut conn) + .await + .unwrap(); + + // ...a signed transaction against that grant... + insert_into(schema::evm_transaction_log::table) + .values(models::NewEvmTransactionLog { + grant_id, + wallet_access_id: access_id, + chain_id: 1u64.into(), + eth_value: vec![0u8; 32], + signed_at: models::SqliteTimestamp(chrono::Utc::now()), + }) + .execute(&mut conn) + .await + .unwrap(); + + // ...and a persistent-grant proposal that named this access before it was voted on. + let operator_id: models::OperatorIdentityId = + insert_into(schema::operator_identity::table) + .values(schema::operator_identity::public_key.eq(rand::random::<[u8; 32]>().to_vec())) + .returning(schema::operator_identity::id) + .get_result(&mut conn) + .await + .unwrap(); + let proposal_id: models::ProposalId = insert_into(schema::proposal::table) + .values(&models::NewProposal { + kind: db::proposal::ProposalKindTag::ApprovePersistentGrant, + initiator_id: operator_id, + expires_at: models::SqliteTimestamp(chrono::Utc::now() + chrono::Duration::days(1)), + }) + .returning(schema::proposal::id) + .get_result(&mut conn) + .await + .unwrap(); + PersistentGrant::insert( + proposal_id, + &persistent_grant::Settings { + wallet_access_id: access_id, + chain_id: 1, + valid_from_secs: None, + valid_until_secs: None, + max_gas_fee_per_gas: None, + max_priority_fee_per_gas: None, + rate_limit: None, + specific: persistent_grant::Specific::EtherTransfer { + targets: vec![[0u8; 20]], + limit: persistent_grant::VolumeLimit { + max_volume: [0u8; 32], + window_secs: 3600, + }, + }, + }, + &mut conn, + ) + .await + .unwrap(); + + // Before this round's fix, this would fail with a foreign-key violation. + let removed = revoke_wallet_access(&mut conn, &[access_id]).await.unwrap(); + assert_eq!(removed, 1); + + let revoked_at: Option = schema::evm_wallet_access::table + .find(access_id) + .select(schema::evm_wallet_access::revoked_at) + .first(&mut conn) + .await + .unwrap(); + assert!( + revoked_at.is_some(), + "the row must be marked revoked, not deleted" + ); + } + + /// A revoked access must no longer resolve through the lookup `shared_analyze_transaction` + /// and `client_sign_transaction` share -- otherwise the SDK client keeps signing after + /// the operator believes it has been cut off. + #[tokio::test] + async fn revoked_access_no_longer_authorizes_signing() { + use crate::actors::{ + GlobalActors, + evm::{EvmActor, SignTransactionError}, + vault::Vault, + }; + use alloy::{ + consensus::TxEip1559, + eips::eip2930::AccessList, + primitives::{Address, Bytes, TxKind, U256}, + }; + use kameo::actor::Spawn as _; + + let pool = db::create_test_pool().await; + let mut conn = pool.get().await.unwrap(); + + let (wallet_id, address) = seed_wallet(&mut conn).await; + let metadata_id = seed_client_metadata(&mut conn).await; + let client_id = seed_client(&mut conn, metadata_id).await; + let access_id = insert_wallet_access(&mut conn, wallet_id, client_id).await; + drop(conn); + + let vault = Vault::spawn( + Vault::new(pool.clone(), GlobalActors::spawn_message_bus()) + .await + .unwrap(), + ); + let mut evm_actor = EvmActor::new(vault, pool.clone()); + + let transaction = TxEip1559 { + chain_id: 1, + nonce: 0, + gas_limit: 21_000, + max_fee_per_gas: 0, + max_priority_fee_per_gas: 0, + to: TxKind::Call(Address::ZERO), + value: U256::ZERO, + input: Bytes::new(), + access_list: AccessList::default(), + }; + let wallet_address = Address::from_slice(&address); + + // The paired positive case: while the access stands, both lookups resolve it and the + // calls fail further along (no grant, sealed vault) rather than at the access filter. + // Without this, a filter that rejected every row would pass the assertions below. + let live_analyze = evm_actor + .shared_analyze_transaction(client_id, wallet_address, transaction.clone()) + .await; + assert!( + !matches!(live_analyze, Err(SignTransactionError::WalletNotFound)), + "a live access must resolve through shared_analyze_transaction: {live_analyze:?}" + ); + let live_sign = evm_actor + .client_sign_transaction(client_id, wallet_address, transaction.clone()) + .await; + assert!( + !matches!(live_sign, Err(SignTransactionError::WalletNotFound)), + "a live access must resolve through client_sign_transaction: {live_sign:?}" + ); + + let mut conn = pool.get().await.unwrap(); + revoke_wallet_access(&mut conn, &[access_id]).await.unwrap(); + drop(conn); + + // Both lookups resolve access the same way; both must reject the revoked row before + // ever touching the vault (neither call bootstraps one). + let analyze_result = evm_actor + .shared_analyze_transaction(client_id, wallet_address, transaction.clone()) + .await; + assert!( + matches!(analyze_result, Err(SignTransactionError::WalletNotFound)), + "a revoked access must not authorize shared_analyze_transaction: {analyze_result:?}" + ); + + let sign_result = evm_actor + .client_sign_transaction(client_id, wallet_address, transaction) + .await; + assert!( + matches!(sign_result, Err(SignTransactionError::WalletNotFound)), + "a revoked access must not authorize client_sign_transaction: {sign_result:?}" + ); + } + + /// Re-granting a revoked access must restore it rather than silently doing nothing: + /// `uniq_wallet_access` is a unique index on `(wallet_id, client_id)`, so a plain insert + /// would conflict on the revoked row forever. + #[tokio::test] + async fn regranting_a_revoked_access_restores_it() { + let pool = db::create_test_pool().await; + let mut conn = pool.get().await.unwrap(); + + let (wallet_id, _address) = seed_wallet(&mut conn).await; + let metadata_id = seed_client_metadata(&mut conn).await; + let client_id = seed_client(&mut conn, metadata_id).await; + let access_id = insert_wallet_access(&mut conn, wallet_id, client_id).await; + + revoke_wallet_access(&mut conn, &[access_id]).await.unwrap(); + + grant_wallet_access( + &mut conn, + vec![models::NewEvmWalletAccess { + wallet_id, + client_id, + }], + ) + .await + .unwrap(); + + let revoked_at: Option = schema::evm_wallet_access::table + .find(access_id) + .select(schema::evm_wallet_access::revoked_at) + .first(&mut conn) + .await + .unwrap(); + assert!( + revoked_at.is_none(), + "re-granting a revoked access must clear revoked_at" + ); + + let total: i64 = schema::evm_wallet_access::table + .count() + .get_result(&mut conn) + .await + .unwrap(); + assert_eq!( + total, 1, + "re-granting a revoked access must revive the existing row, not add a second one" + ); + } + + /// §3.2 puts wallet visibility and a persistent grant to two separate votes. Reviving a + /// revoked access restores visibility, and must restore nothing else: grant lookup keys on + /// `wallet_access_id` with `revoked_at is null`, so a grant left open when the access was + /// cut off would come back live -- with its original volume and rate limits -- the moment + /// the id revives. `EvmActor::grant_wallet_access` executes an approved `GrantWalletAccess` + /// proposal, so that would hand signing authority back to a committee that voted only on + /// visibility. + #[tokio::test] + async fn regranting_an_access_does_not_revive_its_grants() { + let pool = db::create_test_pool().await; + let mut conn = pool.get().await.unwrap(); + + let (wallet_id, _address) = seed_wallet(&mut conn).await; + let metadata_id = seed_client_metadata(&mut conn).await; + let client_id = seed_client(&mut conn, metadata_id).await; + + let entry = || models::NewEvmWalletAccess { + wallet_id, + client_id, + }; + grant_wallet_access(&mut conn, vec![entry()]).await.unwrap(); + let access_id: i32 = schema::evm_wallet_access::table + .filter(schema::evm_wallet_access::wallet_id.eq(wallet_id)) + .filter(schema::evm_wallet_access::client_id.eq(client_id)) + .select(schema::evm_wallet_access::id) + .first(&mut conn) + .await + .unwrap(); + + // The row every persistent grant hangs off: the specific ether- or token-transfer + // rows reference it, so liveness is decided here. + let grant_id: i32 = insert_into(schema::evm_basic_grant::table) + .values(models::NewEvmBasicGrant { + wallet_access_id: access_id, + chain_id: 1u64.into(), + valid_from: None, + valid_until: None, + max_gas_fee_per_gas: None, + max_priority_fee_per_gas: None, + rate_limit_count: None, + rate_limit_window_secs: None, + revoked_at: None, + }) + .returning(schema::evm_basic_grant::id) + .get_result(&mut conn) + .await + .unwrap(); + + assert_eq!( + live_grants_for(&mut conn, access_id).await, + vec![grant_id], + "the seeded grant must start out live, or the assertions below prove nothing" + ); + + revoke_wallet_access(&mut conn, &[access_id]).await.unwrap(); + assert!( + live_grants_for(&mut conn, access_id).await.is_empty(), + "revoking an access must revoke the grants that hang off it" + ); + + grant_wallet_access(&mut conn, vec![entry()]).await.unwrap(); + + let revoked_at: Option = schema::evm_wallet_access::table + .find(access_id) + .select(schema::evm_wallet_access::revoked_at) + .first(&mut conn) + .await + .unwrap(); + assert!( + revoked_at.is_none(), + "re-granting must restore visibility for the access itself" + ); + assert!( + live_grants_for(&mut conn, access_id).await.is_empty(), + "re-granting an access must not revive the grants it held before revocation" + ); + } +} diff --git a/server/crates/arbiter-server/src/peers/operator/session/mod.rs b/server/crates/arbiter-server/src/peers/operator/session/mod.rs index 79281bb..3debd89 100644 --- a/server/crates/arbiter-server/src/peers/operator/session/mod.rs +++ b/server/crates/arbiter-server/src/peers/operator/session/mod.rs @@ -1,4 +1,4 @@ -use super::{OutOfBand, OperatorConnection}; +use super::{AuthenticatedOperator, OutOfBand, OperatorConnection}; use crate::{ actors::{ flow_coordinator::client_connect_approval::ClientApprovalController, @@ -19,6 +19,17 @@ pub enum Error { #[error("State transition failed")] State, + /// §3.5: the ordinary and recovery roles reach for different handlers here. A refusal is a + /// policy answer, so it is named rather than folded into `Internal` beside real faults. + #[error("This operator role may not perform that action")] + RoleNotPermitted, + + /// Fewer access rows were revoked than the request named. Like `RoleNotPermitted` this is + /// an answer about the request, not a fault, so it is named rather than folded into + /// `Internal`. + #[error("Revoked {revoked} of {requested} wallet access entries")] + PartialRevoke { requested: usize, revoked: usize }, + #[error("Internal error: {message}")] Internal { message: Cow<'static, str> }, } @@ -51,6 +62,7 @@ pub struct PendingClientApproval { pub struct OperatorSession { props: OperatorConnection, + credentials: AuthenticatedOperator, sender: Box>, pending_client_approvals: HashMap, PendingClientApproval>, @@ -59,11 +71,31 @@ pub struct OperatorSession { pub mod handlers; impl OperatorSession { - pub(crate) fn new(props: OperatorConnection, sender: Box>) -> Self { + pub(crate) fn new(props: OperatorConnection, credentials: AuthenticatedOperator, sender: Box>) -> Self { Self { props, + credentials, sender, - pending_client_approvals: Default::default(), + pending_client_approvals: HashMap::default(), + } + } + + /// The id of the ordinary operator on the other end, or a refusal. + /// + /// Read from the handshake, never from a request body, so a peer cannot act under an id it + /// did not authenticate as. + const fn ordinary_id(&self) -> Result { + match &self.credentials { + AuthenticatedOperator::Ordinary(credentials) => Ok(credentials.id), + AuthenticatedOperator::Recovery(_) => Err(Error::RoleNotPermitted), + } + } + + /// The id of the recovery operator on the other end, or a refusal. See `ordinary_id`. + const fn recovery_id(&self) -> Result { + match &self.credentials { + AuthenticatedOperator::Recovery(credentials) => Ok(credentials.id), + AuthenticatedOperator::Ordinary(_) => Err(Error::RoleNotPermitted), } } } diff --git a/server/crates/arbiter-server/src/peers/operator/vault_gate/mod.rs b/server/crates/arbiter-server/src/peers/operator/vault_gate/mod.rs index 6a8a265..3c20e7b 100644 --- a/server/crates/arbiter-server/src/peers/operator/vault_gate/mod.rs +++ b/server/crates/arbiter-server/src/peers/operator/vault_gate/mod.rs @@ -1,10 +1,14 @@ -use super::Credentials; +use super::AuthenticatedOperator; use crate::{ actors::{ GlobalActors, vault::{self, Bootstrap, GetState, TryUnseal, VaultState, events}, + vault_coordinator::{ + ContributeBootstrap, ContributeRecoveryBootstrap, ContributeRecoveryUnseal, + ContributeUnseal, StartBootstrap, + }, }, - crypto::integrity::{self}, + crypto::{KeyCell, integrity::{self}}, db::DatabasePool, }; use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; @@ -17,6 +21,9 @@ use tokio::sync::oneshot; use tracing::{error, info}; use x25519_dalek::{EphemeralSecret, PublicKey, SharedSecret}; +pub use VaultGateMessage as Inbound; +pub use VaultGateMessageReply as Outbound; + pub mod state; #[derive(Debug, thiserror::Error)] @@ -29,6 +36,12 @@ pub enum Error { #[error("State transition failed")] State, + /// §3.5: ordinary and recovery operators hold different shares of the same split, so each + /// contribution belongs to exactly one of the two roles. A refusal here is a policy answer + /// and is kept out of `Internal`, which carries genuine faults. + #[error("This operator role may not perform that vault action")] + RoleNotPermitted, + #[error("Internal error: {0}")] Internal(String), } @@ -43,7 +56,7 @@ pub struct HandshakeResponse { } pub struct VaultGate { - pub auth_creds: Credentials, + pub auth_creds: AuthenticatedOperator, pub promotion_tx: Option>>, pub state: State, pub actors: GlobalActors, @@ -52,7 +65,7 @@ pub struct VaultGate { impl VaultGate { pub fn new( - auth_creds: Credentials, + auth_creds: AuthenticatedOperator, actors: GlobalActors, db: DatabasePool, promotion_tx: oneshot::Sender>, @@ -93,16 +106,33 @@ impl Actor for VaultGate { } impl VaultGate { + /// The id of the ordinary operator on the other end, or a refusal. + /// + /// The id is read from the handshake rather than from the request body, so a peer cannot + /// name an operator it did not authenticate as. + const fn ordinary_id(&self) -> Result { + match &self.auth_creds { + AuthenticatedOperator::Ordinary(credentials) => Ok(credentials.id), + AuthenticatedOperator::Recovery(_) => Err(Error::RoleNotPermitted), + } + } + + /// The id of the recovery operator on the other end, or a refusal. See `ordinary_id`. + const fn recovery_id(&self) -> Result { + match &self.auth_creds { + AuthenticatedOperator::Recovery(credentials) => Ok(credentials.id), + AuthenticatedOperator::Ordinary(_) => Err(Error::RoleNotPermitted), + } + } + fn decrypt_key( secret: &SharedSecret, nonce: &[u8], ciphertext: &[u8], associated_data: &[u8], - ) -> Result>, ()> { + ) -> Result { let nonce = XNonce::from_slice(nonce); - let cipher = XChaCha20Poly1305::new(secret.as_bytes().into()); - let mut key_buffer = SafeCell::new(ciphertext.to_vec()); let decryption_result = key_buffer.write_inline(|write_handle| { @@ -110,7 +140,9 @@ impl VaultGate { }); match decryption_result { - Ok(()) => Ok(key_buffer), + Ok(()) => KeyCell::try_from(key_buffer).map_err(|()| { + error!("Decrypted key material has unexpected length"); + }), Err(err) => { error!(?err, "Failed to decrypt encrypted key material"); Err(()) @@ -119,7 +151,7 @@ impl VaultGate { } } -#[messages(messages = Inbound, replies = Outbound)] +#[messages(enum)] impl VaultGate { #[message] pub fn handle_handshake( @@ -141,6 +173,13 @@ impl VaultGate { }) } + /// Deliberately open to both roles, unlike `handle_bootstrap_encrypted_key` below. + /// + /// Handing over the whole seal key to open a sealed vault is participating in unsealing, + /// which §3.5 grants a recovery operator, and the peer has to hold that key already -- it + /// gains nothing here it did not bring. Bootstrap is the opposite: it *chooses* the key for + /// a vault that has none, which is sole custody of the root key and belongs to no §3.5 + /// power. The reasoning that admits one does not admit the other. #[message] pub async fn handle_unseal_encrypted_key( &mut self, @@ -152,17 +191,14 @@ impl VaultGate { return Err(Error::State); }; - let Ok(seal_key_buffer) = Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data) - else { + let Ok(seal_key) = Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data) else { return Err(Error::InvalidKey); }; match self .actors .vault - .ask(TryUnseal { - seal_key_raw: seal_key_buffer, - }) + .ask(TryUnseal { seal_key }) .await { Ok(()) => { @@ -181,6 +217,10 @@ impl VaultGate { } } + /// §3.4/§3.5: bootstrapping picks the root key for a vault that has none, so whoever gets + /// here holds sole custody until the committee splits it. That is not one of a recovery + /// operator's two powers, and the check comes first because the vault commits before this + /// handler could refuse anything afterwards. #[message] pub async fn handle_bootstrap_encrypted_key( &mut self, @@ -188,21 +228,20 @@ impl VaultGate { ciphertext: Vec, associated_data: Vec, ) -> Result<(), Error> { + let _ = self.ordinary_id()?; + let State::ReadyForExchange { secret, .. } = &self.state else { return Err(Error::State); }; - let Ok(seal_key_buffer) = Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data) - else { + let Ok(seal_key) = Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data) else { return Err(Error::InvalidKey); }; match self .actors .vault - .ask(Bootstrap { - seal_key_raw: seal_key_buffer, - }) + .ask(Bootstrap { seal_key }) .await { Ok(()) => { @@ -234,6 +273,97 @@ impl VaultGate { Ok(answer) } + + #[message] + pub async fn handle_declare_committee( + &mut self, + count: usize, + recovery_count: usize, + ) -> Result<(), Error> { + let operator_id = self.ordinary_id()?; + + self.actors + .vault_coordinator + .ask(StartBootstrap { + operator_id, + declared_count: count, + recovery_count, + }) + .await + .map_err(|_| Error::internal("VaultCoordinator unavailable")) + } + + #[message] + pub async fn handle_contribute_bootstrap_passphrase( + &mut self, + passphrase: Vec, + ) -> Result { + let operator_id = self.ordinary_id()?; + + let passphrase_cell = SafeCell::new(passphrase); + self.actors + .vault_coordinator + .ask(ContributeBootstrap { + operator_id, + passphrase: passphrase_cell, + }) + .await + .map_err(|_| Error::internal("VaultCoordinator unavailable")) + } + + #[message] + pub async fn handle_contribute_recovery_bootstrap_passphrase( + &mut self, + passphrase: Vec, + ) -> Result { + let recovery_operator_id = self.recovery_id()?; + + let passphrase_cell = SafeCell::new(passphrase); + self.actors + .vault_coordinator + .ask(ContributeRecoveryBootstrap { + recovery_operator_id, + passphrase: passphrase_cell, + }) + .await + .map_err(|_| Error::internal("VaultCoordinator unavailable")) + } + + #[message] + pub async fn handle_contribute_unseal_passphrase( + &mut self, + passphrase: Vec, + ) -> Result { + let operator_id = self.ordinary_id()?; + + let passphrase_cell = SafeCell::new(passphrase); + self.actors + .vault_coordinator + .ask(ContributeUnseal { + operator_id, + passphrase: passphrase_cell, + }) + .await + .map_err(|_| Error::internal("VaultCoordinator unavailable")) + } + + #[message] + pub async fn handle_contribute_recovery_unseal_passphrase( + &mut self, + passphrase: Vec, + ) -> Result { + let recovery_operator_id = self.recovery_id()?; + + let passphrase_cell = SafeCell::new(passphrase); + self.actors + .vault_coordinator + .ask(ContributeRecoveryUnseal { + recovery_operator_id, + passphrase: passphrase_cell, + }) + .await + .map_err(|_| Error::internal("VaultCoordinator unavailable")) + } } impl Message for VaultGate { @@ -250,13 +380,28 @@ impl Message for VaultGate { .get() .await .map_err(|_| Error::internal("DB unavailable"))?; - integrity::sign_entity( - &mut conn, - &self.actors.vault, - &self.auth_creds, - self.auth_creds.id, - ) - .await + // Each role signs under its own `Integrable::KIND`, so the two id spaces cannot + // collide in `integrity_envelope`. + match &self.auth_creds { + AuthenticatedOperator::Ordinary(credentials) => { + integrity::sign_entity( + &mut conn, + &self.actors.vault, + credentials, + credentials.id, + ) + .await + } + AuthenticatedOperator::Recovery(credentials) => { + integrity::sign_entity( + &mut conn, + &self.actors.vault, + credentials, + credentials.id, + ) + .await + } + } .map_err(|e| { error!(?e, "Failed to sign integrity envelope on bootstrap"); Error::internal("Integrity sign failed") diff --git a/server/crates/arbiter-server/tests/client/auth.rs b/server/crates/arbiter-server/tests/client/auth.rs index a7320f6..4c1977a 100644 --- a/server/crates/arbiter-server/tests/client/auth.rs +++ b/server/crates/arbiter-server/tests/client/auth.rs @@ -1,8 +1,5 @@ -use super::common::ChannelTransport; -use arbiter_crypto::{ - authn::{self, AuthChallenge, CLIENT_CONTEXT}, - safecell::{SafeCell, SafeCellHandle as _}, -}; +use super::common::{ChannelTransport, spawn_actors}; +use arbiter_crypto::authn::{self, AuthChallenge, SigningContext}; use arbiter_proto::{ ClientMetadata, transport::{Receiver, Sender}, @@ -74,7 +71,7 @@ async fn insert_registered_client( fn sign_client_challenge(key: &SigningKey, challenge: &AuthChallenge) -> authn::Signature { let challenge = challenge.format(); key.signing_key() - .sign_deterministic(&challenge, CLIENT_CONTEXT) + .sign_deterministic(&challenge, SigningContext::Client.as_bytes()) .unwrap() .into() } @@ -96,11 +93,11 @@ async fn insert_bootstrap_sentinel_operator(db: &db::DatabasePool) { async fn spawn_test_actors(db: &db::DatabasePool) -> GlobalActors { insert_bootstrap_sentinel_operator(db).await; - let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let actors = spawn_actors(db.clone()).await; actors .vault .ask(Bootstrap { - seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()), + seal_key: arbiter_server::crypto::KeyCell::from([0u8; 32]), }) .await .unwrap(); diff --git a/server/crates/arbiter-server/tests/common/mod.rs b/server/crates/arbiter-server/tests/common/mod.rs index 83a2f81..42fe3a3 100644 --- a/server/crates/arbiter-server/tests/common/mod.rs +++ b/server/crates/arbiter-server/tests/common/mod.rs @@ -2,7 +2,6 @@ dead_code, reason = "Common test utilities that may not be used in every test" )] -use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; use arbiter_proto::transport::{Bi, Error, Receiver, Sender}; use arbiter_server::{ actors::{GlobalActors, vault::Vault}, @@ -19,12 +18,42 @@ pub(crate) async fn bootstrapped_vault(db: &db::DatabasePool) -> Vault { .await .unwrap(); actor - .bootstrap(SafeCell::new(b"test-seal-key".to_vec())) + .bootstrap(arbiter_server::crypto::KeyCell::from([0u8; 32])) .await .unwrap(); actor } +/// Spawns a full `GlobalActors` for a test, backing `Bootstrapper`'s token file with a +/// throwaway temp directory rather than the real `~/.arbiter` -- a test must never be able to +/// reach, let alone write to, the developer's real bootstrap token file. +pub(crate) async fn spawn_actors(db: db::DatabasePool) -> GlobalActors { + let home = tempfile::tempdir().expect("failed to create a temp home directory for a test"); + GlobalActors::spawn_in(db, home.path()) + .await + .expect("failed to spawn GlobalActors for a test") +} + +/// Retries `probe` until it yields a value, then returns it. +/// +/// Effects that travel over the message bus are not visible the moment the publishing call +/// returns: `Publish` only enqueues to the bus's mailbox, which then delivers to each +/// subscriber's mailbox in turn. A test observing such an effect waits for it instead of +/// reading straight after the call that triggered it. +pub(crate) async fn eventually(what: &str, mut probe: F) -> T +where + F: FnMut() -> Fut, + Fut: Future>, +{ + for _ in 0..100 { + if let Some(value) = probe().await { + return value; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + panic!("{what} did not happen within 2s"); +} + pub(crate) async fn root_key_history_id(db: &db::DatabasePool) -> i32 { let mut conn = db.get().await.unwrap(); let id = schema::arbiter_settings::table diff --git a/server/crates/arbiter-server/tests/governance.rs b/server/crates/arbiter-server/tests/governance.rs new file mode 100644 index 0000000..21c4c31 --- /dev/null +++ b/server/crates/arbiter-server/tests/governance.rs @@ -0,0 +1,1350 @@ +use arbiter_crypto::authn::{self, SigningContext}; +// The tests must sign exactly what the server verifies, so they share the encoder. +use arbiter_server::crypto::governance::vote_message as make_vote_message; +use arbiter_server::{ + actors::{ + GlobalActors, + proposal_manager::{ + CancelRecoveryWakeup, CastRecoveryVote, CastVote, CreateProposal, + Error as ProposalError, MAX_TTL_SECS, QueryPending, RequestRecoveryWakeup, VoteOutcome, + }, + }, + crypto::KeyCell, + db::{ + self, + models::{OperatorIdentityId, RecoveryOperatorIdentityId}, + proposal::{ + ProposalKind, approve_sdk_client, grant_wallet_access, one_off_transaction, + persistent_grant, replace_operator, + }, + }, +}; +use arbiter_server::actors::vault::Bootstrap; +use arbiter_server::db::schema::{ + aead_encrypted, arbiter_settings, evm_basic_grant, evm_wallet, evm_wallet_access, + operator_identity, proposal_one_off_transaction_result, recovery_operator_identity, +}; +use diesel::{ExpressionMethods, QueryDsl, insert_into}; +use diesel_async::RunQueryDsl; + +/// Retries `probe` until it yields a value, then returns it. +/// +/// Outcome execution is asynchronous: `CastVote` answers as soon as the quorum is +/// recorded, and the actor that owns the kind runs afterwards off the message bus. Tests +/// therefore wait for the effect instead of reading the database straight after the vote. +async fn eventually(what: &str, mut probe: F) -> T +where + F: FnMut() -> Fut, + Fut: Future>, +{ + for _ in 0..100 { + if let Some(value) = probe().await { + return value; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + panic!("{what} did not happen within 2s"); +} + +/// Spawns a full `GlobalActors` for a test, backing `Bootstrapper`'s token file with a +/// throwaway temp directory rather than the real `~/.arbiter` -- a test must never be able to +/// reach, let alone write to, the developer's real bootstrap token file. +async fn spawn_actors(db: db::DatabasePool) -> GlobalActors { + let home = tempfile::tempdir().expect("failed to create a temp home directory for a test"); + GlobalActors::spawn_in(db, home.path()) + .await + .expect("failed to spawn GlobalActors for a test") +} + +async fn register_operator(db: &db::DatabasePool, pubkey: &authn::PublicKey) -> OperatorIdentityId { + let mut conn = db.get().await.unwrap(); + insert_into(operator_identity::table) + .values(operator_identity::public_key.eq(pubkey.to_bytes())) + .returning(operator_identity::id) + .get_result::(&mut conn) + .await + .unwrap() +} + +async fn register_recovery_operator( + db: &db::DatabasePool, + pubkey: &authn::PublicKey, +) -> RecoveryOperatorIdentityId { + let mut conn = db.get().await.unwrap(); + insert_into(recovery_operator_identity::table) + .values(recovery_operator_identity::public_key.eq(pubkey.to_bytes())) + .returning(recovery_operator_identity::id) + .get_result::(&mut conn) + .await + .unwrap() +} + +/// Backdates a wakeup request so it appears to have passed the 14-day window. +async fn insert_active_wakeup(db: &db::DatabasePool, operator_id: OperatorIdentityId) { + let mut conn = db.get().await.unwrap(); + diesel::sql_query(format!( + "INSERT INTO recovery_wakeup_request (requested_by, requested_at) \ + VALUES ({}, unixepoch('now') - 14*24*3600 - 1)", + operator_id.to_raw() + )) + .execute(&mut conn) + .await + .unwrap(); +} + +/// Requires the vault to already be bootstrapped: it reads the root key row `Bootstrap` +/// creates so the aead-encrypted wallet secret has a real parent to reference. +async fn insert_evm_wallet(db: &db::DatabasePool) -> i32 { + let mut conn = db.get().await.unwrap(); + let root_key_id: i32 = arbiter_settings::table + .select(arbiter_settings::root_key_id) + .first::>(&mut conn) + .await + .unwrap() + .expect("vault must be bootstrapped before creating an aead-encrypted row"); + let aead_id: i32 = insert_into(aead_encrypted::table) + .values(( + aead_encrypted::current_nonce.eq(vec![0u8; 4]), + aead_encrypted::ciphertext.eq(vec![0u8; 32]), + aead_encrypted::tag.eq(vec![0u8; 16]), + aead_encrypted::associated_root_key_id.eq(root_key_id), + )) + .returning(aead_encrypted::id) + .get_result::(&mut conn) + .await + .unwrap(); + insert_into(evm_wallet::table) + .values(( + evm_wallet::address.eq(vec![0u8; 20]), + evm_wallet::aead_encrypted_id.eq(aead_id), + )) + .returning(evm_wallet::id) + .get_result::(&mut conn) + .await + .unwrap() +} + +async fn insert_unapproved_client(db: &db::DatabasePool, pubkey: &authn::PublicKey) -> i32 { + use arbiter_server::db::schema::{client_metadata, program_client}; + let mut conn = db.get().await.unwrap(); + let metadata_id: i32 = insert_into(client_metadata::table) + .values(( + client_metadata::name.eq("test-client"), + client_metadata::description.eq(Option::::None), + client_metadata::version.eq(Option::::None), + )) + .returning(client_metadata::id) + .get_result(&mut conn) + .await + .unwrap(); + + insert_into(program_client::table) + .values(( + program_client::public_key.eq(pubkey.to_bytes()), + program_client::metadata_id.eq(metadata_id), + )) + .returning(program_client::id) + .get_result(&mut conn) + .await + .unwrap() +} + +#[tokio::test] +async fn create_proposal_returns_id() { + let db = db::create_test_pool().await; + let actors = spawn_actors(db.clone()).await; + actors + .vault + .ask(Bootstrap { + seal_key: KeyCell::from([0u8; 32]), + }) + .await + .unwrap(); + + let key = authn::SigningKey::generate(); + let operator_id = register_operator(&db, &key.public_key()).await; + let client_key = authn::SigningKey::generate(); + let client_id = insert_unapproved_client(&db, &client_key.public_key()).await; + + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id }), + initiator_id: operator_id, + ttl_secs: None, + }) + .await + .unwrap(); + + assert!(proposal_id.to_raw() > 0); +} + +#[tokio::test] +async fn create_proposal_caps_the_ttl() { + let db = db::create_test_pool().await; + let actors = spawn_actors(db.clone()).await; + actors + .vault + .ask(Bootstrap { + seal_key: KeyCell::from([0u8; 32]), + }) + .await + .unwrap(); + + let key = authn::SigningKey::generate(); + let op = register_operator(&db, &key.public_key()).await; + let client_key = authn::SigningKey::generate(); + let client_id = insert_unapproved_client(&db, &client_key.public_key()).await; + + let create = async |ttl: u32| { + actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id }), + initiator_id: op, + ttl_secs: Some(ttl), + }) + .await + }; + + // The boundary itself must still be accepted: the check is `>`, not `>=`. + create(MAX_TTL_SECS) + .await + .expect("a TTL at the ceiling must be accepted"); + + assert!(matches!( + create(MAX_TTL_SECS + 1).await, + Err(kameo::error::SendError::HandlerError( + ProposalError::TtlTooLong + )) + )); +} + +#[tokio::test] +async fn single_operator_vote_reaches_quorum() { + let db = db::create_test_pool().await; + let actors = spawn_actors(db.clone()).await; + actors + .vault + .ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }) + .await + .unwrap(); + + let signing_key = authn::SigningKey::generate(); + let op_id = register_operator(&db, &signing_key.public_key()).await; + + let client_key = authn::SigningKey::generate(); + let client_id = insert_unapproved_client(&db, &client_key.public_key()).await; + + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id }), + initiator_id: op_id, + ttl_secs: None, + }) + .await + .unwrap(); + + let msg = make_vote_message(proposal_id, true); + let sig = signing_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); + + let outcome = actors + .proposal_manager + .ask(CastVote { + proposal_id, + operator_id: op_id, + approve: true, + signature: sig.to_bytes(), + }) + .await + .unwrap(); + + assert_eq!(outcome, VoteOutcome::Approved); +} + +#[tokio::test] +async fn two_operator_first_vote_is_pending() { + let db = db::create_test_pool().await; + let actors = spawn_actors(db.clone()).await; + actors + .vault + .ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }) + .await + .unwrap(); + + let key1 = authn::SigningKey::generate(); + let key2 = authn::SigningKey::generate(); + let op1 = register_operator(&db, &key1.public_key()).await; + let _op2 = register_operator(&db, &key2.public_key()).await; + let client_key = authn::SigningKey::generate(); + let client_id = insert_unapproved_client(&db, &client_key.public_key()).await; + + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id }), + initiator_id: op1, + ttl_secs: None, + }) + .await + .unwrap(); + + let msg = make_vote_message(proposal_id, true); + let sig = key1 + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); + + let outcome = actors + .proposal_manager + .ask(CastVote { + proposal_id, + operator_id: op1, + approve: true, + signature: sig.to_bytes(), + }) + .await + .unwrap(); + + assert_eq!(outcome, VoteOutcome::Pending); +} + +#[tokio::test] +async fn duplicate_vote_rejected() { + let db = db::create_test_pool().await; + let actors = spawn_actors(db.clone()).await; + actors + .vault + .ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }) + .await + .unwrap(); + + let key = authn::SigningKey::generate(); + let op = register_operator(&db, &key.public_key()).await; + + let client_key = authn::SigningKey::generate(); + let client_id = insert_unapproved_client(&db, &client_key.public_key()).await; + + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id }), + initiator_id: op, + ttl_secs: None, + }) + .await + .unwrap(); + + let msg = make_vote_message(proposal_id, true); + let sig = key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); + actors + .proposal_manager + .ask(CastVote { + proposal_id, + operator_id: op, + approve: true, + signature: sig.to_bytes(), + }) + .await + .unwrap(); + + // Second vote same operator + let sig2 = key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); + let result = actors + .proposal_manager + .ask(CastVote { + proposal_id, + operator_id: op, + approve: true, + signature: sig2.to_bytes(), + }) + .await; + + assert!(matches!( + result, + Err(kameo::error::SendError::HandlerError(ProposalError::AlreadyVoted)) + )); +} + +#[tokio::test] +async fn invalid_signature_rejected() { + let db = db::create_test_pool().await; + let actors = spawn_actors(db.clone()).await; + actors + .vault + .ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }) + .await + .unwrap(); + + let key = authn::SigningKey::generate(); + let op = register_operator(&db, &key.public_key()).await; + let client_key = authn::SigningKey::generate(); + let client_id = insert_unapproved_client(&db, &client_key.public_key()).await; + + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id }), + initiator_id: op, + ttl_secs: None, + }) + .await + .unwrap(); + + let result = actors + .proposal_manager + .ask(CastVote { + proposal_id, + operator_id: op, + approve: true, + signature: vec![0u8; 32], // garbage + }) + .await; + + assert!(matches!( + result, + Err(kameo::error::SendError::HandlerError(ProposalError::InvalidSignature)) + )); +} + +#[tokio::test] +async fn query_pending_reports_a_tally_per_proposal() { + let db = db::create_test_pool().await; + let actors = spawn_actors(db.clone()).await; + actors + .vault + .ask(Bootstrap { + seal_key: KeyCell::from([0u8; 32]), + }) + .await + .unwrap(); + + // Three operators, so one vote stays below the 2-of-3 threshold and every + // proposal is still pending when it is queried. + let approver = authn::SigningKey::generate(); + let rejecter = authn::SigningKey::generate(); + let watcher = authn::SigningKey::generate(); + let approver_id = register_operator(&db, &approver.public_key()).await; + let rejecter_id = register_operator(&db, &rejecter.public_key()).await; + let watcher_id = register_operator(&db, &watcher.public_key()).await; + + let cast = async |proposal_id, voter_id, key: &authn::SigningKey, approve| { + let sig = key + .sign_message( + &make_vote_message(proposal_id, approve), + SigningContext::GovernanceVote, + ) + .unwrap(); + actors + .proposal_manager + .ask(CastVote { + proposal_id, + operator_id: voter_id, + approve, + signature: sig.to_bytes(), + }) + .await + .unwrap() + }; + + let mut ids = Vec::new(); + for _ in 1..=3 { + let client_key = authn::SigningKey::generate(); + let client_id = insert_unapproved_client(&db, &client_key.public_key()).await; + let id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id }), + initiator_id: watcher_id, + ttl_secs: None, + }) + .await + .unwrap(); + ids.push(id); + } + + // First proposal: one approval. Second: one rejection. Third: neither. + assert_eq!( + cast(ids[0], approver_id, &approver, true).await, + VoteOutcome::Pending + ); + assert_eq!( + cast(ids[1], rejecter_id, &rejecter, false).await, + VoteOutcome::Pending + ); + + let summaries = actors + .proposal_manager + .ask(QueryPending { + operator_id: watcher_id, + }) + .await + .unwrap(); + assert_eq!(summaries.len(), 3, "the watcher has voted on nothing"); + + for summary in summaries { + let (approve, reject) = match summary.id { + id if id == ids[0] => (1, 0), + id if id == ids[1] => (0, 1), + _ => (0, 0), + }; + assert_eq!( + summary.approve_count, approve, + "approvals of {:?}", + summary.id + ); + assert_eq!( + summary.reject_count, reject, + "rejections of {:?}", + summary.id + ); + } +} + +#[tokio::test] +async fn query_pending_excludes_already_voted() { + let db = db::create_test_pool().await; + let actors = spawn_actors(db.clone()).await; + actors + .vault + .ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }) + .await + .unwrap(); + + let signing_key = authn::SigningKey::generate(); + let op = register_operator(&db, &signing_key.public_key()).await; + + let client_key1 = authn::SigningKey::generate(); + let client_id1 = insert_unapproved_client(&db, &client_key1.public_key()).await; + let client_key2 = authn::SigningKey::generate(); + let client_id2 = insert_unapproved_client(&db, &client_key2.public_key()).await; + + let p1 = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id: client_id1 }), + initiator_id: op, + ttl_secs: None, + }) + .await + .unwrap(); + + let p2 = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id: client_id2 }), + initiator_id: op, + ttl_secs: None, + }) + .await + .unwrap(); + + // Vote on p1 — with 1 operator this reaches quorum (Approved) + let msg = make_vote_message(p1, true); + let sig = signing_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); + let outcome = actors + .proposal_manager + .ask(CastVote { + proposal_id: p1, + operator_id: op, + approve: true, + signature: sig.to_bytes(), + }) + .await + .unwrap(); + assert_eq!(outcome, VoteOutcome::Approved); + + // QueryPending should return only p2 + let pending = actors + .proposal_manager + .ask(QueryPending { operator_id: op }) + .await + .unwrap(); + + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].id, p2); +} + +#[tokio::test] +async fn expired_proposal_is_hidden_and_unvotable() { + let db = db::create_test_pool().await; + let actors = spawn_actors(db.clone()).await; + actors + .vault + .ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }) + .await + .unwrap(); + + let signing_key = authn::SigningKey::generate(); + let op = register_operator(&db, &signing_key.public_key()).await; + + let client_key = authn::SigningKey::generate(); + let client_id = insert_unapproved_client(&db, &client_key.public_key()).await; + + // Create proposal with ttl_secs = 0 so it's immediately expired + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id }), + initiator_id: op, + ttl_secs: Some(0), + }) + .await + .unwrap(); + + // The row keeps status 'pending' (nothing sweeps it), but reads must skip it. + let pending = actors + .proposal_manager + .ask(QueryPending { operator_id: op }) + .await + .unwrap(); + assert!(pending.is_empty()); + + // And the write path must refuse it rather than rely on a status flip. + let msg = make_vote_message(proposal_id, true); + let sig = signing_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); + let result = actors + .proposal_manager + .ask(CastVote { + proposal_id, + operator_id: op, + approve: true, + signature: sig.to_bytes(), + }) + .await; + + assert!(matches!( + result, + Err(kameo::error::SendError::HandlerError(ProposalError::ProposalExpired)) + )); +} + +#[tokio::test] +async fn approve_sdk_client_writes_integrity_envelope() { + use arbiter_server::db::schema::integrity_envelope; + + let db = db::create_test_pool().await; + let actors = spawn_actors(db.clone()).await; + actors + .vault + .ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }) + .await + .unwrap(); + + let client_key = authn::SigningKey::generate(); + let client_id = insert_unapproved_client(&db, &client_key.public_key()).await; + + let op_key = authn::SigningKey::generate(); + let op_id = register_operator(&db, &op_key.public_key()).await; + + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id }), + initiator_id: op_id, + ttl_secs: None, + }) + .await + .unwrap(); + + let msg = make_vote_message(proposal_id, true); + let sig = op_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); + let outcome = actors + .proposal_manager + .ask(CastVote { + proposal_id, + operator_id: op_id, + approve: true, + signature: sig.to_bytes(), + }) + .await + .unwrap(); + + assert_eq!(outcome, VoteOutcome::Approved); + + eventually("the client's integrity envelope", || { + let db = db.clone(); + async move { + let mut conn = db.get().await.unwrap(); + let count: i64 = integrity_envelope::table + .filter(integrity_envelope::entity_kind.eq("client_credentials")) + .count() + .get_result(&mut conn) + .await + .unwrap(); + (count == 1).then_some(()) + } + }) + .await; +} + +#[tokio::test] +async fn grant_wallet_access_on_quorum_approval() { + let db = db::create_test_pool().await; + let actors = spawn_actors(db.clone()).await; + actors + .vault + .ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }) + .await + .unwrap(); + + let signing_key = authn::SigningKey::generate(); + let op_id = register_operator(&db, &signing_key.public_key()).await; + + let wallet_id = insert_evm_wallet(&db).await; + let client_key = authn::SigningKey::generate(); + let client_id = insert_unapproved_client(&db, &client_key.public_key()).await; + + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::GrantWalletAccess(grant_wallet_access::Settings { wallet_id, client_id }), + initiator_id: op_id, + ttl_secs: None, + }) + .await + .unwrap(); + + let msg = make_vote_message(proposal_id, true); + let sig = signing_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); + let outcome = actors + .proposal_manager + .ask(CastVote { + proposal_id, + operator_id: op_id, + approve: true, + signature: sig.to_bytes(), + }) + .await + .unwrap(); + + assert_eq!(outcome, VoteOutcome::Approved); + + eventually("the wallet access row", || { + let db = db.clone(); + async move { + let mut conn = db.get().await.unwrap(); + let count: i64 = evm_wallet_access::table + .filter(evm_wallet_access::wallet_id.eq(wallet_id)) + .filter(evm_wallet_access::client_id.eq(client_id)) + .count() + .get_result(&mut conn) + .await + .unwrap(); + (count == 1).then_some(()) + } + }) + .await; +} + +#[tokio::test] +async fn approve_persistent_grant_creates_basic_grant_row() { + let db = db::create_test_pool().await; + let actors = spawn_actors(db.clone()).await; + actors + .vault + .ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }) + .await + .unwrap(); + + let signing_key = authn::SigningKey::generate(); + let op_id = register_operator(&db, &signing_key.public_key()).await; + + // Insert a dummy wallet and client, then a wallet_access row + let wallet_id = insert_evm_wallet(&db).await; + let client_key = authn::SigningKey::generate(); + let client_id = insert_unapproved_client(&db, &client_key.public_key()).await; + + let mut conn = db.get().await.unwrap(); + let wallet_access_id: i32 = insert_into(evm_wallet_access::table) + .values(( + evm_wallet_access::wallet_id.eq(wallet_id), + evm_wallet_access::client_id.eq(client_id), + )) + .returning(evm_wallet_access::id) + .get_result(&mut conn) + .await + .unwrap(); + drop(conn); + + let grant = persistent_grant::Settings { + wallet_access_id, + chain_id: 1, + valid_from_secs: None, + valid_until_secs: None, + max_gas_fee_per_gas: None, + max_priority_fee_per_gas: None, + rate_limit: None, + specific: persistent_grant::Specific::EtherTransfer { + targets: vec![[0u8; 20]], + limit: persistent_grant::VolumeLimit { + max_volume: alloy::primitives::U256::from(1_000_000u64).to_be_bytes(), + window_secs: 86400, + }, + }, + }; + + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApprovePersistentGrant(Box::new(grant)), + initiator_id: op_id, + ttl_secs: None, + }) + .await + .unwrap(); + + let msg = make_vote_message(proposal_id, true); + let sig = signing_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); + let outcome = actors + .proposal_manager + .ask(CastVote { + proposal_id, + operator_id: op_id, + approve: true, + signature: sig.to_bytes(), + }) + .await + .unwrap(); + + assert_eq!(outcome, VoteOutcome::Approved); + + eventually("the basic grant row", || { + let db = db.clone(); + async move { + let mut conn = db.get().await.unwrap(); + let count: i64 = evm_basic_grant::table + .filter(evm_basic_grant::wallet_access_id.eq(wallet_access_id)) + .count() + .get_result(&mut conn) + .await + .unwrap(); + (count == 1).then_some(()) + } + }) + .await; +} + +#[tokio::test] +async fn approve_one_off_transaction_stores_result() { + use alloy::primitives::{Address, U256}; + use arbiter_server::actors::evm::{Generate, OperatorCreateGrant}; + use arbiter_server::evm::policies::{ + SharedGrantSettings, SpecificGrant, VolumeRateLimit, ether_transfer, + }; + use chrono::Duration; + + let db = db::create_test_pool().await; + let actors = spawn_actors(db.clone()).await; + actors + .vault + .ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }) + .await + .unwrap(); + + let signing_key = authn::SigningKey::generate(); + let op_id = register_operator(&db, &signing_key.public_key()).await; + + // Create a real encrypted wallet + let (wallet_id, wallet_address) = actors.evm.ask(Generate {}).await.unwrap(); + + // Create a client and wallet_access + let client_key = authn::SigningKey::generate(); + let client_id = insert_unapproved_client(&db, &client_key.public_key()).await; + + let mut conn = db.get().await.unwrap(); + let wallet_access_id: i32 = insert_into(evm_wallet_access::table) + .values(( + evm_wallet_access::wallet_id.eq(wallet_id), + evm_wallet_access::client_id.eq(client_id), + )) + .returning(evm_wallet_access::id) + .get_result(&mut conn) + .await + .unwrap(); + drop(conn); + + // Create a grant that permits ether transfer to address zero + let to_address = Address::ZERO; + actors + .evm + .ask(OperatorCreateGrant { + basic: SharedGrantSettings { + wallet_access_id, + chain: 1, + valid_from: None, + valid_until: None, + max_gas_fee_per_gas: None, + max_priority_fee_per_gas: None, + rate_limit: None, + }, + grant: SpecificGrant::EtherTransfer(ether_transfer::Settings { + target: vec![to_address], + limit: VolumeRateLimit { + max_volume: U256::from(1_000_000_000_000_000_000u128), + window: Duration::hours(24), + }, + }), + }) + .await + .unwrap(); + + let transaction = one_off_transaction::Settings { + client_id, + wallet_address: wallet_address.into(), + chain_id: 1, + nonce: 0, + gas_limit: 21000, + max_fee_per_gas: 1, + max_priority_fee_per_gas: 1, + to: to_address.into(), + value: U256::from(1u64).to_be_bytes(), + input: vec![], + }; + + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApproveOneOffTransaction(Box::new(transaction)), + initiator_id: op_id, + ttl_secs: None, + }) + .await + .unwrap(); + + let msg = make_vote_message(proposal_id, true); + let sig = signing_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); + let outcome = actors + .proposal_manager + .ask(CastVote { + proposal_id, + operator_id: op_id, + approve: true, + signature: sig.to_bytes(), + }) + .await + .unwrap(); + + assert_eq!(outcome, VoteOutcome::Approved); + + let (r, s, y_parity): (Vec, Vec, i32) = eventually("the transaction signature", || { + let db = db.clone(); + async move { + let mut conn = db.get().await.unwrap(); + proposal_one_off_transaction_result::table + .find(proposal_id) + .select(( + proposal_one_off_transaction_result::r, + proposal_one_off_transaction_result::s, + proposal_one_off_transaction_result::y_parity, + )) + .first(&mut conn) + .await + .ok() + } + }) + .await; + + assert_eq!(r.len(), 32, "r must be a 32-byte scalar"); + assert_eq!(s.len(), 32, "s must be a 32-byte scalar"); + assert!(y_parity == 0 || y_parity == 1, "y_parity must be a bit"); +} + +#[tokio::test] +async fn replace_operator_updates_pubkey_and_starts_rekey() { + let db = db::create_test_pool().await; + let actors = spawn_actors(db.clone()).await; + actors + .vault + .ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }) + .await + .unwrap(); + + let signing_key = authn::SigningKey::generate(); + let op_id = register_operator(&db, &signing_key.public_key()).await; + + let new_op_key = authn::SigningKey::generate(); + let new_pubkey = new_op_key.public_key().to_bytes(); + + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ReplaceOperator(replace_operator::Settings { + old_operator_id: op_id, + new_pubkey: new_pubkey.clone(), + }), + initiator_id: op_id, + ttl_secs: None, + }) + .await + .unwrap(); + + let msg = make_vote_message(proposal_id, true); + let sig = signing_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); + let outcome = actors + .proposal_manager + .ask(CastVote { + proposal_id, + operator_id: op_id, + approve: true, + signature: sig.to_bytes(), + }) + .await + .unwrap(); + + assert_eq!(outcome, VoteOutcome::Approved); + + eventually("the operator's public key to be replaced", || { + let db = db.clone(); + let new_pubkey = new_pubkey.clone(); + async move { + let mut conn = db.get().await.unwrap(); + let stored: Vec = operator_identity::table + .filter(operator_identity::id.eq(op_id)) + .select(operator_identity::public_key) + .first(&mut conn) + .await + .unwrap(); + (stored == new_pubkey).then_some(()) + } + }) + .await; + + // The old identity row is updated in place, so no second operator appears. + let mut conn = db.get().await.unwrap(); + let count: i64 = operator_identity::table + .count() + .get_result(&mut conn) + .await + .unwrap(); + assert_eq!(count, 1); +} + +#[tokio::test] +async fn trigger_rekey_reaches_quorum() { + let db = db::create_test_pool().await; + let actors = spawn_actors(db.clone()).await; + actors + .vault + .ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }) + .await + .unwrap(); + + let signing_key = authn::SigningKey::generate(); + let op_id = register_operator(&db, &signing_key.public_key()).await; + + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::TriggerRekey, + initiator_id: op_id, + ttl_secs: None, + }) + .await + .unwrap(); + + let msg = make_vote_message(proposal_id, true); + let sig = signing_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); + let outcome = actors + .proposal_manager + .ask(CastVote { + proposal_id, + operator_id: op_id, + approve: true, + signature: sig.to_bytes(), + }) + .await + .unwrap(); + + assert_eq!(outcome, VoteOutcome::Approved); +} + +#[tokio::test] +async fn key_rotation_requires_full_quorum() { + // §3.3: ReplaceOperator needs all 3 operators to approve, not just shamir_threshold(3)=2 + let db = db::create_test_pool().await; + let actors = spawn_actors(db.clone()).await; + actors + .vault + .ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }) + .await + .unwrap(); + + let key1 = authn::SigningKey::generate(); + let key2 = authn::SigningKey::generate(); + let key3 = authn::SigningKey::generate(); + let op1 = register_operator(&db, &key1.public_key()).await; + let op2 = register_operator(&db, &key2.public_key()).await; + let op3 = register_operator(&db, &key3.public_key()).await; + + let new_pubkey = authn::SigningKey::generate().public_key().to_bytes(); + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ReplaceOperator(replace_operator::Settings { + old_operator_id: OperatorIdentityId::from_raw(1), + new_pubkey, + }), + initiator_id: op1, + ttl_secs: None, + }) + .await + .unwrap(); + + let cast = |op_id, key: &authn::SigningKey| { + let actors = actors.clone(); + let sig = key + .sign_message(&make_vote_message(proposal_id, true), SigningContext::GovernanceVote) + .unwrap(); + async move { + actors + .proposal_manager + .ask(CastVote { proposal_id, operator_id: op_id, approve: true, signature: sig.to_bytes() }) + .await + .unwrap() + } + }; + + // With shamir_threshold(3)=2, two approvals would suffice for a normal proposal. + // For key rotation, they must not. + assert_eq!(cast(op1, &key1).await, VoteOutcome::Pending); + assert_eq!(cast(op2, &key2).await, VoteOutcome::Pending); + assert_eq!(cast(op3, &key3).await, VoteOutcome::Approved); +} + +// ─── §3.5 / §3.6 Recovery Operator tests ────────────────────────────────── + +#[tokio::test] +async fn recovery_vote_rejected_when_sleeping() { + let db = db::create_test_pool().await; + let actors = spawn_actors(db.clone()).await; + actors.vault.ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }).await.unwrap(); + + let op_key = authn::SigningKey::generate(); + let op_id = register_operator(&db, &op_key.public_key()).await; + let rec_key = authn::SigningKey::generate(); + let rec_id = register_recovery_operator(&db, &rec_key.public_key()).await; + + let new_pubkey = authn::SigningKey::generate().public_key().to_bytes(); + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ReplaceOperator(replace_operator::Settings { + old_operator_id: OperatorIdentityId::from_raw(1), + new_pubkey, + }), + initiator_id: op_id, + ttl_secs: None, + }) + .await + .unwrap(); + + let msg = make_vote_message(proposal_id, true); + let sig = rec_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); + let err = actors + .proposal_manager + .ask(CastRecoveryVote { + proposal_id, + recovery_operator_id: rec_id, + approve: true, + signature: sig.to_bytes(), + }) + .await + .unwrap_err(); + + assert!( + matches!(err, kameo::error::SendError::HandlerError(ProposalError::RecoveryNotActive)), + "expected RecoveryNotActive, got {err:?}" + ); +} + +#[tokio::test] +async fn recovery_vote_blocked_on_non_replace_proposal() { + let db = db::create_test_pool().await; + let actors = spawn_actors(db.clone()).await; + actors.vault.ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }).await.unwrap(); + + let op_key = authn::SigningKey::generate(); + let op_id = register_operator(&db, &op_key.public_key()).await; + let rec_key = authn::SigningKey::generate(); + let rec_id = register_recovery_operator(&db, &rec_key.public_key()).await; + + insert_active_wakeup(&db, op_id).await; + + let client_key = authn::SigningKey::generate(); + let client_id = insert_unapproved_client(&db, &client_key.public_key()).await; + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id }), + initiator_id: op_id, + ttl_secs: None, + }) + .await + .unwrap(); + + let msg = make_vote_message(proposal_id, true); + let sig = rec_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); + let err = actors + .proposal_manager + .ask(CastRecoveryVote { + proposal_id, + recovery_operator_id: rec_id, + approve: true, + signature: sig.to_bytes(), + }) + .await + .unwrap_err(); + + assert!( + matches!( + err, + kameo::error::SendError::HandlerError(ProposalError::NotAllowedForRecoveryOperator) + ), + "expected NotAllowedForRecoveryOperator, got {err:?}" + ); +} + +#[tokio::test] +async fn recovery_wakeup_can_be_cancelled() { + let db = db::create_test_pool().await; + let actors = spawn_actors(db.clone()).await; + actors.vault.ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }).await.unwrap(); + + let key = authn::SigningKey::generate(); + let op_id = register_operator(&db, &key.public_key()).await; + + actors + .proposal_manager + .ask(RequestRecoveryWakeup { operator_id: op_id }) + .await + .unwrap(); + + actors + .proposal_manager + .ask(CancelRecoveryWakeup { operator_id: op_id }) + .await + .unwrap(); + + // Second request must succeed (previous one was cancelled) + actors + .proposal_manager + .ask(RequestRecoveryWakeup { operator_id: op_id }) + .await + .unwrap(); +} + +#[tokio::test] +async fn recovery_wakeup_prevents_duplicate_request() { + let db = db::create_test_pool().await; + let actors = spawn_actors(db.clone()).await; + actors.vault.ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }).await.unwrap(); + + let key = authn::SigningKey::generate(); + let op_id = register_operator(&db, &key.public_key()).await; + + actors + .proposal_manager + .ask(RequestRecoveryWakeup { operator_id: op_id }) + .await + .unwrap(); + + let err = actors + .proposal_manager + .ask(RequestRecoveryWakeup { operator_id: op_id }) + .await + .unwrap_err(); + + assert!( + matches!(err, kameo::error::SendError::HandlerError(ProposalError::WakeupAlreadyPending)), + "expected WakeupAlreadyPending, got {err:?}" + ); +} + +#[tokio::test] +async fn recovery_operator_vote_contributes_to_replace_quorum() { + // 1 ordinary operator + 1 recovery operator; replace_operator needs both. + let db = db::create_test_pool().await; + let actors = spawn_actors(db.clone()).await; + actors.vault.ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }).await.unwrap(); + + let op_key = authn::SigningKey::generate(); + let op_id = register_operator(&db, &op_key.public_key()).await; + let rec_key = authn::SigningKey::generate(); + let rec_id = register_recovery_operator(&db, &rec_key.public_key()).await; + + insert_active_wakeup(&db, op_id).await; + + let new_pubkey = authn::SigningKey::generate().public_key().to_bytes(); + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ReplaceOperator(replace_operator::Settings { + old_operator_id: OperatorIdentityId::from_raw(1), + new_pubkey, + }), + initiator_id: op_id, + ttl_secs: None, + }) + .await + .unwrap(); + + // Ordinary operator approves — still pending (needs recovery too) + let msg = make_vote_message(proposal_id, true); + let sig = op_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); + let outcome = actors + .proposal_manager + .ask(CastVote { + proposal_id, + operator_id: op_id, + approve: true, + signature: sig.to_bytes(), + }) + .await + .unwrap(); + assert_eq!(outcome, VoteOutcome::Pending); + + // Recovery operator approves — now quorum is reached + let sig = rec_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); + let outcome = actors + .proposal_manager + .ask(CastRecoveryVote { + proposal_id, + recovery_operator_id: rec_id, + approve: true, + signature: sig.to_bytes(), + }) + .await + .unwrap(); + assert_eq!(outcome, VoteOutcome::Approved); +} diff --git a/server/crates/arbiter-server/tests/operator/auth.rs b/server/crates/arbiter-server/tests/operator/auth.rs index 433c03d..7444afb 100644 --- a/server/crates/arbiter-server/tests/operator/auth.rs +++ b/server/crates/arbiter-server/tests/operator/auth.rs @@ -1,14 +1,16 @@ -use super::common::ChannelTransport; -use arbiter_crypto::{ - authn::{self, AuthChallenge, OPERATOR_CONTEXT}, - safecell::{SafeCell, SafeCellHandle as _}, -}; +use super::common::{ChannelTransport, bootstrapped_vault, eventually, spawn_actors}; +use arbiter_crypto::authn::{self, AuthChallenge, SigningContext}; use arbiter_proto::transport::{Error as TransportError, Receiver, Sender}; use arbiter_server::{ - actors::{GlobalActors, bootstrap::GetToken, vault::Bootstrap}, + actors::{ + bootstrap::GetToken, + vault::{self, Bootstrap}, + }, crypto::integrity, db::{self, schema}, - peers::operator::{self, Credentials, OperatorConnection, auth, vault_gate}, + peers::operator::{ + self, AuthenticatedOperator, Credentials, OperatorConnection, auth, vault_gate, + }, }; use async_trait::async_trait; @@ -27,7 +29,7 @@ fn sign_operator_challenge( ) -> authn::Signature { let challenge = challenge.format(); key.signing_key() - .sign_deterministic(&challenge, OPERATOR_CONTEXT) + .sign_deterministic(&challenge, SigningContext::Operator.as_bytes()) .unwrap() .into() } @@ -153,14 +155,7 @@ impl Sender for StartTestTransport { #[test_log::test] pub async fn bootstrap_token_auth() { let db = db::create_test_pool().await; - let actors = GlobalActors::spawn(db.clone()).await.unwrap(); - actors - .vault - .ask(Bootstrap { - seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()), - }) - .await - .unwrap(); + let actors = spawn_actors(db.clone()).await; let token = actors.bootstrapper.ask(GetToken).await.unwrap().unwrap(); let (mut server_transport, mut test_transport) = ChannelTransport::new(); @@ -203,22 +198,156 @@ pub async fn bootstrap_token_auth() { .expect("should receive auth result"); assert!(matches!(response, Ok(auth::Outbound::AuthSuccess))); - task.await.unwrap().unwrap(); + let authenticated = task.await.unwrap().unwrap(); let mut conn = db.get().await.unwrap(); - let stored_pubkey: Vec = schema::operator_identity::table - .select(schema::operator_identity::public_key) - .first::>(&mut conn) + let (stored_id, stored_pubkey): (i32, Vec) = schema::operator_identity::table + .select(( + schema::operator_identity::id, + schema::operator_identity::public_key, + )) + .first::<(i32, Vec)>(&mut conn) .await .unwrap(); assert_eq!(stored_pubkey, verifying_key(&new_key).encode().0.to_vec()); + + // A key registered through the bootstrap token is an ordinary operator, carrying the id its + // registration wrote. Asserted here because this is the file's only bootstrap-arm check on + // what `authenticate` actually returns. + match authenticated { + AuthenticatedOperator::Ordinary(creds) => assert_eq!(creds.id, stored_id), + AuthenticatedOperator::Recovery(creds) => panic!( + "expected the ordinary role, got a recovery operator with id {}", + creds.id + ), + } } +/// A multi-operator committee must all register with the same bootstrap token before bootstrap +/// completes, so verifying the token must not consume it. This is the reachability bug fixed by +/// replacing `consume_token` with `verify_token`. #[tokio::test] #[test_log::test] -pub async fn bootstrap_invalid_token_auth() { +pub async fn bootstrap_token_registers_every_committee_member() { let db = db::create_test_pool().await; - let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let actors = spawn_actors(db.clone()).await; + let token = actors.bootstrapper.ask(GetToken).await.unwrap().unwrap(); + + for _ in 0..2 { + let (mut server_transport, mut test_transport) = ChannelTransport::new(); + let db_for_task = db.clone(); + let actors_for_task = actors.clone(); + let task = tokio::spawn(async move { + let mut props = OperatorConnection::new(db_for_task, actors_for_task); + auth::authenticate(&mut props, &mut server_transport).await + }); + + let new_key = MlDsa87::key_gen(&mut rand::rng()); + test_transport + .send(auth::Inbound::AuthChallengeRequest { + pubkey: verifying_key(&new_key).into(), + bootstrap_token: Some(token.clone()), + }) + .await + .unwrap(); + + let response = test_transport + .recv() + .await + .expect("should receive challenge"); + let challenge = match response { + Ok(auth::Outbound::AuthChallenge { challenge }) => challenge, + other => panic!("Expected AuthChallenge, got {other:?}"), + }; + + let signature = sign_operator_challenge(&new_key, &challenge); + test_transport + .send(auth::Inbound::AuthChallengeSolution { + signature: signature.to_bytes(), + }) + .await + .unwrap(); + + let response = test_transport + .recv() + .await + .expect("should receive auth result"); + assert!(matches!(response, Ok(auth::Outbound::AuthSuccess))); + + task.await.unwrap().unwrap(); + } + + let mut conn = db.get().await.unwrap(); + let registered: i64 = schema::operator_identity::table + .count() + .get_result(&mut conn) + .await + .unwrap(); + assert_eq!(registered, 2); + + // Bootstrap has not completed: the token must still be valid. + assert_eq!( + actors.bootstrapper.ask(GetToken).await.unwrap(), + Some(token) + ); +} + +/// `GlobalActors` must subscribe `Bootstrapper` to `events::Bootstrapped` on the message bus. +/// Without that one registration the token stays valid forever in production, which is the +/// defect this task exists to fix, and no other test notices: the test below drives the event +/// handler directly, and the `challenge_auth` family never re-reads the token after +/// bootstrapping. This one goes the whole way round -- real `Vault::bootstrap`, real bus -- +/// and waits for the effect rather than reading straight after the call, because `Publish` +/// only enqueues to the bus's mailbox. +#[tokio::test] +#[test_log::test] +pub async fn bootstrapped_event_retires_the_token_through_the_message_bus() { + let db = db::create_test_pool().await; + let actors = spawn_actors(db.clone()).await; + + assert!( + actors.bootstrapper.ask(GetToken).await.unwrap().is_some(), + "the token must exist before the vault is bootstrapped" + ); + + actors + .vault + .ask(Bootstrap { + seal_key: arbiter_server::crypto::KeyCell::from([0u8; 32]), + }) + .await + .unwrap(); + + eventually("the bootstrap token to be retired", || async { + actors + .bootstrapper + .ask(GetToken) + .await + .unwrap() + .is_none() + .then_some(()) + }) + .await; +} + +/// Once the vault reports `Bootstrapped`, the token is retired: further registrations must be +/// rejected even with a token that verified successfully moments earlier. +#[tokio::test] +#[test_log::test] +pub async fn bootstrap_token_rejected_after_bootstrapped_event() { + let db = db::create_test_pool().await; + let actors = spawn_actors(db.clone()).await; + let token = actors.bootstrapper.ask(GetToken).await.unwrap().unwrap(); + + // Drive the Bootstrapper's own event handler directly rather than through + // `actors.vault.ask(Bootstrap { .. })` + the message bus: bus delivery is fire-and-forget, + // so asserting on it would be racy. The handler under test is the same either way. + actors + .bootstrapper + .ask(vault::events::Bootstrapped) + .await + .unwrap(); + assert!(actors.bootstrapper.ask(GetToken).await.unwrap().is_none()); let (mut server_transport, mut test_transport) = ChannelTransport::new(); let db_for_task = db.clone(); @@ -231,7 +360,7 @@ pub async fn bootstrap_invalid_token_auth() { test_transport .send(auth::Inbound::AuthChallengeRequest { pubkey: verifying_key(&new_key).into(), - bootstrap_token: Some("invalid_token".to_owned()), + bootstrap_token: Some(token), }) .await .unwrap(); @@ -267,15 +396,154 @@ pub async fn bootstrap_invalid_token_auth() { assert_eq!(count, 0); } +/// `register_key`'s database gate must refuse a registration once `arbiter_settings.root_key_id` +/// is set, even when `Bootstrapper`'s own in-memory token has not yet been retired -- exactly +/// the two-mailbox-hop window between `Vault::bootstrap`'s commit and the `Bootstrapped` event +/// reaching `Bootstrapper` in production. "database bootstrapped, Bootstrapper not yet notified" +/// is reproduced deterministically by bootstrapping a throwaway `Vault` wired to its own message +/// bus: it commits `root_key_id` in the same database without ever publishing to the bus +/// `actors.bootstrapper` is registered on, so `actors.bootstrapper`'s token is left untouched. +#[tokio::test] +#[test_log::test] +pub async fn bootstrap_token_rejected_once_the_database_is_bootstrapped() { + let db = db::create_test_pool().await; + let actors = spawn_actors(db.clone()).await; + let token = actors.bootstrapper.ask(GetToken).await.unwrap().unwrap(); + + bootstrapped_vault(&db).await; + + // From Bootstrapper's point of view the token still verifies: it never received an event. + assert_eq!( + actors.bootstrapper.ask(GetToken).await.unwrap(), + Some(token.clone()) + ); + + let (mut server_transport, mut test_transport) = ChannelTransport::new(); + let db_for_task = db.clone(); + let task = tokio::spawn(async move { + let mut props = OperatorConnection::new(db_for_task, actors); + auth::authenticate(&mut props, &mut server_transport).await + }); + + let new_key = MlDsa87::key_gen(&mut rand::rng()); + test_transport + .send(auth::Inbound::AuthChallengeRequest { + pubkey: verifying_key(&new_key).into(), + bootstrap_token: Some(token), + }) + .await + .unwrap(); + + let response = test_transport + .recv() + .await + .expect("should receive challenge"); + let challenge = match response { + Ok(auth::Outbound::AuthChallenge { challenge }) => challenge, + other => panic!("Expected AuthChallenge, got {other:?}"), + }; + + let signature = sign_operator_challenge(&new_key, &challenge); + test_transport + .send(auth::Inbound::AuthChallengeSolution { + signature: signature.to_bytes(), + }) + .await + .unwrap(); + + // The refusal has to reach the peer, not just the task's return value: a registration + // refused after the database was bootstrapped must look like the refusal of a token that + // never verified, rather than a handshake that stops with nothing on the wire. + let refusal = test_transport + .recv() + .await + .expect("the refusal must be sent to the peer"); + assert!(matches!(refusal, Err(auth::Error::InvalidBootstrapToken))); + + assert!(matches!( + task.await.unwrap(), + Err(auth::Error::InvalidBootstrapToken) + )); + + let mut conn = db.get().await.unwrap(); + let count: i64 = schema::operator_identity::table + .count() + .get_result::(&mut conn) + .await + .unwrap(); + assert_eq!(count, 0); +} + +#[tokio::test] +#[test_log::test] +pub async fn bootstrap_invalid_token_auth() { + let db = db::create_test_pool().await; + let actors = spawn_actors(db.clone()).await; + + let (mut server_transport, mut test_transport) = ChannelTransport::new(); + let db_for_task = db.clone(); + let task = tokio::spawn(async move { + let mut props = OperatorConnection::new(db_for_task, actors); + auth::authenticate(&mut props, &mut server_transport).await + }); + + let new_key = MlDsa87::key_gen(&mut rand::rng()); + test_transport + .send(auth::Inbound::AuthChallengeRequest { + pubkey: verifying_key(&new_key).into(), + bootstrap_token: Some("invalid_token".to_owned()), + }) + .await + .unwrap(); + + let response = test_transport + .recv() + .await + .expect("should receive challenge"); + let challenge = match response { + Ok(auth::Outbound::AuthChallenge { challenge }) => challenge, + other => panic!("Expected AuthChallenge, got {other:?}"), + }; + + let signature = sign_operator_challenge(&new_key, &challenge); + test_transport + .send(auth::Inbound::AuthChallengeSolution { + signature: signature.to_bytes(), + }) + .await + .unwrap(); + + // The reference behaviour the refusal above has to match: a token that never verified is + // reported to the peer. Pinned here so the two refusal paths cannot drift apart again. + let refusal = test_transport + .recv() + .await + .expect("the refusal must be sent to the peer"); + assert!(matches!(refusal, Err(auth::Error::InvalidBootstrapToken))); + + assert!(matches!( + task.await.unwrap(), + Err(auth::Error::InvalidBootstrapToken) + )); + + let mut conn = db.get().await.unwrap(); + let count: i64 = schema::operator_identity::table + .count() + .get_result::(&mut conn) + .await + .unwrap(); + assert_eq!(count, 0); +} + #[tokio::test] #[test_log::test] pub async fn challenge_auth() { let db = db::create_test_pool().await; - let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let actors = spawn_actors(db.clone()).await; actors .vault .ask(Bootstrap { - seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()), + seal_key: arbiter_server::crypto::KeyCell::from([0u8; 32]), }) .await .unwrap(); @@ -356,12 +624,12 @@ pub async fn challenge_auth() { #[test_log::test] pub async fn challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() { let db = db::create_test_pool().await; - let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let actors = spawn_actors(db.clone()).await; actors .vault .ask(Bootstrap { - seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()), + seal_key: arbiter_server::crypto::KeyCell::from([0u8; 32]), }) .await .unwrap(); @@ -430,11 +698,11 @@ pub async fn challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() { #[test_log::test] pub async fn challenge_auth_rejects_invalid_signature() { let db = db::create_test_pool().await; - let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let actors = spawn_actors(db.clone()).await; actors .vault .ask(Bootstrap { - seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()), + seal_key: arbiter_server::crypto::KeyCell::from([0u8; 32]), }) .await .unwrap(); @@ -506,3 +774,230 @@ pub async fn challenge_auth_rejects_invalid_signature() { Err(auth::Error::InvalidChallengeSolution) )); } + +/// §3.5: a recovery operator is a separate peer type. Its key resolves against +/// `recovery_operator_identity`, and authentication reports the recovery role. +/// +/// An ordinary operator is registered alongside it so the recovery key is not simply the only +/// key on file: the handshake has to reach the recovery table while `operator_identity` is +/// populated. Both tables autoincrement from 1, so the fixture also pushes the authenticating +/// recovery operator to id 2 -- with one row in each table an id taken from the wrong table +/// would still read as 1, and only the variant would be under test. +#[tokio::test] +#[test_log::test] +pub async fn recovery_operator_authenticates_with_its_own_identity() { + let db = db::create_test_pool().await; + let actors = spawn_actors(db.clone()).await; + + let ordinary_key = MlDsa87::key_gen(&mut rand::rng()); + let other_recovery_key = MlDsa87::key_gen(&mut rand::rng()); + let recovery_key = MlDsa87::key_gen(&mut rand::rng()); + let recovery_pubkey_bytes = authn::PublicKey::from(verifying_key(&recovery_key)).to_bytes(); + + let recovery_id: i32 = { + let mut conn = db.get().await.unwrap(); + insert_into(schema::operator_identity::table) + .values((schema::operator_identity::public_key + .eq(authn::PublicKey::from(verifying_key(&ordinary_key)).to_bytes()),)) + .execute(&mut conn) + .await + .unwrap(); + insert_into(schema::recovery_operator_identity::table) + .values((schema::recovery_operator_identity::public_key + .eq(authn::PublicKey::from(verifying_key(&other_recovery_key)).to_bytes()),)) + .execute(&mut conn) + .await + .unwrap(); + insert_into(schema::recovery_operator_identity::table) + .values((schema::recovery_operator_identity::public_key.eq(recovery_pubkey_bytes),)) + .returning(schema::recovery_operator_identity::id) + .get_result(&mut conn) + .await + .unwrap() + }; + assert_eq!( + recovery_id, 2, + "the fixture must give the authenticating recovery operator an id no ordinary \ + operator holds, or the id assertion below cannot discriminate" + ); + + let (mut server_transport, mut test_transport) = ChannelTransport::new(); + let db_for_task = db.clone(); + let task = tokio::spawn(async move { + let mut props = OperatorConnection::new(db_for_task, actors); + auth::authenticate(&mut props, &mut server_transport).await + }); + + test_transport + .send(auth::Inbound::AuthChallengeRequest { + pubkey: verifying_key(&recovery_key).into(), + bootstrap_token: None, + }) + .await + .unwrap(); + + let response = test_transport + .recv() + .await + .expect("should receive challenge"); + let challenge = match response { + Ok(auth::Outbound::AuthChallenge { challenge }) => challenge, + other => panic!("Expected AuthChallenge, got {other:?}"), + }; + + let signature = sign_operator_challenge(&recovery_key, &challenge); + test_transport + .send(auth::Inbound::AuthChallengeSolution { + signature: signature.to_bytes(), + }) + .await + .unwrap(); + + let response = test_transport + .recv() + .await + .expect("should receive auth result"); + assert!(matches!(response, Ok(auth::Outbound::AuthSuccess))); + + let authenticated = task + .await + .unwrap() + .expect("recovery operator should authenticate"); + match authenticated { + AuthenticatedOperator::Recovery(creds) => assert_eq!(creds.id, recovery_id), + AuthenticatedOperator::Ordinary(creds) => panic!( + "expected the recovery role, got the ordinary operator with id {}", + creds.id + ), + } +} + +/// A key present in neither identity table is still rejected: accepting a key found in either +/// table must not degrade into accepting any key at all. Both tables hold a row so the refusal +/// cannot come from an empty lookup. +#[tokio::test] +#[test_log::test] +pub async fn unknown_key_is_rejected_when_both_tables_are_populated() { + let db = db::create_test_pool().await; + let actors = spawn_actors(db.clone()).await; + + let ordinary_key = MlDsa87::key_gen(&mut rand::rng()); + let recovery_key = MlDsa87::key_gen(&mut rand::rng()); + { + let mut conn = db.get().await.unwrap(); + insert_into(schema::operator_identity::table) + .values((schema::operator_identity::public_key + .eq(authn::PublicKey::from(verifying_key(&ordinary_key)).to_bytes()),)) + .execute(&mut conn) + .await + .unwrap(); + insert_into(schema::recovery_operator_identity::table) + .values((schema::recovery_operator_identity::public_key + .eq(authn::PublicKey::from(verifying_key(&recovery_key)).to_bytes()),)) + .execute(&mut conn) + .await + .unwrap(); + } + + let (mut server_transport, mut test_transport) = ChannelTransport::new(); + let db_for_task = db.clone(); + let task = tokio::spawn(async move { + let mut props = OperatorConnection::new(db_for_task, actors); + auth::authenticate(&mut props, &mut server_transport).await + }); + + let unknown_key = MlDsa87::key_gen(&mut rand::rng()); + test_transport + .send(auth::Inbound::AuthChallengeRequest { + pubkey: verifying_key(&unknown_key).into(), + bootstrap_token: None, + }) + .await + .unwrap(); + + assert!(matches!( + task.await.unwrap(), + Err(auth::Error::UnregisteredPublicKey) + )); +} + +/// `verify_solution` resolves the recovery id only after the peer has sent its solution, so a +/// recovery row removed between challenge and solution reaches that refusal. It has to be sent +/// on the transport, like the `InvalidBootstrapToken` refusals in the arm above: an operator +/// that has answered the challenge sees a protocol error rather than a handshake that stops +/// with nothing on the wire. +#[tokio::test] +#[test_log::test] +pub async fn recovery_key_removed_mid_handshake_is_refused_on_the_wire() { + let db = db::create_test_pool().await; + let actors = spawn_actors(db.clone()).await; + + let recovery_key = MlDsa87::key_gen(&mut rand::rng()); + let recovery_pubkey_bytes = authn::PublicKey::from(verifying_key(&recovery_key)).to_bytes(); + { + let mut conn = db.get().await.unwrap(); + insert_into(schema::recovery_operator_identity::table) + .values(( + schema::recovery_operator_identity::public_key.eq(recovery_pubkey_bytes.clone()), + )) + .execute(&mut conn) + .await + .unwrap(); + } + + let (mut server_transport, mut test_transport) = ChannelTransport::new(); + let db_for_task = db.clone(); + let task = tokio::spawn(async move { + let mut props = OperatorConnection::new(db_for_task, actors); + auth::authenticate(&mut props, &mut server_transport).await + }); + + test_transport + .send(auth::Inbound::AuthChallengeRequest { + pubkey: verifying_key(&recovery_key).into(), + bootstrap_token: None, + }) + .await + .unwrap(); + + let response = test_transport + .recv() + .await + .expect("should receive challenge"); + let challenge = match response { + Ok(auth::Outbound::AuthChallenge { challenge }) => challenge, + other => panic!("Expected AuthChallenge, got {other:?}"), + }; + + // The challenge has been issued and the solution has not been sent, so the server cannot + // have read the table again yet: the row is gone by the time `verify_solution` looks. + { + let mut conn = db.get().await.unwrap(); + diesel::delete( + schema::recovery_operator_identity::table + .filter(schema::recovery_operator_identity::public_key.eq(recovery_pubkey_bytes)), + ) + .execute(&mut conn) + .await + .unwrap(); + } + + let signature = sign_operator_challenge(&recovery_key, &challenge); + test_transport + .send(auth::Inbound::AuthChallengeSolution { + signature: signature.to_bytes(), + }) + .await + .unwrap(); + + let refusal = test_transport + .recv() + .await + .expect("the refusal must be sent to the peer"); + assert!(matches!(refusal, Err(auth::Error::UnregisteredPublicKey))); + + assert!(matches!( + task.await.unwrap(), + Err(auth::Error::UnregisteredPublicKey) + )); +} diff --git a/server/crates/arbiter-server/tests/operator/unseal.rs b/server/crates/arbiter-server/tests/operator/unseal.rs index 365e6ec..4d3b8cb 100644 --- a/server/crates/arbiter-server/tests/operator/unseal.rs +++ b/server/crates/arbiter-server/tests/operator/unseal.rs @@ -1,15 +1,10 @@ -use arbiter_crypto::{ - authn, - safecell::{SafeCell, SafeCellHandle as _}, -}; +use super::common::spawn_actors; +use arbiter_crypto::authn; use arbiter_server::{ - actors::{ - GlobalActors, - vault::{Bootstrap, Seal}, - }, + actors::vault::{Bootstrap, Seal}, db, peers::operator::{ - Credentials, + AuthenticatedOperator, Credentials, vault_gate::{ Error as VaultGateError, HandleHandshake, HandleUnsealEncryptedKey, VaultGate, }, @@ -22,19 +17,19 @@ use tokio::sync::oneshot; use x25519_dalek::{EphemeralSecret, PublicKey}; async fn setup_sealed_gate( - seal_key: &[u8], + seal_key: &[u8; 32], ) -> ( db::DatabasePool, kameo::actor::ActorRef, oneshot::Receiver>, ) { let db = db::create_test_pool().await; - let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let actors = spawn_actors(db.clone()).await; actors .vault .ask(Bootstrap { - seal_key_raw: SafeCell::new(seal_key.to_vec()), + seal_key: arbiter_server::crypto::KeyCell::from(*seal_key), }) .await .unwrap(); @@ -42,7 +37,7 @@ async fn setup_sealed_gate( let (promotion_tx, promotion_rx) = oneshot::channel(); let pubkey = authn::SigningKey::generate().public_key(); - let auth_creds = Credentials { id: 1, pubkey }; + let auth_creds = AuthenticatedOperator::Ordinary(Credentials { id: 1, pubkey }); let gate = VaultGate::spawn(VaultGate::new(auth_creds, actors, db.clone(), promotion_tx)); (db, gate, promotion_rx) @@ -50,7 +45,7 @@ async fn setup_sealed_gate( async fn client_dh_encrypt( gate: &kameo::actor::ActorRef, - key_to_send: &[u8], + key_to_send: &[u8; 32], ) -> HandleUnsealEncryptedKey { let client_secret = EphemeralSecret::random(); let client_public = PublicKey::from(&client_secret); @@ -83,7 +78,7 @@ async fn client_dh_encrypt( #[tokio::test] #[test_log::test] pub async fn unseal_success() { - let seal_key = b"test-seal-key"; + let seal_key = b"test-seal-key-padded-to-32bytes!"; let (_db, gate, _promotion_rx) = setup_sealed_gate(seal_key).await; let encrypted_key = client_dh_encrypt(&gate, seal_key).await; @@ -95,10 +90,10 @@ pub async fn unseal_success() { #[tokio::test] #[test_log::test] pub async fn unseal_wrong_seal_key() { - let seal_key = b"test-seal-key"; + let seal_key = b"test-seal-key-padded-to-32bytes!"; let (_db, gate, _promotion_rx) = setup_sealed_gate(seal_key).await; - let encrypted_key = client_dh_encrypt(&gate, b"wrong-key").await; + let encrypted_key = client_dh_encrypt(&gate, b"wrong-key-padded-to-32-bytes!!!!").await; let response = gate.ask(encrypted_key).await; assert!(matches!( @@ -112,7 +107,7 @@ pub async fn unseal_wrong_seal_key() { #[tokio::test] #[test_log::test] pub async fn unseal_corrupted_ciphertext() { - let seal_key = b"test-seal-key"; + let seal_key = b"test-seal-key-padded-to-32bytes!"; let (_db, gate, _promotion_rx) = setup_sealed_gate(seal_key).await; let client_secret = EphemeralSecret::random(); @@ -143,11 +138,11 @@ pub async fn unseal_corrupted_ciphertext() { #[tokio::test] #[test_log::test] pub async fn unseal_retry_after_invalid_key() { - let seal_key = b"real-seal-key"; + let seal_key = b"real-seal-key-padded-to-32bytes!"; let (_db, gate, _promotion_rx) = setup_sealed_gate(seal_key).await; { - let encrypted_key = client_dh_encrypt(&gate, b"wrong-key").await; + let encrypted_key = client_dh_encrypt(&gate, b"wrong-key-padded-to-32-bytes!!!!").await; let response = gate.ask(encrypted_key).await; assert!(matches!( diff --git a/server/crates/arbiter-server/tests/vault/concurrency.rs b/server/crates/arbiter-server/tests/vault/concurrency.rs index ee84f4a..4e94fa9 100644 --- a/server/crates/arbiter-server/tests/vault/concurrency.rs +++ b/server/crates/arbiter-server/tests/vault/concurrency.rs @@ -166,7 +166,7 @@ async fn decrypt_roundtrip_after_high_concurrency() { .await .unwrap(); decryptor - .try_unseal(SafeCell::new(b"test-seal-key".to_vec())) + .try_unseal(arbiter_server::crypto::KeyCell::from([0u8; 32])) .await .unwrap(); diff --git a/server/crates/arbiter-server/tests/vault/lifecycle.rs b/server/crates/arbiter-server/tests/vault/lifecycle.rs index 25017c4..42cc971 100644 --- a/server/crates/arbiter-server/tests/vault/lifecycle.rs +++ b/server/crates/arbiter-server/tests/vault/lifecycle.rs @@ -1,16 +1,36 @@ use crate::common; -use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; +use arbiter_crypto::{ + authn, + safecell::{SafeCell, SafeCellHandle as _}, +}; use arbiter_server::{ actors::{ GlobalActors, - vault::{Error, Vault}, + vault::{Error, GetState, Vault, VaultState}, + vault_coordinator::{ + ContributeBootstrap, ContributeRecoveryBootstrap, ContributeRecoveryUnseal, + ContributeUnseal, Error as CoordinatorError, StartBootstrap, VaultCoordinator, + }, }, - crypto::encryption::v1::{Nonce, ROOT_KEY_TAG}, + crypto::{KeyCell, encryption::v1::{Nonce, ROOT_KEY_TAG}}, db::{self, models, schema}, + peers::operator::{ + AuthenticatedOperator, Credentials, RecoveryCredentials, + vault_gate::{ + Error as VaultGateError, HandleBootstrapEncryptedKey, + HandleContributeBootstrapPassphrase, HandleContributeRecoveryBootstrapPassphrase, + HandleContributeRecoveryUnsealPassphrase, HandleContributeUnsealPassphrase, + HandleDeclareCommittee, HandleHandshake, VaultGate, + }, + }, }; -use diesel::{QueryDsl, SelectableHelper}; +use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit}; +use diesel::{ExpressionMethods, QueryDsl, SelectableHelper, insert_into, sql_query}; use diesel_async::RunQueryDsl; +use kameo::actor::Spawn as _; +use tokio::sync::oneshot; +use x25519_dalek::{EphemeralSecret, PublicKey}; #[tokio::test] #[test_log::test] @@ -20,7 +40,7 @@ async fn test_bootstrap() { .await .unwrap(); - let seal_key = SafeCell::new(b"test-seal-key".to_vec()); + let seal_key = KeyCell::from([0u8; 32]); actor.bootstrap(seal_key).await.unwrap(); let mut conn = db.get().await.unwrap(); @@ -43,7 +63,7 @@ async fn test_bootstrap_rejects_double() { let db = db::create_test_pool().await; let mut actor = common::bootstrapped_vault(&db).await; - let seal_key2 = SafeCell::new(b"test-seal-key".to_vec()); + let seal_key2 = KeyCell::from([0u8; 32]); let err = actor.bootstrap(seal_key2).await.unwrap_err(); assert!(matches!(err, Error::AlreadyBootstrapped)); } @@ -105,7 +125,7 @@ async fn test_unseal_correct_password() { let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus()) .await .unwrap(); - let seal_key = SafeCell::new(b"test-seal-key".to_vec()); + let seal_key = KeyCell::from([0u8; 32]); actor.try_unseal(seal_key).await.unwrap(); let mut decrypted = actor.decrypt(aead_id).await.unwrap(); @@ -129,13 +149,674 @@ async fn test_unseal_wrong_then_correct_password() { .await .unwrap(); - let bad_key = SafeCell::new(b"wrong-password".to_vec()); + let bad_key = KeyCell::from([1u8; 32]); let err = actor.try_unseal(bad_key).await.unwrap_err(); assert!(matches!(err, Error::InvalidKey)); - let good_key = SafeCell::new(b"test-seal-key".to_vec()); + let good_key = KeyCell::from([0u8; 32]); actor.try_unseal(good_key).await.unwrap(); let mut decrypted = actor.decrypt(aead_id).await.unwrap(); assert_eq!(*decrypted.read(), plaintext); } + +#[tokio::test] +#[test_log::test] +async fn two_operator_vault_requires_recovery_share() { + let db = db::create_test_pool().await; + let bus = GlobalActors::spawn_message_bus(); + let vault_ref = Vault::spawn(Vault::new(db.clone(), bus).await.unwrap()); + let coordinator = VaultCoordinator::spawn(VaultCoordinator::new(db, vault_ref)); + + let err = coordinator + .ask(StartBootstrap { + operator_id: 1, + declared_count: 2, + recovery_count: 0, + }) + .await + .unwrap_err(); + + assert!( + matches!( + err, + kameo::error::SendError::HandlerError(CoordinatorError::TwoOperatorsRequireRecovery) + ), + "expected TwoOperatorsRequireRecovery, got {err:?}" + ); +} + +/// §3.4: Bootstrap with 1 ordinary + 1 recovery operator produces a valid 1-of-2 Shamir split. +/// Both ordinary and recovery shares are stored; the vault can be unsealed with either one. +#[tokio::test] +#[test_log::test] +async fn recovery_share_stored_and_used_for_unseal() { + let db = db::create_test_pool().await; + let bus = GlobalActors::spawn_message_bus(); + let vault_ref = Vault::spawn(Vault::new(db.clone(), bus).await.unwrap()); + let coordinator = VaultCoordinator::spawn(VaultCoordinator::new(db.clone(), vault_ref.clone())); + + // Register one ordinary operator and one recovery operator in the DB + let ordinary_id: i32 = { + let mut conn = db.get().await.unwrap(); + insert_into(schema::operator_identity::table) + .values(schema::operator_identity::public_key.eq(vec![1u8; 32])) + .returning(schema::operator_identity::id) + .get_result(&mut conn) + .await + .unwrap() + }; + let recovery_id: i32 = { + let mut conn = db.get().await.unwrap(); + insert_into(schema::recovery_operator_identity::table) + .values(schema::recovery_operator_identity::public_key.eq(vec![2u8; 32])) + .returning(schema::recovery_operator_identity::id) + .get_result(&mut conn) + .await + .unwrap() + }; + + // Declare committee: 1 ordinary + 1 recovery + coordinator + .ask(StartBootstrap { + operator_id: ordinary_id, + declared_count: 1, + recovery_count: 1, + }) + .await + .unwrap(); + + // Recovery operator contributes first — bootstrap should not finalize yet + let done = coordinator + .ask(ContributeRecoveryBootstrap { + recovery_operator_id: recovery_id, + passphrase: SafeCell::new(b"recovery-pass".to_vec()), + }) + .await + .unwrap(); + assert!(!done, "should not finalize with only recovery passphrase"); + + // Ordinary operator contributes — now bootstrap finalizes + let done = coordinator + .ask(ContributeBootstrap { + operator_id: ordinary_id, + passphrase: SafeCell::new(b"ordinary-pass".to_vec()), + }) + .await + .unwrap(); + assert!(done, "should finalize once all contributors are in"); + + // After bootstrap, vault is Unsealed (seal key still in memory). + let state = vault_ref.ask(GetState {}).await.unwrap(); + assert_eq!(state, VaultState::Unsealed); + + // Verify recovery_operator row was created + let recovery_share_count: i64 = { + let mut conn = db.get().await.unwrap(); + schema::recovery_operator::table + .count() + .get_result(&mut conn) + .await + .unwrap() + }; + assert_eq!(recovery_share_count, 1); + + // Simulate restart: drop vault and coordinator, create fresh vault (comes up Sealed). + drop(coordinator); + drop(vault_ref); + let bus2 = GlobalActors::spawn_message_bus(); + let vault_ref2 = Vault::spawn(Vault::new(db.clone(), bus2).await.unwrap()); + let state = vault_ref2.ask(GetState {}).await.unwrap(); + assert_eq!(state, VaultState::Sealed); + + // §3.6: the recovery operator's share only counts once a wake-up request has stood + // uncancelled for the full dispute window, so back-date one here before unsealing. + { + let mut conn = db.get().await.unwrap(); + sql_query(format!( + "INSERT INTO recovery_wakeup_request (requested_by, requested_at) \ + VALUES ({ordinary_id}, unixepoch('now') - 14*24*3600 - 1)" + )) + .execute(&mut conn) + .await + .unwrap(); + } + + // §3.5: Unseal using ONLY the recovery operator share (threshold = shamir_threshold(1) = 1). + let coordinator2 = VaultCoordinator::spawn(VaultCoordinator::new(db.clone(), vault_ref2.clone())); + let done = coordinator2 + .ask(ContributeRecoveryUnseal { + recovery_operator_id: recovery_id, + passphrase: SafeCell::new(b"recovery-pass".to_vec()), + }) + .await + .unwrap(); + assert!(done, "recovery share alone should satisfy threshold"); + + let state = vault_ref2.ask(GetState {}).await.unwrap(); + assert_eq!(state, VaultState::Unsealed); +} + +/// A committee of zero ordinary operators used to reach `shamir_threshold(0)` and panic, +/// taking the global coordinator down with it. +#[tokio::test] +#[test_log::test] +async fn empty_committee_is_rejected_without_panicking() { + let db = db::create_test_pool().await; + let bus = GlobalActors::spawn_message_bus(); + let vault_ref = Vault::spawn(Vault::new(db.clone(), bus).await.unwrap()); + let coordinator = VaultCoordinator::spawn(VaultCoordinator::new(db, vault_ref)); + + let err = coordinator + .ask(StartBootstrap { + operator_id: 1, + declared_count: 0, + recovery_count: 1, + }) + .await + .unwrap_err(); + + assert!( + matches!( + err, + kameo::error::SendError::HandlerError(CoordinatorError::EmptyCommittee) + ), + "expected EmptyCommittee, got {err:?}" + ); + + // The actor must still be alive to serve the next caller. + let err = coordinator + .ask(StartBootstrap { + operator_id: 1, + declared_count: 0, + recovery_count: 0, + }) + .await + .unwrap_err(); + assert!(matches!( + err, + kameo::error::SendError::HandlerError(CoordinatorError::EmptyCommittee) + )); +} + +/// An approved-but-unfinished operator replacement deletes a share row. The unseal threshold +/// must still describe the split that is actually stored, not the surviving row count. +/// +/// Four ordinary operators give a real 3-of-4 `vsss-rs` split (`shamir_threshold(4) == 3`). +/// Deleting one share row leaves 3 rows, and `shamir_threshold(3) == 2` -- a *different* number +/// from the recorded threshold. A recount-based unseal would therefore finalize one contribution +/// early, combine only 2 shares of a 3-of-4 split, and fail to reconstruct the seal key. +#[tokio::test] +#[test_log::test] +async fn unseal_threshold_survives_a_deleted_share_row() { + let db = db::create_test_pool().await; + let bus = GlobalActors::spawn_message_bus(); + let vault_ref = Vault::spawn(Vault::new(db.clone(), bus).await.unwrap()); + let coordinator = VaultCoordinator::spawn(VaultCoordinator::new(db.clone(), vault_ref)); + + // Four ordinary operators: threshold is 3-of-4. + let mut ids = Vec::new(); + for n in 1..=4u8 { + let mut conn = db.get().await.unwrap(); + let id: i32 = insert_into(schema::operator_identity::table) + .values(schema::operator_identity::public_key.eq(vec![n; 32])) + .returning(schema::operator_identity::id) + .get_result(&mut conn) + .await + .unwrap(); + ids.push(id); + } + + coordinator + .ask(StartBootstrap { + operator_id: ids[0], + declared_count: 4, + recovery_count: 0, + }) + .await + .unwrap(); + for (n, id) in ids.iter().enumerate() { + coordinator + .ask(ContributeBootstrap { + operator_id: *id, + passphrase: SafeCell::new(format!("pass-{n}").into_bytes()), + }) + .await + .unwrap(); + } + + let stored: Option = { + let mut conn = db.get().await.unwrap(); + schema::arbiter_settings::table + .select(schema::arbiter_settings::shamir_threshold) + .first(&mut conn) + .await + .unwrap() + }; + assert_eq!(stored, Some(3), "bootstrap must record the split threshold"); + + // Simulate the aborted replacement: one share row is gone, leaving 3 of the 4 shares. + { + let mut conn = db.get().await.unwrap(); + diesel::delete(schema::operator::table) + .filter(schema::operator::id.eq(Some(ids[3]))) + .execute(&mut conn) + .await + .unwrap(); + } + + // Restart and unseal with the three surviving operators' original passphrases. + drop(coordinator); + let bus2 = GlobalActors::spawn_message_bus(); + let vault_ref2 = Vault::spawn(Vault::new(db.clone(), bus2).await.unwrap()); + let coordinator2 = VaultCoordinator::spawn(VaultCoordinator::new(db.clone(), vault_ref2.clone())); + + let done = coordinator2 + .ask(ContributeUnseal { + operator_id: ids[0], + passphrase: SafeCell::new(b"pass-0".to_vec()), + }) + .await + .unwrap(); + assert!(!done, "one share must not be enough for a 3-of-4 split"); + + let done = coordinator2 + .ask(ContributeUnseal { + operator_id: ids[1], + passphrase: SafeCell::new(b"pass-1".to_vec()), + }) + .await + .unwrap(); + assert!( + !done, + "two shares must not be enough for a 3-of-4 split -- a recount would wrongly finalize here" + ); + + let done = coordinator2 + .ask(ContributeUnseal { + operator_id: ids[2], + passphrase: SafeCell::new(b"pass-2".to_vec()), + }) + .await + .unwrap(); + assert!(done, "three shares must reconstruct a 3-of-4 split"); + assert_eq!( + vault_ref2.ask(GetState {}).await.unwrap(), + VaultState::Unsealed + ); +} + +/// §3.6: recovery operators are asleep by default. Without a wake-up whose 14-day dispute +/// window has elapsed, their share must not count towards an unseal. +#[tokio::test] +#[test_log::test] +async fn sleeping_recovery_operator_cannot_contribute_to_unseal() { + let db = db::create_test_pool().await; + let bus = GlobalActors::spawn_message_bus(); + let vault_ref = Vault::spawn(Vault::new(db.clone(), bus).await.unwrap()); + let coordinator = VaultCoordinator::spawn(VaultCoordinator::new(db.clone(), vault_ref.clone())); + + let ordinary_id: i32 = { + let mut conn = db.get().await.unwrap(); + insert_into(schema::operator_identity::table) + .values(schema::operator_identity::public_key.eq(vec![1u8; 32])) + .returning(schema::operator_identity::id) + .get_result(&mut conn) + .await + .unwrap() + }; + let recovery_id: i32 = { + let mut conn = db.get().await.unwrap(); + insert_into(schema::recovery_operator_identity::table) + .values(schema::recovery_operator_identity::public_key.eq(vec![2u8; 32])) + .returning(schema::recovery_operator_identity::id) + .get_result(&mut conn) + .await + .unwrap() + }; + + coordinator + .ask(StartBootstrap { + operator_id: ordinary_id, + declared_count: 1, + recovery_count: 1, + }) + .await + .unwrap(); + coordinator + .ask(ContributeRecoveryBootstrap { + recovery_operator_id: recovery_id, + passphrase: SafeCell::new(b"recovery-pass".to_vec()), + }) + .await + .unwrap(); + coordinator + .ask(ContributeBootstrap { + operator_id: ordinary_id, + passphrase: SafeCell::new(b"ordinary-pass".to_vec()), + }) + .await + .unwrap(); + + // Restart so the vault comes up Sealed. + drop(coordinator); + drop(vault_ref); + let bus2 = GlobalActors::spawn_message_bus(); + let vault_ref2 = Vault::spawn(Vault::new(db.clone(), bus2).await.unwrap()); + let coordinator2 = VaultCoordinator::spawn(VaultCoordinator::new(db.clone(), vault_ref2.clone())); + + let err = coordinator2 + .ask(ContributeRecoveryUnseal { + recovery_operator_id: recovery_id, + passphrase: SafeCell::new(b"recovery-pass".to_vec()), + }) + .await + .unwrap_err(); + + assert!( + matches!( + err, + kameo::error::SendError::HandlerError(CoordinatorError::RecoveryNotActive) + ), + "expected RecoveryNotActive, got {err:?}" + ); + assert_eq!( + vault_ref2.ask(GetState {}).await.unwrap(), + VaultState::Sealed, + "a sleeping recovery operator unsealed the vault" + ); +} + +type PromotionRx = oneshot::Receiver>; + +/// One `VaultGate` per authenticated role against a shared `GlobalActors`, which is what +/// `peers::operator::start` builds for two connected peers. +struct RoleGates { + ordinary: kameo::actor::ActorRef, + recovery: kameo::actor::ActorRef, + ordinary_id: i32, + recovery_id: i32, + /// Held only so the gates' promotion channels stay open for the fixture's lifetime. + _promotions: (PromotionRx, PromotionRx), +} + +/// Registers one ordinary and one recovery identity, then spawns a gate for each. +async fn spawn_role_gates(db: &db::DatabasePool, actors: &GlobalActors) -> RoleGates { + let ordinary_pubkey = authn::SigningKey::generate().public_key(); + let recovery_pubkey = authn::SigningKey::generate().public_key(); + + let ordinary_id: i32 = { + let mut conn = db.get().await.unwrap(); + insert_into(schema::operator_identity::table) + .values(schema::operator_identity::public_key.eq(ordinary_pubkey.to_bytes())) + .returning(schema::operator_identity::id) + .get_result(&mut conn) + .await + .unwrap() + }; + let recovery_id: i32 = { + let mut conn = db.get().await.unwrap(); + insert_into(schema::recovery_operator_identity::table) + .values(schema::recovery_operator_identity::public_key.eq(recovery_pubkey.to_bytes())) + .returning(schema::recovery_operator_identity::id) + .get_result(&mut conn) + .await + .unwrap() + }; + + let (ordinary_promotion_tx, ordinary_promotion_rx) = oneshot::channel(); + let ordinary = VaultGate::spawn(VaultGate::new( + AuthenticatedOperator::Ordinary(Credentials { + id: ordinary_id, + pubkey: ordinary_pubkey, + }), + actors.clone(), + db.clone(), + ordinary_promotion_tx, + )); + + let (recovery_promotion_tx, recovery_promotion_rx) = oneshot::channel(); + let recovery = VaultGate::spawn(VaultGate::new( + AuthenticatedOperator::Recovery(RecoveryCredentials { + id: recovery_id, + pubkey: recovery_pubkey, + }), + actors.clone(), + db.clone(), + recovery_promotion_tx, + )); + + RoleGates { + ordinary, + recovery, + ordinary_id, + recovery_id, + _promotions: (ordinary_promotion_rx, recovery_promotion_rx), + } +} + +/// Runs the gate's X25519 handshake and encrypts `seal_key` to the shared secret, producing the +/// message a peer would send to bootstrap the vault. Mirrors `tests/operator/unseal.rs`'s +/// `client_dh_encrypt`, which does the same for the unseal side. +async fn bootstrap_key_for( + gate: &kameo::actor::ActorRef, + seal_key: &[u8; 32], +) -> HandleBootstrapEncryptedKey { + let client_secret = EphemeralSecret::random(); + let client_public = PublicKey::from(&client_secret); + + let response = gate + .ask(HandleHandshake { + client_pubkey: client_public, + }) + .await + .unwrap(); + + let shared_secret = client_secret.diffie_hellman(&response.server_pubkey); + let cipher = XChaCha20Poly1305::new(shared_secret.as_bytes().into()); + let nonce = XNonce::from([0u8; 24]); + let associated_data = b"bootstrap"; + let mut ciphertext = seal_key.to_vec(); + cipher + .encrypt_in_place(&nonce, associated_data, &mut ciphertext) + .unwrap(); + + HandleBootstrapEncryptedKey { + nonce: nonce.to_vec(), + ciphertext, + associated_data: associated_data.to_vec(), + } +} + +/// Asserts a gate turned a request down on the peer's role rather than on anything else -- +/// notably not on coordinator state, which is what an unguarded handler would have reported. +#[track_caller] +fn assert_role_refused( + what: &str, + result: Result>, +) { + match result { + Err(kameo::error::SendError::HandlerError(VaultGateError::RoleNotPermitted)) => {} + other => panic!("{what}: expected RoleNotPermitted, got {other:?}"), + } +} + +/// §3.5: which committee seat a passphrase fills is decided by the handshake, not by the +/// request, so neither role can spend the other's slot. +/// +/// Neither request carries an operator id, so the ordinary peer has nothing left to forge; the +/// point of running the bootstrap to completion afterwards is that its refusal left the +/// recovery seat empty rather than filling it under a chosen id. +#[tokio::test] +#[test_log::test] +async fn ordinary_operator_cannot_contribute_a_recovery_share() { + let db = db::create_test_pool().await; + let actors = common::spawn_actors(db.clone()).await; + let gates = spawn_role_gates(&db, &actors).await; + assert_eq!( + gates.ordinary_id, gates.recovery_id, + "the two ids must collide for the attestation check at the end to mean anything" + ); + + gates + .ordinary + .ask(HandleDeclareCommittee { + count: 1, + recovery_count: 1, + }) + .await + .unwrap(); + + assert_role_refused( + "an ordinary operator contributed a recovery share", + gates + .ordinary + .ask(HandleContributeRecoveryBootstrapPassphrase { + passphrase: b"forged-recovery-pass".to_vec(), + }) + .await, + ); + + assert_role_refused( + "a recovery operator contributed an ordinary share", + gates + .recovery + .ask(HandleContributeBootstrapPassphrase { + passphrase: b"forged-ordinary-pass".to_vec(), + }) + .await, + ); + + // The recovery seat is still empty: had the forged contribution landed, this one would come + // back as a duplicate instead of being accepted. + let done = gates + .recovery + .ask(HandleContributeRecoveryBootstrapPassphrase { + passphrase: b"recovery-pass".to_vec(), + }) + .await + .unwrap(); + assert!(!done, "the ordinary share is still outstanding"); + + let done = gates + .ordinary + .ask(HandleContributeBootstrapPassphrase { + passphrase: b"ordinary-pass".to_vec(), + }) + .await + .unwrap(); + assert!(done, "both seats are filled, so bootstrap must finalize"); + assert_eq!( + actors.vault.ask(GetState {}).await.unwrap(), + VaultState::Unsealed + ); + + // Both peers hold the same id in their own table (asserted above), so only the attestation + // kind tells the two envelopes apart. Two rows means the recovery peer signed as itself + // rather than overwriting the ordinary operator's attestation. + let kinds = common::eventually("both bootstrap attestations are written", || { + let db = db.clone(); + async move { + let mut conn = db.get().await.unwrap(); + let mut kinds: Vec = schema::integrity_envelope::table + .select(schema::integrity_envelope::entity_kind) + .load(&mut conn) + .await + .unwrap(); + kinds.sort(); + (kinds.len() == 2).then_some(kinds) + } + }) + .await; + assert_eq!( + kinds, + vec![ + "operator_credentials".to_owned(), + "recovery_operator_credentials".to_owned(), + ] + ); +} + +/// Every gate action that belongs to one role refuses the other, and refuses it before the +/// action takes effect. +/// +/// The vault is left unbootstrapped and the coordinator idle on purpose: an unguarded handler +/// would reach the vault or the coordinator and come back with `State`, `NotBootstrapping` or +/// `NotUnsealing`, so `RoleNotPermitted` can only come from the role check itself. +/// +/// §3.4/§3.5: `HandleBootstrapEncryptedKey` matters most here. It hands the vault a root key of +/// the peer's choosing, and the window it needs -- an unbootstrapped vault that already holds +/// recovery identity rows -- is exactly the state committee formation has to pass through. +#[tokio::test] +#[test_log::test] +async fn vault_gate_refuses_the_actions_of_the_other_role() { + let db = db::create_test_pool().await; + let actors = common::spawn_actors(db.clone()).await; + let gates = spawn_role_gates(&db, &actors).await; + + assert_role_refused( + "a recovery operator declared the committee", + gates + .recovery + .ask(HandleDeclareCommittee { + count: 1, + recovery_count: 1, + }) + .await, + ); + + // A key the vault would have accepted, negotiated through the gate's own handshake -- so + // the refusal comes from the role and not from a malformed request. + let seized_key = bootstrap_key_for(&gates.recovery, b"recovery-seized-32-byte-seal-key").await; + assert_role_refused( + "a recovery operator bootstrapped the vault", + gates.recovery.ask(seized_key).await, + ); + + assert_role_refused( + "a recovery operator contributed an ordinary bootstrap share", + gates + .recovery + .ask(HandleContributeBootstrapPassphrase { + passphrase: b"forged-ordinary-pass".to_vec(), + }) + .await, + ); + + assert_role_refused( + "an ordinary operator contributed a recovery bootstrap share", + gates + .ordinary + .ask(HandleContributeRecoveryBootstrapPassphrase { + passphrase: b"forged-recovery-pass".to_vec(), + }) + .await, + ); + + assert_role_refused( + "a recovery operator contributed an ordinary unseal share", + gates + .recovery + .ask(HandleContributeUnsealPassphrase { + passphrase: b"forged-ordinary-pass".to_vec(), + }) + .await, + ); + + assert_role_refused( + "an ordinary operator contributed a recovery unseal share", + gates + .ordinary + .ask(HandleContributeRecoveryUnsealPassphrase { + passphrase: b"forged-recovery-pass".to_vec(), + }) + .await, + ); + + // Nothing above took effect: the refusals came before the vault and the coordinator. + assert_eq!( + actors.vault.ask(GetState {}).await.unwrap(), + VaultState::Unbootstrapped, + "a refused request still reached the vault" + ); +} diff --git a/useragent/rust/Cargo.lock b/useragent/rust/Cargo.lock index 5442523..d89b5cb 100644 --- a/useragent/rust/Cargo.lock +++ b/useragent/rust/Cargo.lock @@ -36,7 +36,7 @@ dependencies = [ "accesskit", "accesskit_consumer", "atspi-common", - "phf", + "phf 0.13.1", "serde", "zvariant", ] @@ -171,6 +171,649 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "alloy" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9867f85f660948ddbef071ae484027654c04b62c7decd5b61464a7ed1f8931a0" +dependencies = [ + "alloy-consensus", + "alloy-contract", + "alloy-core", + "alloy-eips", + "alloy-ens", + "alloy-genesis", + "alloy-network", + "alloy-provider", + "alloy-rpc-client", + "alloy-rpc-types", + "alloy-serde", + "alloy-signer", + "alloy-signer-local", + "alloy-transport", + "alloy-transport-http", + "alloy-trie", +] + +[[package]] +name = "alloy-chains" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fdcfed8f106be3df944054aaa42bc13ae103a3ac8a9f4b08d4f053e3a743f8" +dependencies = [ + "alloy-primitives", + "num_enum", + "phf 0.14.0", +] + +[[package]] +name = "alloy-consensus" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12a542fe1e6a48cfd010a9e8eba1235436de58ee0f1415e588c27d5d556e98df" +dependencies = [ + "alloy-eips", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "alloy-trie", + "alloy-tx-macros", + "auto_impl", + "borsh", + "c-kzg", + "derive_more", + "either", + "k256", + "once_cell", + "rand 0.8.8", + "secp256k1 0.30.0", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-consensus-any" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05ac1559726a5e71d6dbab59cbd8ad0988c762105d2ae41ae4f3241a3166262d" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "serde", +] + +[[package]] +name = "alloy-contract" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b86722084d6d07822a6c67e39ae794d437fb9de12fa4127d4f9fd93f89651121" +dependencies = [ + "alloy-consensus", + "alloy-dyn-abi", + "alloy-json-abi", + "alloy-network", + "alloy-network-primitives", + "alloy-primitives", + "alloy-provider", + "alloy-rpc-types-eth", + "alloy-sol-types", + "alloy-transport", + "futures", + "futures-util", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "wasmtimer", +] + +[[package]] +name = "alloy-core" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e88cf3d065edfb29a13278215b8521d3ef72a41e2432e019c1f0dd8e30649a5d" +dependencies = [ + "alloy-dyn-abi", + "alloy-json-abi", + "alloy-primitives", + "alloy-rlp", + "alloy-sol-types", +] + +[[package]] +name = "alloy-dyn-abi" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f1a3f2206f2ba4206fdeeddce6640eed3e26b8a13ac41444adb66b76d8e650" +dependencies = [ + "alloy-json-abi", + "alloy-primitives", + "alloy-sol-type-parser", + "alloy-sol-types", + "itoa", + "serde", + "serde_json", + "winnow 1.0.1", +] + +[[package]] +name = "alloy-eip2124" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "741bdd7499908b3aa0b159bba11e71c8cddd009a2c2eb7a06e825f1ec87900a5" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "crc", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-eip2930" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64579d931b3f8eacc7c9ab0b220e87e9c4816e5c724ede1947b55c2f8e92ae5" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "borsh", + "serde", +] + +[[package]] +name = "alloy-eip7702" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2919c5a56a1007492da313e7a3b6d45ef5edc5d33416fdec63c0d7a2702a0d20" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "borsh", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-eip7928" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb59cca9e58f5c624becc3cffb32772dc278f31eeae4606844a88592e8d2672" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "borsh", + "once_cell", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-eips" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d40246adf7468b430fce87945ece8a54ef27fa760dd9f670f5162a412007bdb2" +dependencies = [ + "alloy-eip2124", + "alloy-eip2930", + "alloy-eip7702", + "alloy-eip7928", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "auto_impl", + "borsh", + "c-kzg", + "derive_more", + "either", + "serde", + "serde_with", + "sha2", +] + +[[package]] +name = "alloy-ens" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be9aa797694e7c55b007aa8f172daef76a83272cbedb8800bdc7038eed3bdb1a" +dependencies = [ + "alloy-contract", + "alloy-primitives", + "alloy-provider", + "alloy-sol-types", + "async-trait", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-genesis" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4951473e6ff25cdf82eb87b6a1a5fbb4af9c97528398a4a62fbf516198ec5028" +dependencies = [ + "alloy-eips", + "alloy-primitives", + "alloy-serde", + "alloy-trie", + "borsh", + "serde", + "serde_with", +] + +[[package]] +name = "alloy-json-abi" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "208699c66c453fbb4c50d2e602f8ceff8a5f1fa48ac8b6ee3b6357fdc93da311" +dependencies = [ + "alloy-primitives", + "alloy-sol-type-parser", + "serde", + "serde_json", +] + +[[package]] +name = "alloy-json-rpc" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e5722ea29a9a85ecdba2516c58f50d71e77cd25f8ce7e9f4ee8570ddeee6df1" +dependencies = [ + "alloy-primitives", + "alloy-sol-types", + "http", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "alloy-network" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e4d278913f00ab611f5367448a3a800fed173ab791c3cc67b7a1b0d7afd305" +dependencies = [ + "alloy-consensus", + "alloy-consensus-any", + "alloy-eips", + "alloy-json-rpc", + "alloy-network-primitives", + "alloy-primitives", + "alloy-rpc-types-any", + "alloy-rpc-types-eth", + "alloy-serde", + "alloy-signer", + "alloy-sol-types", + "async-trait", + "auto_impl", + "derive_more", + "futures-utils-wasm", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-network-primitives" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e52ae56d1acaa6b46e9d7015b8ae6c10f3d1c213a32ae70b17426a03ae6729" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "alloy-serde", + "serde", +] + +[[package]] +name = "alloy-primitives" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c902f0ca3f8353c41e3e1ec3cf26be49412525bc48ab9d3c4710d7be4f01832" +dependencies = [ + "alloy-rlp", + "bytes", + "cfg-if", + "const-hex", + "derive_more", + "fixed-cache", + "foldhash 0.2.0", + "hashbrown 0.17.1", + "indexmap 2.13.1", + "itoa", + "k256", + "keccak-asm", + "paste", + "proptest", + "rand 0.9.5", + "rapidhash", + "ruint", + "rustc-hash 2.1.2", + "secp256k1 0.31.1", + "serde", + "sha3", +] + +[[package]] +name = "alloy-provider" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "386124639a5c386329b9f2ef2b42969badbf7bba40422b513e3e5739c516dc34" +dependencies = [ + "alloy-chains", + "alloy-consensus", + "alloy-eips", + "alloy-json-rpc", + "alloy-network", + "alloy-network-primitives", + "alloy-primitives", + "alloy-rpc-client", + "alloy-rpc-types-eth", + "alloy-signer", + "alloy-sol-types", + "alloy-transport", + "alloy-transport-http", + "async-stream", + "async-trait", + "auto_impl", + "dashmap 6.1.0", + "either", + "futures", + "futures-utils-wasm", + "lru", + "parking_lot", + "pin-project", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "wasmtimer", +] + +[[package]] +name = "alloy-rlp" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24671b1f62edcf0f9b62994c7bf72cd621a04a4b99f5020ece1a647b40e2f103" +dependencies = [ + "alloy-rlp-derive", + "arrayvec", + "bytes", +] + +[[package]] +name = "alloy-rlp-derive" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d4311c03125e8a18296504560b9de3d75ecbd0dcda7f71e6cf2a196d57e6fba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "alloy-rpc-client" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfd3ebc1756426a6a9e17979d4467632aff01f359136c50e5320ac6e32d6b8d" +dependencies = [ + "alloy-json-rpc", + "alloy-primitives", + "alloy-transport", + "alloy-transport-http", + "futures", + "pin-project", + "reqwest", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tower", + "tracing", + "url", + "wasmtimer", +] + +[[package]] +name = "alloy-rpc-types" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417dc664965e36fc43a4f9c8eb8b2d14067a29c430e23470d2c923c1c977bac7" +dependencies = [ + "alloy-primitives", + "alloy-rpc-types-eth", + "alloy-serde", + "serde", +] + +[[package]] +name = "alloy-rpc-types-any" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0a6cce3f97e5d9c0048081b5d173e0e406c2056c52c012e578a1d451ff0cdc" +dependencies = [ + "alloy-consensus-any", + "alloy-network-primitives", + "alloy-primitives", + "alloy-rpc-types-eth", + "alloy-serde", + "serde", + "serde_json", +] + +[[package]] +name = "alloy-rpc-types-eth" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1eb4c87dfdde83cd97e03a8c0c5e986b52e14075e02266e322ac6c7eb02e7399" +dependencies = [ + "alloy-consensus", + "alloy-consensus-any", + "alloy-eips", + "alloy-network-primitives", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "alloy-sol-types", + "itertools 0.14.0", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-serde" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ff89f75b8a9d00f659750e9220c31b42d67c69ffc43b2ac8911dfd0506eb4a6" +dependencies = [ + "alloy-primitives", + "serde", + "serde_json", +] + +[[package]] +name = "alloy-signer" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69e1f6fae3ade809daa4c32bee35a30a439bb307209d7b42f3995988b5cc74b8" +dependencies = [ + "alloy-primitives", + "async-trait", + "auto_impl", + "either", + "elliptic-curve", + "k256", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-signer-local" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "621cc39c34b85875858ef149598f3d5f9797dff9a3247f3606fcc06f9b20acc8" +dependencies = [ + "alloy-consensus", + "alloy-network", + "alloy-primitives", + "alloy-signer", + "async-trait", + "k256", + "rand 0.8.8", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-sol-macro" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdcbd48d60e029be4a325c3a2f1312761caea4ed249f18ba9e8ed24ca1bf01e6" +dependencies = [ + "alloy-sol-macro-expander", + "alloy-sol-macro-input", + "proc-macro-error3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "alloy-sol-macro-expander" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59c9f7c535f99a7e7b64cc520968b09ed14cec3715572fcc277cfbff602808cd" +dependencies = [ + "alloy-json-abi", + "alloy-sol-macro-input", + "const-hex", + "heck", + "indexmap 2.13.1", + "proc-macro-error3", + "proc-macro2", + "quote", + "sha3", + "syn 2.0.117", + "syn-solidity", +] + +[[package]] +name = "alloy-sol-macro-input" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1abd404fbc12f543823005146b73fd07621bdc0baaa950d26995c543a9d73811" +dependencies = [ + "alloy-json-abi", + "const-hex", + "dunce", + "heck", + "macro-string", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.117", + "syn-solidity", +] + +[[package]] +name = "alloy-sol-type-parser" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a7fd71864526bfeca8903010d5bb7fd28a0a4f5cc55818304c9cad8f0d63ab" +dependencies = [ + "serde", + "winnow 1.0.1", +] + +[[package]] +name = "alloy-sol-types" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adfc2ba3fb0e865de4934bcad6d37fc51e9ffcd5294be1322eab38e4494e051b" +dependencies = [ + "alloy-json-abi", + "alloy-primitives", + "alloy-sol-macro", + "serde", +] + +[[package]] +name = "alloy-transport" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06f4ca8484d6295d5165f67de8fdb00ae630ba585efb606f003171286b56441c" +dependencies = [ + "alloy-json-rpc", + "auto_impl", + "base64", + "derive_more", + "futures", + "futures-utils-wasm", + "parking_lot", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tower", + "tracing", + "url", + "wasmtimer", +] + +[[package]] +name = "alloy-transport-http" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8342aa5a7f8dee6d606307eebbc847d9799a823c7e4f37b9c1e32b08715f73c8" +dependencies = [ + "alloy-json-rpc", + "alloy-transport", + "itertools 0.14.0", + "reqwest", + "serde_json", + "tower", + "tracing", + "url", +] + +[[package]] +name = "alloy-trie" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f14b5d9b2c2173980202c6ff470d96e7c5e202c65a9f67884ad565226df7fbb" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "derive_more", + "nybbles", + "serde", + "smallvec", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "alloy-tx-macros" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d0626c4f3b2028f7e8db32f53c22d9ecd5939f772317b7c4b6fe5219cb0589a" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "android-activity" version = "0.6.1" @@ -232,10 +875,14 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" name = "arbiter-crypto" version = "0.1.0" dependencies = [ + "alloy", "chrono", + "hmac 0.13.0", "memsafe", "ml-dsa", - "rand", + "rand 0.10.2", + "strum", + "thiserror 2.0.18", "x-wing", ] @@ -259,6 +906,269 @@ dependencies = [ "x11rb", ] +[[package]] +name = "ark-ff" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b3235cc41ee7a12aaaf2c575a2ad7b46713a8a50bda2fc3b003a04845c05dd6" +dependencies = [ + "ark-ff-asm 0.3.0", + "ark-ff-macros 0.3.0", + "ark-serialize 0.3.0", + "ark-std 0.3.0", + "derivative", + "num-bigint", + "num-traits", + "paste", + "rustc_version 0.3.3", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec847af850f44ad29048935519032c33da8aa03340876d351dfab5660d2966ba" +dependencies = [ + "ark-ff-asm 0.4.2", + "ark-ff-macros 0.4.2", + "ark-serialize 0.4.2", + "ark-std 0.4.0", + "derivative", + "digest 0.10.7", + "itertools 0.10.5", + "num-bigint", + "num-traits", + "paste", + "rustc_version 0.4.1", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a177aba0ed1e0fbb62aa9f6d0502e9b46dad8c2eab04c14258a1212d2557ea70" +dependencies = [ + "ark-ff-asm 0.5.0", + "ark-ff-macros 0.5.0", + "ark-serialize 0.5.0", + "ark-std 0.5.0", + "arrayvec", + "digest 0.10.7", + "educe", + "itertools 0.13.0", + "num-bigint", + "num-traits", + "paste", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7a806ac6c8307b929df4645776290a50ee2aac754ad09d8bdf73391309e43af" +dependencies = [ + "ark-ff-asm 0.6.0", + "ark-ff-macros 0.6.0", + "ark-serialize 0.6.0", + "ark-std 0.6.0", + "digest 0.10.7", + "educe", + "num-bigint", + "num-traits", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db02d390bf6643fb404d3d22d31aee1c4bc4459600aef9113833d17e786c6e44" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-asm" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-asm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ark-ff-asm" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1479009684adc073dff49a1025d3a7065b317a9ead25aaaca38cdc70058ba8a2" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ark-ff-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fd794a08ccb318058009eefdf15bcaaaaf6f8161eb3345f907222bac38b20" +dependencies = [ + "num-bigint", + "num-traits", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ark-ff-macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0691ed21ef00ef89c1e9bda832eba493dda3ec2f8d892fb25b705f73f06bb8" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ark-serialize" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d6c2b318ee6e10f8c2853e73a83adc0ccb88995aa978d8a3408d492ab2ee671" +dependencies = [ + "ark-std 0.3.0", + "digest 0.9.0", +] + +[[package]] +name = "ark-serialize" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" +dependencies = [ + "ark-std 0.4.0", + "digest 0.10.7", + "num-bigint", +] + +[[package]] +name = "ark-serialize" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" +dependencies = [ + "ark-std 0.5.0", + "arrayvec", + "digest 0.10.7", + "num-bigint", +] + +[[package]] +name = "ark-serialize" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a74dd304fd536fb95d0a328e72be759209cc496a9da094c5bc56e5fea4f9e86b" +dependencies = [ + "ark-serialize-derive", + "ark-std 0.6.0", + "digest 0.10.7", + "num-bigint", + "serde_with", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f153690697a2b91e5e1251ff98411ee5371500a111a0fd317a70e588eb300f9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ark-std" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df2c09229cbc5a028b1d70e00fdb2acee28b1055dfb5ca73eea49c5a25c4e7c" +dependencies = [ + "num-traits", + "rand 0.8.8", +] + +[[package]] +name = "ark-std" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" +dependencies = [ + "num-traits", + "rand 0.8.8", +] + +[[package]] +name = "ark-std" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" +dependencies = [ + "num-traits", + "rand 0.8.8", +] + +[[package]] +name = "ark-std" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "367c9c827ed431bff6868b7aa926e05b16eb46603cc8b6e768e4a5553fa1d155" +dependencies = [ + "num-traits", + "rand 0.8.8", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -379,7 +1289,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -400,6 +1310,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "async-task" version = "4.7.1" @@ -414,7 +1346,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -466,12 +1398,46 @@ dependencies = [ "zbus", ] +[[package]] +name = "auto_impl" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "autocfg" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "backtrace" version = "0.3.69" @@ -487,6 +1453,18 @@ dependencies = [ "rustc-demangle", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64ct" version = "1.8.3" @@ -508,6 +1486,42 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +[[package]] +name = "bitcoin-consensus-encoding" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6712f9c6fd6785b3b270884e57c441c403dc5d7e19ca45368c97c7a1de3000ec" +dependencies = [ + "bitcoin-internals", + "hex-conservative 1.2.0", + "serde", +] + +[[package]] +name = "bitcoin-internals" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" + +[[package]] +name = "bitcoin-io" +version = "0.1.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78" +dependencies = [ + "bitcoin-consensus-encoding", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" +dependencies = [ + "bitcoin-io", + "hex-conservative 0.2.2", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -520,6 +1534,18 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -569,6 +1595,51 @@ dependencies = [ "piper", ] +[[package]] +name = "blst" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c20659f9bbee16cbbd2f7393e40ab6309f5a98f76a2eb57a995ec508b72387fe" +dependencies = [ + "cc", + "glob", + "threadpool", + "zeroize", +] + +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "build-target" version = "0.4.0" @@ -581,6 +1652,12 @@ version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" +[[package]] +name = "byte-slice-cast" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" + [[package]] name = "bytemuck" version = "1.25.0" @@ -598,7 +1675,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -618,6 +1695,24 @@ name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "c-kzg" +version = "2.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d04308254695569fdb9bfe3bacc1c91837a670d0806605eb82d63748fbd3a6" +dependencies = [ + "blst", + "cc", + "glob", + "hex", + "libc", + "once_cell", + "serde", +] [[package]] name = "calloop" @@ -672,12 +1767,14 @@ dependencies = [ [[package]] name = "cc" -version = "1.0.83" +version = "1.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" dependencies = [ + "find-msvc-tools", "jobserver", "libc", + "shlex", ] [[package]] @@ -709,7 +1806,7 @@ checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core", + "rand_core 0.10.0", ] [[package]] @@ -735,6 +1832,15 @@ dependencies = [ "error-code", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "cmov" version = "0.5.3" @@ -790,12 +1896,60 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "const-hex" +version = "1.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e2a781ebdf4467d1428dc4593067825fb646f6871475098d8577421af73558" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "proptest", + "serde_core", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -864,6 +2018,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + [[package]] name = "crc32fast" version = "1.5.0" @@ -885,6 +2054,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -901,8 +2082,9 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" dependencies = [ + "getrandom 0.4.2", "hybrid-array", - "rand_core", + "rand_core 0.10.0", ] [[package]] @@ -930,7 +2112,7 @@ dependencies = [ "cpufeatures 0.2.17", "curve25519-dalek-derive", "fiat-crypto", - "rustc_version", + "rustc_version 0.4.1", "subtle", "zeroize", ] @@ -943,7 +2125,42 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "serde", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", ] [[package]] @@ -968,6 +2185,51 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "delegate-attr" version = "0.3.0" @@ -976,7 +2238,17 @@ checksum = "51aac4c99b2e6775164b412ea33ae8441b2fde2dbf05a20bc0052a63d08c475b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "zeroize", ] [[package]] @@ -985,10 +2257,62 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" dependencies = [ - "const-oid", + "const-oid 0.10.2", "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version 0.4.1", + "syn 2.0.117", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + [[package]] name = "digest" version = "0.10.7" @@ -996,7 +2320,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid 0.9.6", "crypto-common 0.1.6", + "subtle", ] [[package]] @@ -1007,6 +2333,7 @@ checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" dependencies = [ "block-buffer 0.12.0", "crypto-common 0.2.1", + "ctutils", ] [[package]] @@ -1033,7 +2360,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1066,6 +2393,33 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "serdect", + "signature 2.2.0", + "spki 0.7.3", +] + [[package]] name = "ecolor" version = "0.34.1" @@ -1076,6 +2430,18 @@ dependencies = [ "emath", ] +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "eframe" version = "0.34.1" @@ -1189,6 +2555,35 @@ dependencies = [ "winit", ] +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +dependencies = [ + "serde", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sec1", + "serdect", + "subtle", + "zeroize", +] + [[package]] name = "emath" version = "0.34.1" @@ -1204,6 +2599,26 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" +[[package]] +name = "enum-ordinalize" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -1222,7 +2637,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1321,6 +2736,28 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fastrlp" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139834ddba373bbdd213dffe02c8d110508dcf1726c2be27e8d1f7d7e1856418" +dependencies = [ + "arrayvec", + "auto_impl", + "bytes", +] + +[[package]] +name = "fastrlp" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce8dba4714ef14b8274c371879b175aa55b16b30f269663f19d576f380018dc4" +dependencies = [ + "arrayvec", + "auto_impl", + "bytes", +] + [[package]] name = "fax" version = "0.2.6" @@ -1338,7 +2775,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1359,12 +2796,50 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "fixed-cache" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fe63500644ef0269fe6b744e7e5dc5c20b5eebf3d881bc2be53f194636f6583" +dependencies = [ + "equivalent", + "rapidhash", +] + +[[package]] +name = "fixed-hash" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" +dependencies = [ + "byteorder", + "rand 0.8.8", + "rustc-hex", + "static_assertions", +] + [[package]] name = "flate2" version = "1.1.9" @@ -1414,7 +2889,7 @@ dependencies = [ "md-5", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1456,7 +2931,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1474,6 +2949,18 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures" version = "0.3.29" @@ -1543,7 +3030,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1575,6 +3062,12 @@ dependencies = [ "slab", ] +[[package]] +name = "futures-utils-wasm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42012b0f064e01aa58b545fe3727f90f7dd4020f4a3ea735b50344965f5a57e9" + [[package]] name = "generic-array" version = "0.14.7" @@ -1583,6 +3076,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1595,6 +3089,19 @@ dependencies = [ "windows-link", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1614,11 +3121,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", - "rand_core", + "rand_core 0.10.0", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -1638,6 +3147,12 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "glow" version = "0.17.0" @@ -1750,6 +3265,17 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "half" version = "2.7.1" @@ -1762,6 +3288,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" @@ -1788,6 +3320,19 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", + "serde", + "serde_core", +] + [[package]] name = "heck" version = "0.5.0" @@ -1812,12 +3357,87 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex-conservative" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35431185f361ccf3ffc58254628af5f1f5d5f28531da2e02e5d6c82bbc282a10" +dependencies = [ + "arrayvec", +] + [[package]] name = "hexf-parse" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.2", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "hybrid-array" version = "0.4.10" @@ -1829,6 +3449,64 @@ dependencies = [ "zeroize", ] +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -1941,6 +3619,12 @@ 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 = "idna" version = "1.1.0" @@ -1976,6 +3660,37 @@ dependencies = [ "tiff", ] +[[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-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.117", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.13.1" @@ -1988,12 +3703,98 @@ dependencies = [ "serde_core", ] +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.22.4" @@ -2019,9 +3820,9 @@ checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" dependencies = [ "proc-macro2", "quote", - "rustc_version", + "rustc_version 0.4.1", "simd_cesu8", - "syn", + "syn 2.0.117", ] [[package]] @@ -2049,7 +3850,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2074,6 +3875,20 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "serdect", + "sha2", +] + [[package]] name = "keccak" version = "0.2.0" @@ -2084,6 +3899,16 @@ dependencies = [ "cpufeatures 0.3.0", ] +[[package]] +name = "keccak-asm" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5dc2c0d691cbf7595cde551ced329cca99c2387c2cbc97754c5d0cd045d3ee" +dependencies = [ + "digest 0.10.7", + "sha3-asm", +] + [[package]] name = "kem" version = "0.3.0" @@ -2091,7 +3916,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" dependencies = [ "crypto-common 0.2.1", - "rand_core", + "rand_core 0.10.0", ] [[package]] @@ -2111,6 +3936,21 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + [[package]] name = "kurbo" version = "0.13.0" @@ -2213,6 +4053,32 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d317b4b9eb398e6acce275758ec6125535505e7a146fb1a9b8bda2451b0ff4c" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "macro-string" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a9dbbfc75d2688ed057456ce8a3ee3f48d12eec09229f560f3643b9f275653" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "md-5" version = "0.10.6" @@ -2277,19 +4143,30 @@ dependencies = [ ] [[package]] -name = "ml-dsa" -version = "0.1.0-rc.8" +name = "mio" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5b2bb0ad6fa2b40396775bd56f51345171490fef993f46f91a876ecdbdaea55" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ - "const-oid", + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "ml-dsa" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d" +dependencies = [ + "const-oid 0.10.2", + "crypto-common 0.2.1", "ctutils", "hybrid-array", "module-lattice", - "pkcs8", - "rand_core", - "sha3", - "signature", + "pkcs8 0.11.0", + "shake", + "signature 3.0.0", "zeroize", ] @@ -2302,16 +4179,16 @@ dependencies = [ "hybrid-array", "kem", "module-lattice", - "rand_core", + "rand_core 0.10.0", "sha3", "zeroize", ] [[package]] name = "module-lattice" -version = "0.2.1" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "164eb3faeaecbd14b0b2a917c1b4d0c035097a9c559b0bed85c2cdd032bc8faa" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" dependencies = [ "ctutils", "hybrid-array", @@ -2344,7 +4221,7 @@ dependencies = [ "half", "hashbrown 0.16.1", "hexf-parse", - "indexmap", + "indexmap 2.13.1", "libm", "log", "num-traits", @@ -2391,6 +4268,31 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2430,7 +4332,21 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "nybbles" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d49ff0c0d00d4a502b39df9af3a525e1efeb14b9dabb5bb83335284c1309210" +dependencies = [ + "alloy-rlp", + "cfg-if", + "proptest", + "ruint", + "serde", + "smallvec", ] [[package]] @@ -2757,6 +4673,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "orbclient" version = "0.3.51" @@ -2793,7 +4715,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80d2043d1f61d77cb2f4b1f7b7b2295f40507f5f8e9d1c8bf10a1ca5f97a3969" dependencies = [ "cc", - "dashmap", + "dashmap 5.5.3", "log", ] @@ -2806,6 +4728,34 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "parity-scale-codec" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" +dependencies = [ + "arrayvec", + "bitvec", + "byte-slice-cast", + "const_format", + "impl-trait-for-tuples", + "parity-scale-codec-derive", + "rustversion", + "serde", +] + +[[package]] +name = "parity-scale-codec-derive" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "parking" version = "2.2.1" @@ -2835,6 +4785,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "peniko" version = "0.6.0" @@ -2854,6 +4810,16 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" +dependencies = [ + "memchr", + "ucd-trie", +] + [[package]] name = "phf" version = "0.13.1" @@ -2861,10 +4827,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ "phf_macros", - "phf_shared", + "phf_shared 0.13.1", "serde", ] +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_shared 0.14.0", +] + [[package]] name = "phf_generator" version = "0.13.1" @@ -2872,7 +4847,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", ] [[package]] @@ -2882,10 +4857,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.13.1", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2897,6 +4872,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.11" @@ -2914,7 +4898,7 @@ checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2923,6 +4907,12 @@ version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "piper" version = "0.2.5" @@ -2936,12 +4926,22 @@ dependencies = [ [[package]] name = "pkcs8" -version = "0.11.0-rc.11" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12922b6296c06eb741b02d7b5161e3aaa22864af38dfa025a1a3ba3f68c84577" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", - "spki", + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.0", + "spki 0.8.0", ] [[package]] @@ -3013,6 +5013,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "presser" version = "0.3.1" @@ -3026,7 +5041,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "primitive-types" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b34d9fd68ae0b74a41b21c03c2f62847aa0ffea044eee893b4c140b37e244e2" +dependencies = [ + "fixed-hash", + "impl-codec", + "uint", ] [[package]] @@ -3038,6 +5064,28 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error-attr3" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82366fd7d8b7a440d66d13418820c69df9b3908bcb1a0476d7f5ce5d12f5a04d" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error3" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b511283ea8a74b4b39447b128c5d00f03a356b7424554b13e298a5550100d9ac" +dependencies = [ + "proc-macro-error-attr3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -3053,6 +5101,21 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bitflags 2.11.0", + "num-traits", + "rand 0.9.5", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "unarray", +] + [[package]] name = "pxfm" version = "0.1.28" @@ -3084,6 +5147,63 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.2", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.2", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash 2.1.2", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -3106,14 +5226,82 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] -name = "rand" -version = "0.10.0" +name = "radium" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", + "serde", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", + "serde", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.2", - "rand_core", + "rand_core 0.10.0", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", + "serde", ] [[package]] @@ -3122,12 +5310,39 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.0", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "range-alloc" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" +[[package]] +name = "rapidhash" +version = "4.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e" +dependencies = [ + "rustversion", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3183,6 +5398,26 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "regex" version = "1.10.2" @@ -3218,6 +5453,112 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rlp" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb919243f34364b6bd2fc10ef797edbfa75f33c252e7998527479c6d6b47e1ec" +dependencies = [ + "bytes", + "rustc-hex", +] + +[[package]] +name = "ruint" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5e99bff0393163bb25029a6af25d3d8d202ba5b5438a74d1bd8789f5c822970" +dependencies = [ + "alloy-rlp", + "ark-ff 0.3.0", + "ark-ff 0.4.2", + "ark-ff 0.5.0", + "ark-ff 0.6.0", + "bytes", + "fastrlp 0.3.1", + "fastrlp 0.4.0", + "num-bigint", + "num-integer", + "num-traits", + "parity-scale-codec", + "primitive-types", + "proptest", + "rand 0.8.8", + "rand 0.9.5", + "rlp", + "ruint-macro", + "serde_core", + "valuable", + "zeroize", +] + +[[package]] +name = "ruint-macro" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" + [[package]] name = "rust_lib_arbiter" version = "0.1.0" @@ -3247,13 +5588,28 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustc-hex" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e75f6a532d0fd9f7f13144f392b6ad56a32696bfcd9c78f797f16bbb6f072d6" + +[[package]] +name = "rustc_version" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0dfe2087c51c460008730de8b57e6a320782fbfb312e1f4d520e6c6fae155ee" +dependencies = [ + "semver 0.11.0", +] + [[package]] name = "rustc_version" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ - "semver", + "semver 1.0.28", ] [[package]] @@ -3282,6 +5638,81 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -3297,6 +5728,39 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -3322,18 +5786,115 @@ dependencies = [ "tiny-skia", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der 0.7.10", + "generic-array", + "pkcs8 0.10.2", + "serdect", + "subtle", + "zeroize", +] + +[[package]] +name = "secp256k1" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" +dependencies = [ + "bitcoin_hashes", + "rand 0.8.8", + "secp256k1-sys 0.10.1", + "serde", +] + +[[package]] +name = "secp256k1" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2" +dependencies = [ + "bitcoin_hashes", + "rand 0.9.5", + "secp256k1-sys 0.11.0", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "secp256k1-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcb913707158fadaf0d8702c2db0e857de66eb003ccfdda5924b5f5ac98efb38" +dependencies = [ + "cc", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "self_cell" version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" +[[package]] +name = "semver" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f301af10236f6df4160f7c3f04eec6dbc70ace82d23326abad5edee88801c6b6" +dependencies = [ + "semver-parser", +] + [[package]] name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "semver-parser" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9900206b54a3527fdc7b8a938bffd94a568bac4f4aa8113b209df75a09c0dec2" +dependencies = [ + "pest", +] + [[package]] name = "serde" version = "1.0.228" @@ -3361,7 +5922,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3385,7 +5946,61 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "serde_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.1", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serdect" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -3398,6 +6013,33 @@ dependencies = [ "keccak", ] +[[package]] +name = "sha3-asm" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6287fd675f713484342a89cbf0a386abef5f15919cfad607e5e1f19e1e15331" +dependencies = [ + "cc", + "cfg-if", +] + +[[package]] +name = "shake" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" +dependencies = [ + "digest 0.11.2", + "keccak", + "sponge-cursor", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -3410,12 +6052,22 @@ dependencies = [ [[package]] name = "signature" -version = "3.0.0-rc.10" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f1880df446116126965eeec169136b2e0251dba37c6223bcc819569550edea3" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" dependencies = [ "digest 0.11.2", - "rand_core", + "rand_core 0.10.0", ] [[package]] @@ -3430,7 +6082,7 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" dependencies = [ - "rustc_version", + "rustc_version 0.4.1", "simdutf8", ] @@ -3479,6 +6131,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "smithay-client-toolkit" @@ -3552,6 +6207,16 @@ dependencies = [ "serde", ] +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "spirv" version = "0.4.0+sdk-1.4.341.0" @@ -3561,6 +6226,16 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + [[package]] name = "spki" version = "0.8.0" @@ -3568,9 +6243,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" dependencies = [ "base64ct", - "der", + "der 0.8.0", ] +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3589,12 +6270,50 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[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.117" @@ -3606,6 +6325,38 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn-solidity" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e452eb8cb83fc8b81597eb07c8d39f770d04905af9c5bffce8bea7213df29960" +dependencies = [ + "paste", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -3614,9 +6365,15 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tempfile" version = "3.27.0" @@ -3665,7 +6422,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3676,7 +6433,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3702,6 +6459,36 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-skia" version = "0.11.4" @@ -3738,14 +6525,79 @@ dependencies = [ ] [[package]] -name = "tokio" -version = "1.34.0" +name = "tinyvec" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0c014766411e834f7af5b8f4cf46257aab4036ca95e9d2c144a10f59ad6f5b9" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ - "backtrace", - "num_cpus", + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", ] [[package]] @@ -3763,7 +6615,7 @@ version = "0.25.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b" dependencies = [ - "indexmap", + "indexmap 2.13.1", "toml_datetime", "toml_parser", "winnow 1.0.1", @@ -3778,6 +6630,51 @@ dependencies = [ "winnow 1.0.1", ] +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -3798,7 +6695,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3810,6 +6707,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "ttf-parser" version = "0.25.1" @@ -3831,6 +6734,12 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "uds_windows" version = "1.2.1" @@ -3842,6 +6751,24 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "uint" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f64bba2c53b04fcab63c01a7d7427eadc821e3bc48c34dc9ba29c501164b52" +dependencies = [ + "byteorder", + "crunchy", + "hex", + "static_assertions", +] + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.12" @@ -3866,6 +6793,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -3876,6 +6809,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -3895,6 +6829,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vello_common" version = "0.0.6" @@ -3937,6 +6877,21 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +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" @@ -3997,7 +6952,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -4027,7 +6982,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.13.1", "wasm-encoder", "wasmparser", ] @@ -4040,8 +6995,22 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.11.0", "hashbrown 0.15.5", - "indexmap", - "semver", + "indexmap 2.13.1", + "semver 1.0.28", +] + +[[package]] +name = "wasmtimer" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b" +dependencies = [ + "futures", + "js-sys", + "parking_lot", + "pin-utils", + "slab", + "wasm-bindgen", ] [[package]] @@ -4215,6 +7184,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -4265,7 +7243,7 @@ dependencies = [ "cfg_aliases", "document-features", "hashbrown 0.16.1", - "indexmap", + "indexmap 2.13.1", "log", "naga", "once_cell", @@ -4482,7 +7460,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4493,7 +7471,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4802,9 +7780,9 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.13.1", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -4820,7 +7798,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -4833,7 +7811,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.11.0", - "indexmap", + "indexmap 2.13.1", "log", "serde", "serde_derive", @@ -4852,9 +7830,9 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.13.1", "log", - "semver", + "semver 1.0.28", "serde", "serde_derive", "serde_json", @@ -4868,6 +7846,15 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "x-wing" version = "0.1.0-rc.0" @@ -4876,7 +7863,7 @@ checksum = "e17d0d5f4d1f26b9b9e7477af1d3bef960e1d1fb64edab7912fde472a8a8432e" dependencies = [ "kem", "ml-kem", - "rand_core", + "rand_core 0.10.0", "sha3", "x25519-dalek", "zeroize", @@ -4921,7 +7908,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3d5d6ff67acd3945b933e592bfa7143db4fcbb2f871754b6b9fbd7847fc5aea" dependencies = [ "curve25519-dalek", - "rand_core", + "rand_core 0.10.0", "zeroize", ] @@ -4975,7 +7962,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -5032,7 +8019,7 @@ checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "zbus-lockstep", "zbus_xml", "zvariant", @@ -5047,7 +8034,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "zbus_names", "zvariant", "zvariant_utils", @@ -5093,7 +8080,7 @@ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5113,7 +8100,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -5122,6 +8109,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.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" @@ -5153,7 +8154,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5200,7 +8201,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "zvariant_utils", ] @@ -5213,6 +8214,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn", + "syn 2.0.117", "winnow 0.7.15", ]