diff --git a/mise.lock b/mise.lock index 346f56d..966beeb 100644 --- a/mise.lock +++ b/mise.lock @@ -152,5 +152,8 @@ url = "https://github.com/astral-sh/python-build-standalone/releases/download/20 provenance = "github-attestations" [[tools.rust]] -version = "1.95.0" +version = "1.98.1" backend = "core:rust" + +[tools.rust.options] +components = "clippy,rust-analyzer" diff --git a/mise.toml b/mise.toml index a1f3729..e88e7e0 100644 --- a/mise.toml +++ b/mise.toml @@ -4,7 +4,7 @@ "cargo:cargo-vet" = "0.10.2" flutter = "3.41.7-stable" protoc = "29.6" -rust = { version = "1.95.0", components = "clippy,rust-analyzer" } +rust = { version = "latest", components = "clippy,rust-analyzer" } "cargo:cargo-features-manager" = "0.12.0" "cargo:cargo-nextest" = "0.9.133" "cargo:cargo-shear" = "latest" diff --git a/server/Cargo.lock b/server/Cargo.lock index dcf3fa1..069a9eb 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]] @@ -504,7 +504,7 @@ dependencies = [ "async-trait", "auto_impl", "either", - "elliptic-curve", + "elliptic-curve 0.13.8", "k256", "thiserror", ] @@ -787,6 +787,7 @@ dependencies = [ "tonic", "tracing", "tracing-subscriber", + "vsss-rs", "x25519-dalek 2.0.1", ] @@ -821,7 +822,7 @@ dependencies = [ "ark-serialize 0.3.0", "ark-std 0.3.0", "derivative", - "num-bigint", + "num-bigint 0.4.6", "num-traits", "paste", "rustc_version 0.3.3", @@ -841,7 +842,7 @@ dependencies = [ "derivative", "digest 0.10.7", "itertools 0.10.5", - "num-bigint", + "num-bigint 0.4.6", "num-traits", "paste", "rustc_version 0.4.1", @@ -862,7 +863,7 @@ dependencies = [ "digest 0.10.7", "educe", "itertools 0.13.0", - "num-bigint", + "num-bigint 0.4.6", "num-traits", "paste", "zeroize", @@ -904,7 +905,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db2fd794a08ccb318058009eefdf15bcaaaaf6f8161eb3345f907222bac38b20" dependencies = [ - "num-bigint", + "num-bigint 0.4.6", "num-traits", "quote", "syn 1.0.109", @@ -916,7 +917,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" dependencies = [ - "num-bigint", + "num-bigint 0.4.6", "num-traits", "proc-macro2", "quote", @@ -929,7 +930,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3" dependencies = [ - "num-bigint", + "num-bigint 0.4.6", "num-traits", "proc-macro2", "quote", @@ -954,7 +955,7 @@ checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" dependencies = [ "ark-std 0.4.0", "digest 0.10.7", - "num-bigint", + "num-bigint 0.4.6", ] [[package]] @@ -966,7 +967,7 @@ dependencies = [ "ark-std 0.5.0", "arrayvec", "digest 0.10.7", - "num-bigint", + "num-bigint 0.4.6", ] [[package]] @@ -1196,6 +1197,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base64" version = "0.22.1" @@ -1284,7 +1291,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]] @@ -1553,6 +1560,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1613,19 +1626,35 @@ 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", "subtle", "zeroize", ] +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "serdect 0.4.3", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" 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", ] @@ -1647,6 +1676,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" dependencies = [ "cmov", + "subtle", ] [[package]] @@ -1809,7 +1839,7 @@ dependencies = [ "asn1-rs", "displaydoc", "nom", - "num-bigint", + "num-bigint 0.4.6", "num-traits", "rusticata-macros", ] @@ -1928,7 +1958,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]] @@ -2006,9 +2036,9 @@ checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der 0.7.10", "digest 0.10.7", - "elliptic-curve", + "elliptic-curve 0.13.8", "rfc6979", - "serdect", + "serdect 0.2.0", "signature 2.2.0", "spki 0.7.3", ] @@ -2040,20 +2070,52 @@ version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "base16ct", - "crypto-bigint", + "base16ct 0.2.0", + "crypto-bigint 0.5.5", "digest 0.10.7", - "ff", - "generic-array", - "group", + "ff 0.13.1", + "generic-array 0.14.7", + "group 0.13.0", "pkcs8 0.10.2", "rand_core 0.6.4", - "sec1", - "serdect", + "sec1 0.7.3", + "serdect 0.2.0", "subtle", "zeroize", ] +[[package]] +name = "elliptic-curve" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" +dependencies = [ + "base16ct 1.0.0", + "crypto-bigint 0.7.5", + "crypto-common 0.2.1", + "ff 0.14.0", + "group 0.14.0", + "hybrid-array", + "pkcs8 0.11.0", + "rand_core 0.10.1", + "sec1 0.8.1", + "serdect 0.4.3", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve-tools" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0d5e534f103b079a71ef1d66c6e62c89413f1b62709ca11f744429b4afe5b4" +dependencies = [ + "elliptic-curve 0.14.1", + "heapless", + "serde", + "serdect 0.4.3", +] + [[package]] name = "enum-ordinalize" version = "4.3.2" @@ -2128,6 +2190,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -2324,6 +2396,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "generic-array" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb130435a959a8d525e6bca66ff6c40981a300ee96d70e3ef56f046556d614a3" +dependencies = [ + "rustversion", + "serde_core", + "typenum", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -2383,11 +2466,22 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ - "ff", + "ff 0.13.1", "rand_core 0.6.4", "subtle", ] +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "h2" version = "0.4.13" @@ -2407,6 +2501,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" @@ -2447,6 +2550,16 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -2539,11 +2652,13 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hybrid-array" -version = "0.4.11" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ "ctutils", + "serde", + "subtle", "typenum", "zeroize", ] @@ -2809,7 +2924,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]] @@ -2946,17 +3061,17 @@ checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" dependencies = [ "cfg-if", "ecdsa", - "elliptic-curve", + "elliptic-curve 0.13.8", "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=3bbebac#3bbebac9f2a943be75588d80826e6bea45079d5f" dependencies = [ "downcast-rs", "dyn-clone", @@ -2969,8 +3084,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=3bbebac#3bbebac9f2a943be75588d80826e6bea45079d5f" dependencies = [ "futures", "glob", @@ -2981,14 +3096,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=3bbebac#3bbebac9f2a943be75588d80826e6bea45079d5f" dependencies = [ - "darling 0.23.0", "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.5", ] [[package]] @@ -3332,6 +3446,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" +dependencies = [ + "num-integer", + "num-traits", + "serde", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -4226,7 +4351,7 @@ dependencies = [ "bytes", "fastrlp 0.3.1", "fastrlp 0.4.0", - "num-bigint", + "num-bigint 0.4.6", "num-integer", "num-traits", "parity-scale-codec", @@ -4453,11 +4578,26 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "base16ct", + "base16ct 0.2.0", "der 0.7.10", - "generic-array", + "generic-array 0.14.7", "pkcs8 0.10.2", - "serdect", + "serdect 0.2.0", + "subtle", + "zeroize", +] + +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct 1.0.0", + "ctutils", + "der 0.8.0", + "hybrid-array", + "serdect 0.4.3", "subtle", "zeroize", ] @@ -4619,7 +4759,17 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" dependencies = [ - "base16ct", + "base16ct 0.2.0", + "serde", +] + +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct 1.0.0", "serde", ] @@ -4665,6 +4815,17 @@ dependencies = [ "keccak 0.2.0", ] +[[package]] +name = "sha3" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" +dependencies = [ + "digest 0.11.2", + "keccak 0.2.0", + "sponge-cursor", +] + [[package]] name = "sha3-asm" version = "0.1.6" @@ -4675,6 +4836,17 @@ dependencies = [ "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 0.2.0", + "sponge-cursor", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -4808,6 +4980,12 @@ dependencies = [ "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 = "sqlite-wasm-rs" version = "0.5.3" @@ -4935,6 +5113,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn-solidity" version = "1.5.7" @@ -5575,6 +5764,29 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsss-rs" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bfc736cfd88115aedb95ba84bc2d428fe351e92a56f69fce090af301402d91" +dependencies = [ + "crypto-bigint 0.7.5", + "elliptic-curve 0.14.1", + "elliptic-curve-tools", + "ff 0.14.0", + "generic-array 1.4.2", + "hex", + "hybrid-array", + "num-bigint 0.5.1", + "num-traits", + "rand_core 0.10.1", + "serde", + "sha3 0.12.0", + "shake", + "subtle", + "zeroize", +] + [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/server/Cargo.toml b/server/Cargo.toml index ab81ee3..34f738d 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 = "3bbebac"} +kameo_actors = {git = "https://github.com/hdbg/kameo.git", rev = "3bbebac"} hmac = "0.13.0" miette = { version = "7.6.0", features = ["fancy", "serde"] } ml-dsa = { version = "0.1.0-rc.9", features = ["zeroize"] } @@ -78,6 +78,7 @@ pub_underscore_fields = "allow" redundant_pub_crate = "allow" uninhabited_references = "allow" # safe with unsafe_code = "forbid" and standard uninhabited pattern (match *self {}) too-many-lines = "allow" # this is a very common pattern in server code, and it's not always possible to break it down into smaller modules without hurting readability +unused_async_trait_impl = "allow" # too pedantic # restriction lints alloc_instead_of_core = "warn" diff --git a/server/crates/arbiter-client/src/client.rs b/server/crates/arbiter-client/src/client.rs index 7269772..cfacfb2 100644 --- a/server/crates/arbiter-client/src/client.rs +++ b/server/crates/arbiter-client/src/client.rs @@ -94,7 +94,6 @@ impl ArbiterClient { } #[cfg(feature = "evm")] - #[expect(clippy::unused_async, reason = "false positive")] pub async fn evm_wallets(&self) -> Result, ArbiterClientError> { todo!("fetch EVM wallet list from server") } 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 e3a202c..3322b29 100644 --- a/server/crates/arbiter-server/Cargo.toml +++ b/server/crates/arbiter-server/Cargo.toml @@ -51,6 +51,7 @@ subtle = "2.6.1" x25519-dalek.workspace = true k256.workspace = true kameo_actors.workspace = true +vsss-rs = "6.0.1" [dev-dependencies] proptest = "1.11.0" 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..cddc2dc 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,8 @@ 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 integer ) STRICT; insert into arbiter_settings (id) values (1) on conflict do nothing; @@ -56,6 +57,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')) diff --git a/server/crates/arbiter-server/src/actors/bootstrap.rs b/server/crates/arbiter-server/src/actors/bootstrap.rs index 40186bd..e35ddbb 100644 --- a/server/crates/arbiter-server/src/actors/bootstrap.rs +++ b/server/crates/arbiter-server/src/actors/bootstrap.rs @@ -1,119 +1,160 @@ -use crate::db::{self, DatabasePool, schema}; +use crate::{ + actors::vault::events, + db::{self, schema}, +}; use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; use arbiter_proto::{BOOTSTRAP_PATH, home_path}; use diesel::QueryDsl; use diesel_async::RunQueryDsl; -use kameo::{Actor, messages}; +use kameo::{ + Actor, + actor::ActorRef, + messages, + prelude::{Context, Message}, +}; +use kameo_actors::message_bus::{MessageBus, Register}; use rand::{RngExt, distr::Alphanumeric, rngs::SysRng}; use rand_core::UnwrapErr; use std::path::{Path, PathBuf}; use subtle::ConstantTimeEq as _; -use thiserror::Error; use tracing::warn; const TOKEN_LENGTH: usize = 64; async fn write_token_file(path: &Path, content: &str) -> Result<(), std::io::Error> { + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } tokio::fs::write(path, content.as_bytes()).await?; - #[cfg(unix)] { use std::os::unix::fs::PermissionsExt as _; tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await?; } - Ok(()) } +async fn remove_token_file(path: &Path) { + match tokio::fs::remove_file(path).await { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => warn!(?error, ?path, "Failed to remove bootstrap token file"), + } +} + async fn generate_token(path: &Path) -> Result, std::io::Error> { let mut cell = SafeCell::new([0u8; TOKEN_LENGTH]); { let mut buf = cell.write(); - for (slot, b) in buf + for (slot, byte) in buf .iter_mut() .zip(UnwrapErr(SysRng).sample_iter(Alphanumeric)) { - *slot = b; + *slot = byte; } } - - let token_str = cell.read_inline(|buf| String::from_utf8_lossy(buf.as_ref()).into_owned()); - - write_token_file(path, &token_str).await?; - + let token = cell.read_inline(|buf| String::from_utf8_lossy(buf.as_ref()).into_owned()); + write_token_file(path, &token).await?; Ok(cell) } -#[derive(Error, Debug)] +#[derive(Debug, thiserror::Error)] pub enum Error { #[error("Database error: {0}")] Database(#[from] db::PoolError), - #[error("I/O error: {0}")] Io(#[from] std::io::Error), - #[error("Database query error: {0}")] Query(#[from] diesel::result::Error), } -#[derive(Actor)] +/// Custodian of the one-time bootstrap token. +/// +/// The token authorises registering operator identities before the vault +/// exists. A Shamir committee needs every member registered before it can be +/// declared, so the token stays valid across several registrations and is +/// retired by the `Bootstrapped` event rather than by first use, whichever +/// bootstrap path fired it. +/// +/// Every daemon start mints a fresh token and overwrites the file: a token +/// handed out by an earlier run is dead. pub struct Bootstrapper { token: Option>, token_path: Option, + events: ActorRef, } -impl Bootstrapper { - pub async fn new(db: &DatabasePool) -> Result { - let row_count: i64 = { - let mut conn = db.get().await?; +impl Actor for Bootstrapper { + type Args = Self; + type Error = std::convert::Infallible; - schema::operator::table - .count() - .get_result(&mut conn) - .await? - }; - - let (token, token_path) = if row_count == 0 { - let path = home_path()?.join(BOOTSTRAP_PATH); - let token = generate_token(&path).await?; - (Some(token), Some(path)) - } else { - (None, None) - }; - - Ok(Self { token, token_path }) + async fn on_start(args: Self::Args, actor_ref: ActorRef) -> Result { + let _ = args + .events + .tell(Register(actor_ref.recipient::())) + .await; + Ok(args) } } impl Bootstrapper { - fn is_correct_token(&mut self, token: &[u8]) -> bool { - self.token.as_mut().is_some_and(|expected| { - expected.read_inline(|exp| bool::from(exp.as_ref().ct_eq(token))) + pub async fn new(db: &db::DatabasePool, events: ActorRef) -> Result { + let path = home_path()?.join(BOOTSTRAP_PATH); + let mut conn = db.get().await?; + + let bootstrapped = schema::arbiter_settings::table + .select(schema::arbiter_settings::root_key_id) + .first::>(&mut conn) + .await? + .is_some(); + if bootstrapped { + // A token file can outlive the bootstrap that made it obsolete: + // the daemon may have been killed before the event was handled. + remove_token_file(&path).await; + return Ok(Self { + token: None, + token_path: None, + events, + }); + } + + Ok(Self { + token: Some(generate_token(&path).await?), + token_path: Some(path), + events, }) } -} -#[messages] -impl Bootstrapper { - #[message] - pub async fn consume_token(&mut self, token: Vec) -> bool { - if self.is_correct_token(&token) { - self.token = None; - if let Some(path) = self.token_path.take() - && let Err(e) = tokio::fs::remove_file(&path).await - { - warn!(error = ?e, path = ?path, "Failed to delete bootstrap token file after consumption"); - } - true - } else { - false + async fn forget(&mut self) { + self.token = None; + if let Some(path) = self.token_path.take() { + remove_token_file(&path).await; } } } +impl Message for Bootstrapper { + type Reply = (); + + async fn handle( + &mut self, + _: events::Bootstrapped, + _ctx: &mut Context, + ) -> Self::Reply { + self.forget().await; + } +} + #[messages] impl Bootstrapper { + #[message] + pub fn verify_token(&mut self, token: Vec) -> bool { + self.token.as_mut().is_some_and(|expected| { + expected.read_inline(|bytes| bool::from(bytes.as_ref().ct_eq(token.as_slice()))) + }) + } + #[message] pub fn get_token(&mut self) -> Option { self.token diff --git a/server/crates/arbiter-server/src/actors/mod.rs b/server/crates/arbiter-server/src/actors/mod.rs index e9900ae..292c1ae 100644 --- a/server/crates/arbiter-server/src/actors/mod.rs +++ b/server/crates/arbiter-server/src/actors/mod.rs @@ -1,11 +1,16 @@ use crate::{ actors::{ bootstrap::Bootstrapper, evm::EvmActor, flow_coordinator::FlowCoordinator, - operator_registry::OperatorRegistry, vault::Vault, + operator_registry::OperatorRegistry, vault::Vault, vault_coordinator::VaultCoordinator, + }, + db::{ + self, + custody::{CustodyStore, DieselCustodyStore}, }, - db, }; +use std::sync::Arc; + use kameo::actor::{ActorRef, Spawn}; use kameo_actors::{DeliveryStrategy, message_bus::MessageBus}; use thiserror::Error; @@ -15,20 +20,22 @@ pub mod evm; pub mod flow_coordinator; pub mod operator_registry; pub mod vault; +pub mod vault_coordinator; #[derive(Error, Debug)] pub enum SpawnError { #[error("Failed to spawn Bootstrapper actor")] Bootstrapper(#[from] bootstrap::Error), - #[error("Failed to spawn Vault actor")] Vault(#[from] vault::Error), + #[error("Failed to spawn VaultCoordinator actor")] + VaultCoordinator(#[from] vault_coordinator::Error), } -/// Long-lived actors that are shared across all connections and handle global state and operations #[derive(Clone)] pub struct GlobalActors { pub vault: ActorRef, + pub vault_coordinator: ActorRef, pub bootstrapper: ActorRef, pub flow_coordinator: ActorRef, pub operator_registry: ActorRef, @@ -42,18 +49,24 @@ impl GlobalActors { } pub async fn spawn(db: db::DatabasePool) -> Result { - let message_bus = Self::spawn_message_bus(); - let key_holder = Vault::spawn(Vault::new(db.clone(), message_bus.clone()).await?); + let events = Self::spawn_message_bus(); + let custody: Arc = Arc::new(DieselCustodyStore); + let vault = + Vault::spawn(Vault::new(db.clone(), events.clone(), Arc::clone(&custody)).await?); + let bootstrapper = Bootstrapper::spawn(Bootstrapper::new(&db, events.clone()).await?); + let vault_coordinator = + VaultCoordinator::spawn(VaultCoordinator::new(db.clone(), vault.clone(), custody)); let operator_registry = OperatorRegistry::spawn(OperatorRegistry::default()); Ok(Self { - bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?), - evm: EvmActor::spawn(EvmActor::new(key_holder.clone(), db)), - vault: key_holder, + bootstrapper, + evm: EvmActor::spawn(EvmActor::new(vault.clone(), db.clone())), + vault, + vault_coordinator, flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::new( operator_registry.clone(), )), operator_registry, - events: message_bus, + events, }) } } diff --git a/server/crates/arbiter-server/src/actors/operator_registry.rs b/server/crates/arbiter-server/src/actors/operator_registry.rs index f25ebbc..e988b2d 100644 --- a/server/crates/arbiter-server/src/actors/operator_registry.rs +++ b/server/crates/arbiter-server/src/actors/operator_registry.rs @@ -20,8 +20,8 @@ impl Actor for OperatorRegistry { type Error = Infallible; - async fn on_start(args: Self::Args, _: ActorRef) -> Result { - Ok(args) + fn on_start(args: Self::Args, _: ActorRef) -> impl Future> { + std::future::ready(Ok(args)) } async fn on_link_died( diff --git a/server/crates/arbiter-server/src/actors/vault/mod.rs b/server/crates/arbiter-server/src/actors/vault/mod.rs index 1be509f..d92eb70 100644 --- a/server/crates/arbiter-server/src/actors/vault/mod.rs +++ b/server/crates/arbiter-server/src/actors/vault/mod.rs @@ -1,16 +1,19 @@ use crate::{ crypto::{ - KeyCell, derive_key, + KeyCell, encryption::v1::{self, Nonce}, integrity::v1::HmacSha256, }, db::{ self, + custody::{CustodyRecord, CustodyStore}, models::{self, RootKeyHistory, RootKeyHistoryId}, schema::{self}, }, }; + use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; +use std::sync::Arc; use chrono::Utc; use diesel::{ @@ -60,6 +63,9 @@ pub enum Error { #[error("Database transaction error: {0}")] DatabaseTransaction(#[from] diesel::result::Error), + #[error("Custody storage error: {0}")] + Custody(#[from] db::custody::Error), + #[error("Broken database")] BrokenDatabase, @@ -95,12 +101,17 @@ pub struct Vault { db: db::DatabasePool, state: State, events: ActorRef, + custody: Arc, unseal_failures: u32, } #[messages] impl Vault { - pub async fn new(db: db::DatabasePool, events: ActorRef) -> Result { + pub async fn new( + db: db::DatabasePool, + events: ActorRef, + custody: Arc, + ) -> Result { let state = { let mut conn = db.get().await?; @@ -118,7 +129,13 @@ impl Vault { } }; - Ok(Self { db, state, events, unseal_failures: 0 }) + Ok(Self { + db, + state, + events, + custody, + unseal_failures: 0, + }) } // Exclusive transaction to avoid race condtions if multiple vaults write @@ -167,13 +184,16 @@ impl Vault { } } + /// Create the root key and take the vault into the unsealed state. #[message] - pub async fn bootstrap(&mut self, seal_key_raw: SafeCell>) -> Result<(), Error> { + pub async fn bootstrap( + &mut self, + mut seal_key: KeyCell, + custody: Option, + ) -> 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 @@ -193,6 +213,7 @@ impl Vault { let mut conn = self.db.get().await?; let data_encryption_nonce_bytes = data_encryption_nonce.to_vec(); + let custody_store = Arc::clone(&self.custody); let root_key_history_id = conn .transaction(async |conn| { let root_key_history_id = insert_into(schema::root_key_history::table) @@ -202,7 +223,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: v1::generate_salt().to_vec(), }) .returning(schema::root_key_history::id) .get_result(&mut *conn) @@ -213,9 +234,11 @@ impl Vault { .execute(&mut *conn) .await?; - Result::<_, diesel::result::Error>::Ok(RootKeyHistoryId::from_raw( - root_key_history_id, - )) + if let Some(record) = custody.as_ref() { + custody_store.write_record(&mut *conn, record).await?; + } + + Result::<_, Error>::Ok(RootKeyHistoryId::from_raw(root_key_history_id)) }) .await?; @@ -231,7 +254,7 @@ impl Vault { } #[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> { if self.unseal_failures >= MAX_UNSEAL_ATTEMPTS { return Err(Error::LockedOut); } @@ -253,13 +276,6 @@ impl Vault { .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 = @@ -441,18 +457,20 @@ impl Vault { #[cfg(test)] mod tests { - use crate::actors::GlobalActors; - use crate::db::models::RootKeyHistory; - use arbiter_crypto::safecell::SafeCellHandle as _; + use crate::{actors::GlobalActors, db::custody::DieselCustodyStore}; use super::*; async fn bootstrapped_actor(db: &db::DatabasePool) -> Vault { - 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(); + let mut actor = Vault::new( + db.clone(), + GlobalActors::spawn_message_bus(), + Arc::new(DieselCustodyStore), + ) + .await + .unwrap(); + let seal_key = KeyCell::from([0u8; 32]); + actor.bootstrap(seal_key, None).await.unwrap(); actor } 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..a3f6aa3 --- /dev/null +++ b/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs @@ -0,0 +1,397 @@ +//! Coordinates the multi-operator ceremonies that create and open the vault. +//! +//! The coordinator collects one passphrase per committee member, then hands the +//! assembled material to [`Vault`] in a single message. It owns no Diesel code: +//! everything it reads or writes goes through [`CustodyStore`]. + +use std::sync::Arc; + +use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; +use argon2::RECOMMENDED_SALT_LEN; +use kameo::{Actor, actor::ActorRef, error::SendError, messages}; +use rand::rngs::SysRng; +use rand_core::{Rng as _, UnwrapErr}; + +use crate::{ + actors::vault::{self, Bootstrap, TryUnseal, Vault}, + crypto::{KeyCell, derive_key, encryption::v1::Nonce, shamir}, + db::{ + self, + custody::{CustodyRecord, CustodyStore, EncryptedShare}, + models::OperatorId, + }, +}; + +const SHARE_AAD: &[u8] = b"arbiter/shamir-share/v1"; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("An ordinary committee is already being coordinated")] + AlreadyBootstrapping, + #[error("An unseal is already being coordinated")] + AlreadyUnsealing, + #[error("Bootstrap is not in progress")] + NotBootstrapping, + #[error("The operator already contributed")] + DuplicateContribution, + #[error("The ordinary committee cannot be empty")] + EmptyCommittee, + #[error("Two-operator committees are unsupported")] + UnsupportedCommittee, + #[error( + "The ordinary committee cannot exceed {} members", + shamir::MAX_COMMITTEE_SIZE + )] + CommitteeTooLarge, + #[error("Invalid passphrase")] + InvalidPassphrase, + #[error("Broken database")] + BrokenDatabase, + #[error("Shamir error: {0}")] + Shamir(String), + #[error("Database connection error: {0}")] + DatabaseConnection(#[from] db::PoolError), + #[error("Custody storage error: {0}")] + Custody(#[from] db::custody::Error), + #[error("Encryption error")] + Encryption, + #[error("The vault is already bootstrapped")] + AlreadyBootstrapped, + #[error("Vault error")] + Vault, +} + +/// Passphrases gathered so far, in contribution order. +/// +/// A `Vec` rather than a map because [`SafeCell`] values are neither cloneable +/// nor hashable, and a committee holds at most +/// [`shamir::MAX_COMMITTEE_SIZE`] of them. +#[derive(Default)] +struct Contributions(Vec<(OperatorId, SafeCell>)>); + +impl Contributions { + fn contains(&self, operator_id: OperatorId) -> bool { + self.0.iter().any(|(id, _)| *id == operator_id) + } + + fn put(&mut self, operator_id: OperatorId, passphrase: SafeCell>) { + match self.0.iter_mut().find(|(id, _)| *id == operator_id) { + Some(slot) => slot.1 = passphrase, + None => self.0.push((operator_id, passphrase)), + } + } + + const fn len(&self) -> usize { + self.0.len() + } + + fn operators(&self) -> Vec { + self.0.iter().map(|(id, _)| *id).collect() + } +} + +enum CoordinatorState { + Idle, + Bootstrapping { + /// The operator that declared the committee. Only they may re-declare + /// it, which is the way out of a ceremony the others never finish. + declarer: OperatorId, + declared_count: usize, + contributions: Contributions, + retryable: bool, + }, + Unsealing { + threshold: usize, + contributions: Contributions, + retryable: bool, + }, +} + +#[derive(Actor)] +pub struct VaultCoordinator { + db: db::DatabasePool, + vault: ActorRef, + custody: Arc, + state: CoordinatorState, +} + +impl VaultCoordinator { + pub fn new( + db: db::DatabasePool, + vault: ActorRef, + custody: Arc, + ) -> Self { + Self { + db, + vault, + custody, + state: CoordinatorState::Idle, + } + } +} + +/// Explain why a committee size was rejected. +const fn committee_error(declared_count: usize) -> Error { + match declared_count { + 0 => Error::EmptyCommittee, + 2 => Error::UnsupportedCommittee, + _ => Error::CommitteeTooLarge, + } +} + +fn encrypt_share( + passphrase: &mut SafeCell>, + share: &[u8], +) -> Result { + let mut salt = [0u8; RECOMMENDED_SALT_LEN]; + UnwrapErr(SysRng).fill_bytes(&mut salt); + + let nonce = Nonce::default(); + let ciphertext = derive_key(passphrase, &salt) + .encrypt(&nonce, SHARE_AAD, share) + .map_err(|_| Error::Encryption)?; + + Ok(EncryptedShare { + ciphertext, + nonce: nonce.to_vec(), + salt: salt.to_vec(), + }) +} + +fn decrypt_share( + passphrase: &mut SafeCell>, + share: EncryptedShare, +) -> Result>, Error> { + let nonce = Nonce::try_from(share.nonce.as_slice()).map_err(|()| Error::BrokenDatabase)?; + + let mut buffer = SafeCell::new(share.ciphertext); + derive_key(passphrase, &share.salt) + .decrypt_in_place(&nonce, SHARE_AAD, &mut buffer) + .map_err(|_| Error::InvalidPassphrase)?; + + Ok(buffer) +} + +const fn bootstrap_error(error: &SendError) -> Error { + match error { + SendError::HandlerError(vault::Error::AlreadyBootstrapped) => Error::AlreadyBootstrapped, + _ => Error::Vault, + } +} + +/// Build the custody record and hand it to the vault, which stores it in the +/// same transaction as the root key. +async fn finalize_bootstrap( + vault: &ActorRef, + contributions: &mut Contributions, +) -> Result<(), Error> { + let total = contributions.len(); + let threshold = shamir::shamir_threshold(total).ok_or_else(|| committee_error(total))?; + + let mut seal_key = KeyCell::new_secure_random(); + let mut shares = shamir::split_key(threshold, total, &mut seal_key, UnwrapErr(SysRng)) + .map_err(|error| Error::Shamir(error.to_string()))?; + + if shares.len() < total { + return Err(Error::Shamir("missing share for operator".to_owned())); + } + + let mut encrypted = Vec::with_capacity(total); + for ((operator_id, passphrase), share) in contributions.0.iter_mut().zip(shares.iter_mut()) { + let share = share.read_inline(|share| encrypt_share(passphrase, share))?; + encrypted.push((*operator_id, share)); + } + + vault + .ask(Bootstrap { + seal_key, + custody: Some(CustodyRecord { + threshold, + shares: encrypted, + }), + }) + .await + .map_err(|error| bootstrap_error(&error)) +} + +/// Reconstruct the seal key from the contributed passphrases and unseal. +async fn finalize_unseal( + db: &db::DatabasePool, + custody: &Arc, + vault: &ActorRef, + threshold: usize, + contributions: &mut Contributions, +) -> Result<(), Error> { + let stored = { + let mut conn = db.get().await?; + custody + .shares(&mut conn, &contributions.operators()) + .await? + }; + + let mut plaintext = Vec::with_capacity(stored.len()); + for ((_, passphrase), share) in contributions.0.iter_mut().zip(stored) { + plaintext.push(decrypt_share(passphrase, share)?); + } + + let seal_key = shamir::combine_shares(threshold, &mut plaintext) + .map_err(|error| Error::Shamir(error.to_string()))?; + + vault + .ask(TryUnseal { seal_key }) + .await + .map_err(|_| Error::Vault) +} + +#[messages] +impl VaultCoordinator { + /// Announce how many operators will contribute to the bootstrap. + /// + /// The declaring operator may re-declare to restart the ceremony; that is + /// the only way to release a committee whose members never all show up. + #[message] + pub fn start_bootstrap( + &mut self, + operator_id: OperatorId, + declared_count: usize, + ) -> Result<(), Error> { + if shamir::shamir_threshold(declared_count).is_none() { + return Err(committee_error(declared_count)); + } + + match &self.state { + CoordinatorState::Unsealing { .. } => return Err(Error::AlreadyUnsealing), + CoordinatorState::Bootstrapping { declarer, .. } if *declarer != operator_id => { + return Err(Error::AlreadyBootstrapping); + } + CoordinatorState::Bootstrapping { .. } | CoordinatorState::Idle => {} + } + + self.state = CoordinatorState::Bootstrapping { + declarer: operator_id, + declared_count, + contributions: Contributions::default(), + retryable: false, + }; + Ok(()) + } + + #[message] + pub async fn contribute_bootstrap( + &mut self, + operator_id: OperatorId, + passphrase: SafeCell>, + ) -> Result { + let CoordinatorState::Bootstrapping { + declared_count, + contributions, + retryable, + .. + } = &mut self.state + else { + return Err(Error::NotBootstrapping); + }; + + if contributions.contains(operator_id) && !*retryable { + return Err(Error::DuplicateContribution); + } + contributions.put(operator_id, passphrase); + *retryable = false; + + if contributions.len() < *declared_count { + return Ok(false); + } + + let state = std::mem::replace(&mut self.state, CoordinatorState::Idle); + let CoordinatorState::Bootstrapping { + declarer, + declared_count, + mut contributions, + .. + } = state + else { + unreachable!("state was matched as Bootstrapping above") + }; + + match finalize_bootstrap(&self.vault, &mut contributions).await { + Ok(()) => Ok(true), + Err(error) => { + self.state = CoordinatorState::Bootstrapping { + declarer, + declared_count, + contributions, + retryable: true, + }; + Err(error) + } + } + } + + #[message] + pub async fn contribute_unseal( + &mut self, + operator_id: OperatorId, + passphrase: SafeCell>, + ) -> Result { + if matches!(self.state, CoordinatorState::Idle) { + let threshold = { + let mut conn = self.db.get().await?; + self.custody.threshold(&mut conn).await? + }; + self.state = CoordinatorState::Unsealing { + threshold, + contributions: Contributions::default(), + retryable: false, + }; + } + + let CoordinatorState::Unsealing { + threshold, + contributions, + retryable, + } = &mut self.state + else { + return Err(Error::AlreadyBootstrapping); + }; + + if contributions.contains(operator_id) && !*retryable { + return Err(Error::DuplicateContribution); + } + contributions.put(operator_id, passphrase); + *retryable = false; + + if contributions.len() < *threshold { + return Ok(false); + } + + let state = std::mem::replace(&mut self.state, CoordinatorState::Idle); + let CoordinatorState::Unsealing { + threshold, + mut contributions, + .. + } = state + else { + unreachable!("state was matched as Unsealing above") + }; + + match finalize_unseal( + &self.db, + &self.custody, + &self.vault, + threshold, + &mut contributions, + ) + .await + { + Ok(()) => Ok(true), + Err(error) => { + self.state = CoordinatorState::Unsealing { + threshold, + contributions, + retryable: true, + }; + Err(error) + } + } + } +} diff --git a/server/crates/arbiter-server/src/crypto/encryption/v1.rs b/server/crates/arbiter-server/src/crypto/encryption/v1.rs index 0dac366..f6b6f45 100644 --- a/server/crates/arbiter-server/src/crypto/encryption/v1.rs +++ b/server/crates/arbiter-server/src/crypto/encryption/v1.rs @@ -61,12 +61,11 @@ 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 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 password, &salt); let key1_reader = key1.0.read(); let key2_reader = key2.0.read(); @@ -77,10 +76,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/integrity/v1.rs b/server/crates/arbiter-server/src/crypto/integrity/v1.rs index 0b053d9..66c1f01 100644 --- a/server/crates/arbiter-server/src/crypto/integrity/v1.rs +++ b/server/crates/arbiter-server/src/crypto/integrity/v1.rs @@ -206,6 +206,9 @@ pub async fn is_signing_available(vault: &ActorRef) -> Result ActorRef { let actor = Vault::spawn( - Vault::new(db.clone(), GlobalActors::spawn_message_bus()) - .await - .unwrap(), + Vault::new( + db.clone(), + GlobalActors::spawn_message_bus(), + Arc::new(DieselCustodyStore), + ) + .await + .unwrap(), ); actor .ask(Bootstrap { - seal_key_raw: SafeCell::new(b"integrity-test-seal-key".to_vec()), + seal_key: KeyCell::from([0u8; 32]), + custody: None, }) .await .unwrap(); diff --git a/server/crates/arbiter-server/src/crypto/mod.rs b/server/crates/arbiter-server/src/crypto/mod.rs index 440cb24..3c3c5eb 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::{ @@ -13,6 +13,7 @@ use rand::{ pub mod encryption; pub mod integrity; +pub mod shamir; pub struct KeyCell(pub SafeCell); impl From> for KeyCell { @@ -20,6 +21,16 @@ impl From> for KeyCell { Self(value) } } + +impl From<[u8; 32]> for KeyCell { + fn from(bytes: [u8; 32]) -> Self { + let cell = SafeCell::new_inline(|key: &mut Key| { + key.copy_from_slice(&bytes); + }); + Self(cell) + } +} + impl TryFrom>> for KeyCell { type Error = (); @@ -58,6 +69,7 @@ impl KeyCell { let buffer = buffer.as_mut(); cipher.encrypt_in_place(nonce, associated_data, buffer) } + pub fn decrypt_in_place( &mut self, nonce: &Nonce, @@ -93,8 +105,11 @@ 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 { +/// Derive a fixed-length key from a passphrase using Argon2id. +/// +/// The passphrase is borrowed so that callers can keep it in protected memory +/// and reuse it across retries instead of handing over a copy. +pub fn derive_key(password: &mut SafeCell>, salt: &[u8]) -> KeyCell { let params = { #[cfg(debug_assertions)] { @@ -132,11 +147,11 @@ 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 nonce = Nonce(*b"unique nonce 123 1231233"); // 24 bytes for XChaCha20Poly1305 + let mut key = derive_key(&mut password, &salt); + let nonce = Nonce(*b"unique nonce 123 1231233"); 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..b2ed84d --- /dev/null +++ b/server/crates/arbiter-server/src/crypto/shamir.rs @@ -0,0 +1,221 @@ +use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; +use rand_core::CryptoRng; +use vsss_rs::Gf256; + +use crate::crypto::KeyCell; + +/// GF(256) addresses shares by a non-zero byte, so no committee can exceed 255. +pub const MAX_COMMITTEE_SIZE: usize = 255; + +/// Errors returned by Shamir split/combine operations. +#[derive(Debug, thiserror::Error)] +pub enum ShamirError { + #[error("failed to split key: {0}")] + Split(String), + #[error("failed to combine shares: {0}")] + Combine(String), +} + +/// Return the required threshold for a Shamir share pool of `committee_size`. +/// +/// A pool of two is rejected: a majority of two is two, which gives each holder +/// a veto over every unseal without giving either one recovery. That rejects no +/// supported committee, because a two-operator vault must carry at least one +/// recovery share and so never splits into a pool of two -- see +/// `docs/ARCHITECTURE.md` 3.9. +#[expect( + clippy::integer_division, + reason = "majority thresholds use integer arithmetic" +)] +#[must_use] +pub const fn shamir_threshold(committee_size: usize) -> Option { + match committee_size { + 0 | 2 => None, + size if size > MAX_COMMITTEE_SIZE => None, + 1 => Some(1), + size => Some(size / 2 + 1), + } +} + +/// Split a seal key into `total` shares, `threshold` of which reconstruct it. +pub fn split_key( + threshold: usize, + total: usize, + key: &mut KeyCell, + rng: impl CryptoRng, +) -> Result>>, ShamirError> { + if total == 0 || threshold == 0 || threshold > total || total == 2 || total > MAX_COMMITTEE_SIZE + { + return Err(ShamirError::Split( + "unsupported committee parameters".to_owned(), + )); + } + + // Nothing to interpolate when one share suffices. + if threshold == 1 { + return Ok(key.0.read_inline(|key| { + std::iter::repeat_with(|| SafeCell::new(key.as_slice().to_vec())) + .take(total) + .collect() + })); + } + + key.0.read_inline(|key| { + let key: &[u8; 32] = key + .as_slice() + .try_into() + .map_err(|_| ShamirError::Split("unexpected seal key length".to_owned()))?; + + Gf256::split_array(threshold, total, key, rng) + .map(|shares| shares.into_iter().map(SafeCell::new).collect()) + .map_err(|error| ShamirError::Split(format!("{error:?}"))) + }) +} + +/// Combine shares back into the seal key. +/// +/// `threshold` comes from storage rather than from the shapes of the shares: +/// a one-of-one committee stores the key verbatim, and telling that apart by +/// share length alone would misread any Shamir share that happened to be key +/// sized. +pub fn combine_shares( + threshold: usize, + shares: &mut [SafeCell>], +) -> Result { + if threshold == 0 { + return Err(ShamirError::Combine("threshold is zero".to_owned())); + } + if shares.len() < threshold { + return Err(ShamirError::Combine( + "not enough shares supplied".to_owned(), + )); + } + + // Mirror of the one-of-one case in [`split_key`]: the share is the key. + if threshold == 1 { + let share = shares + .first_mut() + .ok_or_else(|| ShamirError::Combine("no shares supplied".to_owned()))?; + return reconstructed_key(share.read_inline(|share| SafeCell::new(share.clone()))); + } + + let mut gathered = SafeCell::new(Vec::with_capacity(shares.len())); + for share in shares.iter_mut() { + share.read_inline(|share| { + gathered.write_inline(|gathered| gathered.push(share.clone())); + }); + } + + let combined = gathered.read_inline(|gathered| { + Gf256::combine_array(gathered.as_slice()) + .map(SafeCell::new) + .map_err(|error| ShamirError::Combine(format!("{error:?}"))) + })?; + + reconstructed_key(combined) +} + +fn reconstructed_key(bytes: SafeCell>) -> Result { + KeyCell::try_from(bytes) + .map_err(|()| ShamirError::Combine("unexpected reconstructed key length".to_owned())) +} + +#[cfg(test)] +mod tests { + use super::{MAX_COMMITTEE_SIZE, combine_shares, shamir_threshold, split_key}; + use crate::crypto::KeyCell; + use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; + use rand::rngs::SysRng; + use rand_core::UnwrapErr; + use rstest::rstest; + + fn key_bytes(mut key: KeyCell) -> [u8; 32] { + key.0.read_inline(|key| { + let mut bytes = [0u8; 32]; + bytes.copy_from_slice(key.as_slice()); + bytes + }) + } + + fn select(shares: &mut [SafeCell>], indexes: &[usize]) -> Vec>> { + indexes + .iter() + .filter_map(|index| { + shares + .get_mut(*index) + .map(|share| share.read_inline(|share| SafeCell::new(share.clone()))) + }) + .collect() + } + + #[rstest] + #[case(&[0, 1])] + #[case(&[0, 2])] + #[case(&[1, 2])] + fn threshold_shares_reconstruct_fixed_key(#[case] indexes: &[usize]) { + let expected = [9_u8; 32]; + let mut key = KeyCell::from(expected); + let rng = UnwrapErr(SysRng); + let mut shares = split_key(2, 3, &mut key, rng).expect("split should succeed"); + let mut selected = select(&mut shares, indexes); + let combined = combine_shares(2, &mut selected).expect("combine should succeed"); + assert_eq!(key_bytes(combined), expected); + } + + #[test] + fn one_of_one_round_trips_a_fixed_size_key() { + let expected = [7_u8; 32]; + let mut key = KeyCell::from(expected); + let rng = UnwrapErr(SysRng); + let mut shares = split_key(1, 1, &mut key, rng).expect("split should succeed"); + let combined = combine_shares(1, &mut shares).expect("combine should succeed"); + assert_eq!(key_bytes(combined), expected); + } + + #[test] + fn fewer_shares_than_threshold_is_rejected() { + let mut key = KeyCell::from([3_u8; 32]); + let rng = UnwrapErr(SysRng); + let mut shares = split_key(3, 5, &mut key, rng).expect("split should succeed"); + let mut selected = select(&mut shares, &[0, 1]); + assert!( + combine_shares(3, &mut selected).is_err(), + "two of three shares must not reconstruct the key" + ); + } + + #[rstest] + #[case(0, None)] + #[case(1, Some(1))] + #[case(2, None)] + #[case(3, Some(2))] + #[case(4, Some(3))] + #[case(MAX_COMMITTEE_SIZE, Some(128))] + #[case(MAX_COMMITTEE_SIZE + 1, None)] + fn committee_threshold_is_a_majority( + #[case] committee_size: usize, + #[case] expected: Option, + ) { + assert_eq!(shamir_threshold(committee_size), expected); + } + + #[test] + fn oversized_committee_is_rejected_by_split() { + let mut key = KeyCell::from([1_u8; 32]); + let rng = UnwrapErr(SysRng); + assert!( + split_key(129, MAX_COMMITTEE_SIZE + 1, &mut key, rng).is_err(), + "committees above the GF(256) share limit must be rejected" + ); + } + + #[test] + fn two_operator_committee_is_explicitly_unsupported() { + let mut key = KeyCell::from([7_u8; 32]); + let rng = UnwrapErr(SysRng); + assert!( + split_key(2, 2, &mut key, rng).is_err(), + "two-operator committees must be rejected" + ); + } +} diff --git a/server/crates/arbiter-server/src/db/custody.rs b/server/crates/arbiter-server/src/db/custody.rs new file mode 100644 index 0000000..01da33d --- /dev/null +++ b/server/crates/arbiter-server/src/db/custody.rs @@ -0,0 +1,162 @@ +//! Storage for Shamir custody material: the reconstruction threshold and the +//! per-operator encrypted shares of the vault seal key. +//! +//! Every query lives behind [`CustodyStore`] so that the actors above it hold +//! no Diesel code of their own. [`CustodyStore::write_record`] borrows the +//! caller's connection instead of taking one from the pool, which lets the +//! vault write custody material inside the same transaction that stores the +//! root key. + +use std::collections::HashMap; + +use async_trait::async_trait; +use diesel::{ExpressionMethods as _, QueryDsl}; +use diesel_async::RunQueryDsl; + +use crate::db::{ + self, + models::{OperatorId, SqliteTimestamp}, + schema, +}; + +/// One Shamir share, encrypted under a key derived from its operator's passphrase. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EncryptedShare { + pub ciphertext: Vec, + pub nonce: Vec, + pub salt: Vec, +} + +/// Everything a bootstrap persists about custody, written as a single unit. +#[derive(Debug)] +pub struct CustodyRecord { + pub threshold: usize, + pub shares: Vec<(OperatorId, EncryptedShare)>, +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Database query error: {0}")] + Query(#[from] diesel::result::Error), + #[error("Stored committee threshold is missing or out of range")] + BrokenThreshold, + #[error("Custody settings row is missing")] + MissingSettings, + #[error("No share stored for operator {0:?}")] + MissingShare(OperatorId), +} + +#[async_trait] +pub trait CustodyStore: std::fmt::Debug + Send + Sync { + /// Persist threshold and shares on the caller's connection, joining any + /// transaction the caller has already opened. + async fn write_record( + &self, + conn: &mut db::DatabaseConnection, + record: &CustodyRecord, + ) -> Result<(), Error>; + + /// Number of shares required to reconstruct the seal key. + async fn threshold(&self, conn: &mut db::DatabaseConnection) -> Result; + + /// Load the shares of `operators` in one query, in the order requested. + async fn shares( + &self, + conn: &mut db::DatabaseConnection, + operators: &[OperatorId], + ) -> Result, Error>; +} + +/// The production [`CustodyStore`], backed by the `SQLite` schema. +#[derive(Debug, Clone, Copy, Default)] +pub struct DieselCustodyStore; + +#[async_trait] +impl CustodyStore for DieselCustodyStore { + async fn write_record( + &self, + conn: &mut db::DatabaseConnection, + record: &CustodyRecord, + ) -> Result<(), Error> { + let threshold = i32::try_from(record.threshold).map_err(|_| Error::BrokenThreshold)?; + + // SQLite has no batch form for REPLACE INTO in diesel, so the rows go + // in one at a time. The caller's transaction still makes them atomic. + let now = SqliteTimestamp::now(); + for (operator_id, share) in &record.shares { + diesel::replace_into(schema::operator::table) + .values(( + schema::operator::id.eq(Some(*operator_id)), + schema::operator::share.eq(&share.ciphertext), + schema::operator::share_nonce.eq(&share.nonce), + schema::operator::share_salt.eq(&share.salt), + schema::operator::created_at.eq(now.clone()), + schema::operator::updated_at.eq(now.clone()), + )) + .execute(&mut *conn) + .await?; + } + + let updated = diesel::update(schema::arbiter_settings::table) + .set(schema::arbiter_settings::shamir_threshold.eq(Some(threshold))) + .execute(&mut *conn) + .await?; + if updated != 1 { + return Err(Error::MissingSettings); + } + + Ok(()) + } + + async fn threshold(&self, conn: &mut db::DatabaseConnection) -> Result { + let stored: Option = schema::arbiter_settings::table + .select(schema::arbiter_settings::shamir_threshold) + .first(conn) + .await?; + + stored + .and_then(|value| usize::try_from(value).ok()) + .filter(|threshold| *threshold > 0) + .ok_or(Error::BrokenThreshold) + } + + async fn shares( + &self, + conn: &mut db::DatabaseConnection, + operators: &[OperatorId], + ) -> Result, Error> { + let wanted: Vec> = operators.iter().copied().map(Some).collect(); + + let rows: Vec<(Option, Vec, Vec, Vec)> = schema::operator::table + .filter(schema::operator::id.eq_any(wanted)) + .select(( + schema::operator::id, + schema::operator::share, + schema::operator::share_nonce, + schema::operator::share_salt, + )) + .load(conn) + .await?; + + let mut found: HashMap = rows + .into_iter() + .filter_map(|(id, ciphertext, nonce, salt)| { + id.map(|id| { + ( + id, + EncryptedShare { + ciphertext, + nonce, + salt, + }, + ) + }) + }) + .collect(); + + operators + .iter() + .map(|id| found.remove(id).ok_or(Error::MissingShare(*id))) + .collect() + } +} diff --git a/server/crates/arbiter-server/src/db/mod.rs b/server/crates/arbiter-server/src/db/mod.rs index ef7cb56..8d10819 100644 --- a/server/crates/arbiter-server/src/db/mod.rs +++ b/server/crates/arbiter-server/src/db/mod.rs @@ -8,6 +8,7 @@ use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations}; use thiserror::Error; use tracing::info; +pub mod custody; pub mod models; pub mod schema; @@ -154,3 +155,39 @@ pub async fn create_test_pool() -> DatabasePool { .await .expect("Failed to create test database pool") } + +#[cfg(test)] +mod tests { + use diesel::{ExpressionMethods as _, result::DatabaseErrorKind}; + use diesel_async::RunQueryDsl as _; + + use super::*; + + #[tokio::test] + async fn operator_share_salt_must_be_supplied_by_application() { + let pool = create_test_pool().await; + let mut conn = pool.get().await.expect("pool connection"); + + let operator_id = diesel::insert_into(schema::operator_identity::table) + .values(schema::operator_identity::public_key.eq(vec![1])) + .returning(schema::operator_identity::id) + .get_result::(&mut conn) + .await + .expect("insert operator identity"); + + let error = diesel::insert_into(schema::operator::table) + .values(( + schema::operator::id.eq(operator_id), + schema::operator::share.eq(vec![2]), + schema::operator::share_nonce.eq(vec![3]), + )) + .execute(&mut conn) + .await + .expect_err("operator insert without an application-generated salt must fail"); + + assert!(matches!( + error, + diesel::result::Error::DatabaseError(DatabaseErrorKind::NotNullViolation, _) + )); + } +} diff --git a/server/crates/arbiter-server/src/db/models.rs b/server/crates/arbiter-server/src/db/models.rs index 557d579..ba04ca3 100644 --- a/server/crates/arbiter-server/src/db/models.rs +++ b/server/crates/arbiter-server/src/db/models.rs @@ -110,6 +110,18 @@ pub mod types { ToSql::::to_sql(&self.0, out) } } + + impl arbiter_crypto::hashing::Hashable for $name { + fn hash(&self, hasher: &mut H) { + arbiter_crypto::hashing::Hashable::hash(&self.0, hasher); + } + } + + impl crate::crypto::integrity::v1::IntoId for $name { + fn into_id(self) -> Vec { + crate::crypto::integrity::v1::IntoId::into_id(self.0) + } + } }; } @@ -203,6 +215,7 @@ pub struct ArbiterSettings { pub id: i32, pub root_key_id: Option, // references root_key_history.id pub tls_id: Option, // references tls_history.id + pub shamir_threshold: Option, } #[derive(Models, Queryable, Debug, Insertable, Selectable)] @@ -285,6 +298,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, } diff --git a/server/crates/arbiter-server/src/db/schema.rs b/server/crates/arbiter-server/src/db/schema.rs index c2f9869..2772360 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, } } @@ -157,6 +158,7 @@ diesel::table! { id -> Nullable, share -> Binary, share_nonce -> Binary, + share_salt -> Binary, created_at -> Integer, updated_at -> Integer, } diff --git a/server/crates/arbiter-server/src/evm/mod.rs b/server/crates/arbiter-server/src/evm/mod.rs index b606654..930fa04 100644 --- a/server/crates/arbiter-server/src/evm/mod.rs +++ b/server/crates/arbiter-server/src/evm/mod.rs @@ -501,8 +501,10 @@ impl Engine { #[cfg(test)] mod tests { + use std::sync::Arc; + + use crate::db::custody::DieselCustodyStore; use alloy::primitives::{Address, Bytes, U256, address}; - use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; use chrono::{Duration, Utc}; use diesel::{SelectableHelper, insert_into}; use diesel_async::RunQueryDsl; @@ -510,6 +512,7 @@ mod tests { use rstest::rstest; use crate::actors::{GlobalActors, vault::{Bootstrap, Vault}}; + use crate::crypto::KeyCell; use crate::crypto::integrity; use crate::db::{ self, DatabaseConnection, @@ -766,13 +769,18 @@ mod tests { async fn bootstrapped_vault(db: &db::DatabasePool) -> ActorRef { let actor = Vault::spawn( - Vault::new(db.clone(), GlobalActors::spawn_message_bus()) - .await - .unwrap(), + Vault::new( + db.clone(), + GlobalActors::spawn_message_bus(), + Arc::new(DieselCustodyStore), + ) + .await + .unwrap(), ); actor .ask(Bootstrap { - seal_key_raw: SafeCell::new(b"integrity-test-seal-key".to_vec()), + seal_key: KeyCell::from([0u8; 32]), + custody: None, }) .await .unwrap(); diff --git a/server/crates/arbiter-server/src/grpc/operator.rs b/server/crates/arbiter-server/src/grpc/operator.rs index cdd4e80..b5f4e65 100644 --- a/server/crates/arbiter-server/src/grpc/operator.rs +++ b/server/crates/arbiter-server/src/grpc/operator.rs @@ -1,11 +1,10 @@ use crate::{ grpc::request_tracker::RequestTracker, - peers::operator::{OutOfBand, OperatorConnection, OperatorSession}, + peers::operator::{OperatorConnection, OperatorSession, OutOfBand}, }; use arbiter_proto::{ proto::operator::{ - OperatorRequest, OperatorResponse, - operator_request::Payload as OperatorRequestPayload, + OperatorRequest, OperatorResponse, operator_request::Payload as OperatorRequestPayload, operator_response::Payload as OperatorResponsePayload, }, transport::{Error as TransportError, Receiver, Sender, grpc::GrpcBi}, @@ -111,6 +110,9 @@ async fn dispatch_inner( OperatorRequestPayload::Vault(req) => vault::dispatch(actor, req).await, OperatorRequestPayload::Evm(req) => evm::dispatch(actor, req).await, OperatorRequestPayload::SdkClient(req) => sdk_client::dispatch(actor, req).await, + OperatorRequestPayload::Governance(_) => { + Err(Status::permission_denied(stringify!(Governance))) + } OperatorRequestPayload::Auth(..) => { warn!("Unsupported post-auth operator auth request"); Err(Status::invalid_argument("Unsupported operator request")) diff --git a/server/crates/arbiter-server/src/grpc/operator/vault.rs b/server/crates/arbiter-server/src/grpc/operator/vault.rs index ac1c293..69b0e2d 100644 --- a/server/crates/arbiter-server/src/grpc/operator/vault.rs +++ b/server/crates/arbiter-server/src/grpc/operator/vault.rs @@ -3,7 +3,6 @@ use crate::{ peers::operator::{OperatorSession, session::handlers::HandleQueryVaultState}, }; use arbiter_proto::{ - proto::shared::VaultState as ProtoVaultState, proto::operator::{ operator_response::Payload as OperatorResponsePayload, vault::{ @@ -11,6 +10,7 @@ use arbiter_proto::{ response::Payload as VaultResponsePayload, }, }, + proto::shared::VaultState as ProtoVaultState, }; use kameo::actor::ActorRef; @@ -33,11 +33,11 @@ pub(super) async fn dispatch( match payload { VaultRequestPayload::QueryState(()) => handle_query_vault_state(actor).await, - VaultRequestPayload::Unseal(_) | VaultRequestPayload::Bootstrap(_) => { - Err(Status::permission_denied( - "Vault is already unsealed; unseal/bootstrap not permitted in session", - )) - } + VaultRequestPayload::Unseal(_) + | VaultRequestPayload::Bootstrap(_) + | VaultRequestPayload::Rekey(_) => Err(Status::permission_denied( + "Vault is already unsealed; unseal/bootstrap not permitted in session", + )), } } 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..d0af646 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::{ + crypto::shamir, grpc::{Convert, TryConvert}, peers::operator::vault_gate::{ - self as vault_gate, HandleBootstrapEncryptedKey, HandleHandshake, HandleUnsealEncryptedKey, + self as vault_gate, HandleBootstrapEncryptedKey, HandleContributeBootstrapPassphrase, + 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,7 @@ 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::unimplemented("Vault re-key is not available")), } } } @@ -73,6 +77,16 @@ impl TryConvert for UnsealRequestPayload { match self { Self::Start(start) => start.try_convert(), Self::EncryptedKey(key) => Ok(key.convert()), + Self::ContributePassphrase(passphrase) => { + Ok(vault_gate::Inbound::HandleContributeUnsealPassphrase( + HandleContributeUnsealPassphrase { + passphrase: passphrase.passphrase, + }, + )) + } + Self::ContributeRecoveryPassphrase(_) => Err(Status::unimplemented( + "Recovery operator contributions are not available", + )), } } } @@ -107,12 +121,52 @@ 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) => { + if dc.recovery_count != 0 { + return Err(Status::unimplemented( + "Recovery operator contributions are not available", + )); + } + let count = usize::try_from(dc.count) + .ok() + .filter(|count| *count <= shamir::MAX_COMMITTEE_SIZE) + .ok_or_else(|| { + Status::invalid_argument(format!( + "Committee count must not exceed {}", + shamir::MAX_COMMITTEE_SIZE + )) + })?; + Ok(vault_gate::Inbound::HandleDeclareCommittee( + HandleDeclareCommittee { count }, + )) + } + Self::ContributePassphrase(cp) => { + Ok(vault_gate::Inbound::HandleContributeBootstrapPassphrase( + HandleContributeBootstrapPassphrase { + passphrase: cp.passphrase, + }, + )) + } + Self::ContributeRecoveryPassphrase(_) => Err(Status::unimplemented( + "Recovery operator contributions are not available", + )), + } + } +} + 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 539d672..f7a17f2 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 @@ -1,10 +1,9 @@ use crate::{ - actors::vault::VaultState, + actors::{vault::VaultState, vault_coordinator}, grpc::{Convert, TryConvert}, 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; @@ -34,6 +34,26 @@ const fn wrap_unseal_response(payload: UnsealResponsePayload) -> OperatorRespons })) } +/// Ceremony errors are the operator's own doing far more often than ours, so +/// they travel back as a specific status instead of a blanket internal error. +fn ceremony_status(error: &vault_coordinator::Error) -> Status { + match error { + vault_coordinator::Error::AlreadyBootstrapping + | vault_coordinator::Error::AlreadyUnsealing + | vault_coordinator::Error::NotBootstrapping + | vault_coordinator::Error::DuplicateContribution => { + Status::failed_precondition(error.to_string()) + } + vault_coordinator::Error::EmptyCommittee + | vault_coordinator::Error::UnsupportedCommittee + | vault_coordinator::Error::CommitteeTooLarge => { + Status::invalid_argument(error.to_string()) + } + vault_coordinator::Error::InvalidPassphrase => Status::unauthenticated(error.to_string()), + _ => Status::internal("Vault ceremony failed"), + } +} + fn wrap_bootstrap_response(result: ProtoBootstrapResult) -> OperatorResponsePayload { wrap_vault_response(VaultResponsePayload::Bootstrap(proto_bootstrap::Response { result: result.into(), @@ -87,7 +107,6 @@ impl TryConvert for vault_gate::Outbound { let proto_result = match result { Ok(()) => ProtoUnsealResult::Success, Err(vault_gate::Error::InvalidKey) => ProtoUnsealResult::InvalidKey, - Err(vault_gate::Error::LockedOut) => ProtoUnsealResult::LockedOut, Err(err) => { warn!(?err, "unseal failed"); return Err(Status::internal("Failed to unseal vault")); @@ -111,6 +130,56 @@ impl TryConvert for vault_gate::Outbound { }; Ok(wrap_bootstrap_response(proto_result)) } + Self::HandleDeclareCommittee(result) => { + let proto_result = match result { + Ok(()) => ProtoBootstrapResult::AwaitingContributions, + Err(vault_gate::Error::Ceremony( + vault_coordinator::Error::AlreadyBootstrapped, + )) => ProtoBootstrapResult::AlreadyBootstrapped, + Err(vault_gate::Error::Ceremony(err)) => { + warn!(?err, "declare committee failed"); + return Err(ceremony_status(&err)); + } + 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(vault_gate::Error::Ceremony( + vault_coordinator::Error::AlreadyBootstrapped, + )) => ProtoBootstrapResult::AlreadyBootstrapped, + Err(vault_gate::Error::Ceremony(err)) => { + warn!(?err, "contribute bootstrap passphrase failed"); + return Err(ceremony_status(&err)); + } + Err(err) => { + warn!(?err, "contribute bootstrap passphrase failed"); + return Err(Status::internal( + "Failed to contribute 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) => { + warn!(?err, "contribute unseal passphrase failed"); + return Err(Status::internal("Failed to contribute unseal passphrase")); + } + }; + Ok(wrap_unseal_response(UnsealResponsePayload::Result( + proto_result.into(), + ))) + } } } } 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 a5134b1..27a4322 100644 --- a/server/crates/arbiter-server/src/peers/operator/auth/state.rs +++ b/server/crates/arbiter-server/src/peers/operator/auth/state.rs @@ -3,8 +3,8 @@ use super::{ Error, }; use crate::{ - actors::bootstrap::ConsumeToken, - db::{DatabasePool, schema::operator_identity}, + actors::bootstrap::VerifyToken, + db::{DatabasePool, models::OperatorId, schema::operator_identity}, peers::operator::auth::Outbound, }; use arbiter_crypto::authn::{self, AuthChallenge, OPERATOR_CONTEXT}; @@ -37,7 +37,10 @@ smlang::statemachine!( } ); -async fn get_client_id(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result, Error> { +async fn get_client_id( + db: &DatabasePool, + pubkey: &authn::PublicKey, +) -> Result, Error> { let mut conn = db.get().await.map_err(|e| { error!(error = ?e, "Database pool error"); Error::internal("Database unavailable") @@ -46,7 +49,7 @@ async fn get_client_id(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result(&mut conn) + .first::(&mut conn) .await .optional() .map_err(|e| { @@ -55,14 +58,14 @@ async fn get_client_id(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result Result { +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| { error!(error = ?e, "Database pool error"); Error::internal("Database unavailable") })?; - let id: i32 = diesel::insert_into(operator_identity::table) + let id: OperatorId = diesel::insert_into(operator_identity::table) .values((operator_identity::public_key.eq(pubkey_bytes),)) .returning(operator_identity::id) .get_result(&mut conn) @@ -156,7 +159,7 @@ where .conn .actors .bootstrapper - .ask(ConsumeToken { token }) + .ask(VerifyToken { token }) .await .map_err(|e| { error!(?e, "Failed to consume bootstrap token"); diff --git a/server/crates/arbiter-server/src/peers/operator/mod.rs b/server/crates/arbiter-server/src/peers/operator/mod.rs index 0869d51..78a7edb 100644 --- a/server/crates/arbiter-server/src/peers/operator/mod.rs +++ b/server/crates/arbiter-server/src/peers/operator/mod.rs @@ -4,7 +4,7 @@ use crate::{ vault::{GetState, Vault}, }, crypto::integrity::{self, AttestationStatus, Integrable}, - db::{DatabaseError, DatabasePool}, + db::{DatabaseError, DatabasePool, models::OperatorId}, peers::client::ClientProfile, }; use arbiter_crypto::authn; @@ -25,7 +25,7 @@ pub mod vault_gate; #[derive(Debug, Clone, Hashable)] pub struct Credentials { - pub id: i32, + pub id: OperatorId, pub pubkey: authn::PublicKey, } 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 d0e38e3..54702a7 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 @@ -3,8 +3,9 @@ use crate::{ actors::{ GlobalActors, vault::{self, Bootstrap, GetState, TryUnseal, VaultState, events}, + vault_coordinator::{self, ContributeBootstrap, ContributeUnseal, StartBootstrap}, }, - crypto::integrity::{self}, + crypto::{KeyCell, integrity}, db::DatabasePool, }; use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; @@ -27,17 +28,27 @@ pub enum Error { InvalidKey, #[error("Vault locked: too many failed unseal attempts")] LockedOut, - #[error("State transition failed")] State, - + #[error("Vault ceremony failed: {0}")] + Ceremony(#[from] vault_coordinator::Error), #[error("Internal error: {0}")] Internal(String), } + impl Error { fn internal(message: impl Into) -> Self { Self::Internal(message.into()) } + + /// Preserve the coordinator's own error so the operator learns why a + /// ceremony was refused instead of reading "internal error". + fn ceremony(error: SendError) -> Self { + match error { + SendError::HandlerError(inner) => Self::Ceremony(inner), + _ => Self::internal("VaultCoordinator unavailable"), + } + } } pub struct HandshakeResponse { @@ -71,7 +82,6 @@ impl VaultGate { impl Actor for VaultGate { type Args = Self; - type Error = (); async fn on_start( @@ -102,11 +112,8 @@ impl VaultGate { associated_data: &[u8], ) -> 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| { cipher.decrypt_in_place(nonce, associated_data, write_handle) }); @@ -119,9 +126,13 @@ impl VaultGate { } } } + + fn key_cell(buffer: SafeCell>) -> Result { + KeyCell::try_from(buffer).map_err(|()| Error::InvalidKey) + } } -#[messages(messages = Inbound, replies = Outbound)] +#[messages] impl VaultGate { #[message] pub fn handle_handshake( @@ -130,14 +141,11 @@ impl VaultGate { ) -> Result { let ephemeral_secret = EphemeralSecret::random(); let public_key = PublicKey::from(&ephemeral_secret); - let secret = ephemeral_secret.diffie_hellman(&client_pubkey); - self.state = State::ReadyForExchange { server_key: public_key, secret, }; - Ok(HandshakeResponse { server_pubkey: public_key, }) @@ -153,20 +161,11 @@ impl VaultGate { let State::ReadyForExchange { secret, .. } = &self.state else { return Err(Error::State); }; + let seal_key = Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data) + .map_err(|()| Error::InvalidKey) + .and_then(Self::key_cell)?; - let Ok(seal_key_buffer) = 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, - }) - .await - { + match self.actors.vault.ask(TryUnseal { seal_key }).await { Ok(()) => { info!("Successfully unsealed key with client-provided key"); Ok(()) @@ -194,17 +193,16 @@ impl VaultGate { 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 { - return Err(Error::InvalidKey); - }; + let seal_key = Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data) + .map_err(|()| Error::InvalidKey) + .and_then(Self::key_cell)?; match self .actors .vault .ask(Bootstrap { - seal_key_raw: seal_key_buffer, + seal_key, + custody: None, }) .await { @@ -228,14 +226,53 @@ impl VaultGate { #[message] pub async fn handle_vault_state(&mut self) -> Result { - let answer = self - .actors + self.actors .vault .ask(GetState {}) .await - .map_err(|_| Error::internal("failed to query vault"))?; + .map_err(|_| Error::internal("failed to query vault")) + } - Ok(answer) + #[message] + pub async fn handle_declare_committee(&mut self, count: usize) -> Result<(), Error> { + self.actors + .vault_coordinator + .ask(StartBootstrap { + operator_id: self.auth_creds.id, + declared_count: count, + }) + .await + .map_err(Error::ceremony) + } + + #[message] + pub async fn handle_contribute_bootstrap_passphrase( + &mut self, + passphrase: Vec, + ) -> Result { + self.actors + .vault_coordinator + .ask(ContributeBootstrap { + operator_id: self.auth_creds.id, + passphrase: SafeCell::new(passphrase), + }) + .await + .map_err(Error::ceremony) + } + + #[message] + pub async fn handle_contribute_unseal_passphrase( + &mut self, + passphrase: Vec, + ) -> Result { + self.actors + .vault_coordinator + .ask(ContributeUnseal { + operator_id: self.auth_creds.id, + passphrase: SafeCell::new(passphrase), + }) + .await + .map_err(Error::ceremony) } } @@ -289,3 +326,75 @@ impl Message for VaultGate { ctx.stop(); } } + +pub enum Inbound { + HandleHandshake(HandleHandshake), + HandleUnsealEncryptedKey(HandleUnsealEncryptedKey), + HandleBootstrapEncryptedKey(HandleBootstrapEncryptedKey), + HandleVaultState, + HandleDeclareCommittee(HandleDeclareCommittee), + HandleContributeBootstrapPassphrase(HandleContributeBootstrapPassphrase), + HandleContributeUnsealPassphrase(HandleContributeUnsealPassphrase), +} + +pub enum Outbound { + HandleHandshake(Result), + HandleUnsealEncryptedKey(Result<(), Error>), + HandleBootstrapEncryptedKey(Result<(), Error>), + HandleVaultState(Result), + HandleDeclareCommittee(Result<(), Error>), + HandleContributeBootstrapPassphrase(Result), + HandleContributeUnsealPassphrase(Result), +} + +impl Message for VaultGate { + type Reply = Result; + + async fn handle( + &mut self, + msg: Inbound, + _ctx: &mut kameo::prelude::Context, + ) -> Self::Reply { + match msg { + Inbound::HandleHandshake(message) => Ok(Outbound::HandleHandshake( + self.handle_handshake(message.client_pubkey), + )), + Inbound::HandleUnsealEncryptedKey(message) => Ok(Outbound::HandleUnsealEncryptedKey( + self.handle_unseal_encrypted_key( + message.nonce, + message.ciphertext, + message.associated_data, + ) + .await, + )), + Inbound::HandleBootstrapEncryptedKey(message) => { + Ok(Outbound::HandleBootstrapEncryptedKey( + self.handle_bootstrap_encrypted_key( + message.nonce, + message.ciphertext, + message.associated_data, + ) + .await, + )) + } + Inbound::HandleVaultState => { + Ok(Outbound::HandleVaultState(self.handle_vault_state().await)) + } + Inbound::HandleDeclareCommittee(message) => Ok(Outbound::HandleDeclareCommittee( + self.handle_declare_committee(message.count).await, + )), + Inbound::HandleContributeBootstrapPassphrase(message) => { + Ok(Outbound::HandleContributeBootstrapPassphrase( + self.handle_contribute_bootstrap_passphrase(message.passphrase) + .await, + )) + } + Inbound::HandleContributeUnsealPassphrase(message) => { + Ok(Outbound::HandleContributeUnsealPassphrase( + self.handle_contribute_unseal_passphrase(message.passphrase) + .await, + )) + } + } + } +} diff --git a/server/crates/arbiter-server/tests/client/auth.rs b/server/crates/arbiter-server/tests/client/auth.rs index 433712d..8b9b462 100644 --- a/server/crates/arbiter-server/tests/client/auth.rs +++ b/server/crates/arbiter-server/tests/client/auth.rs @@ -1,15 +1,12 @@ use super::common::ChannelTransport; -use arbiter_crypto::{ - authn::{self, AuthChallenge, CLIENT_CONTEXT}, - safecell::{SafeCell, SafeCellHandle as _}, -}; +use arbiter_crypto::authn::{self, AuthChallenge, CLIENT_CONTEXT}; use arbiter_proto::{ ClientMetadata, transport::{Receiver, Sender}, }; use arbiter_server::{ actors::{GlobalActors, vault::Bootstrap}, - crypto::integrity, + crypto::{KeyCell, integrity}, db::{self, schema}, peers::client::{ClientConnection, ClientCredentials, auth, connect_client}, }; @@ -100,7 +97,8 @@ async fn spawn_test_actors(db: &db::DatabasePool) -> GlobalActors { actors .vault .ask(Bootstrap { - seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()), + seal_key: KeyCell::from([0u8; 32]), + custody: None, }) .await .unwrap(); @@ -340,7 +338,10 @@ pub async fn metadata_frozen_after_approval_ignores_reconnect_changes() { .first::<(String, Option, Option)>(&mut conn) .await .unwrap(); - assert_eq!(metadata_count, 1, "frozen: no new metadata row on reconnect"); + assert_eq!( + metadata_count, 1, + "frozen: no new metadata row on reconnect" + ); assert_eq!(history_count, 0, "frozen: no history entry on reconnect"); assert_eq!( current, diff --git a/server/crates/arbiter-server/tests/common/mod.rs b/server/crates/arbiter-server/tests/common/mod.rs index 83a2f81..5878a97 100644 --- a/server/crates/arbiter-server/tests/common/mod.rs +++ b/server/crates/arbiter-server/tests/common/mod.rs @@ -2,24 +2,30 @@ 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}, - db::{self, schema}, + crypto::KeyCell, + db::{self, custody::DieselCustodyStore, schema}, }; use async_trait::async_trait; use diesel::QueryDsl; use diesel_async::RunQueryDsl; +use std::sync::Arc; use tokio::sync::mpsc; pub(crate) async fn bootstrapped_vault(db: &db::DatabasePool) -> Vault { - let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus()) - .await - .unwrap(); + let mut actor = Vault::new( + db.clone(), + GlobalActors::spawn_message_bus(), + Arc::new(DieselCustodyStore), + ) + .await + .unwrap(); actor - .bootstrap(SafeCell::new(b"test-seal-key".to_vec())) + .bootstrap(KeyCell::from([0u8; 32]), None) .await .unwrap(); actor diff --git a/server/crates/arbiter-server/tests/operator/auth.rs b/server/crates/arbiter-server/tests/operator/auth.rs index 18c5503..416a439 100644 --- a/server/crates/arbiter-server/tests/operator/auth.rs +++ b/server/crates/arbiter-server/tests/operator/auth.rs @@ -1,13 +1,10 @@ use super::common::ChannelTransport; -use arbiter_crypto::{ - authn::{self, AuthChallenge, OPERATOR_CONTEXT}, - safecell::{SafeCell, SafeCellHandle as _}, -}; +use arbiter_crypto::authn::{self, AuthChallenge, OPERATOR_CONTEXT}; use arbiter_proto::transport::{Error as TransportError, Receiver, Sender}; use arbiter_server::{ actors::{GlobalActors, bootstrap::GetToken, vault::Bootstrap}, - crypto::integrity, - db::{self, schema}, + crypto::{KeyCell, integrity}, + db::{self, models::OperatorId, schema}, peers::operator::{self, Credentials, OperatorConnection, auth, vault_gate}, }; @@ -154,13 +151,6 @@ impl Sender for StartTestTransport { 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 token = actors.bootstrapper.ask(GetToken).await.unwrap().unwrap(); let (mut server_transport, mut test_transport) = ChannelTransport::new(); @@ -275,7 +265,8 @@ pub async fn challenge_auth() { actors .vault .ask(Bootstrap { - seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()), + seal_key: KeyCell::from([0u8; 32]), + custody: None, }) .await .unwrap(); @@ -285,10 +276,10 @@ pub async fn challenge_auth() { { let mut conn = db.get().await.unwrap(); - let id: i32 = insert_into(schema::operator_identity::table) + let id: OperatorId = insert_into(schema::operator_identity::table) .values((schema::operator_identity::public_key.eq(pubkey_bytes.clone()),)) .returning(schema::operator_identity::id) - .get_result(&mut conn) + .get_result::(&mut conn) .await .unwrap(); integrity::sign_entity( @@ -361,7 +352,8 @@ pub async fn challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() { actors .vault .ask(Bootstrap { - seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()), + seal_key: KeyCell::from([0u8; 32]), + custody: None, }) .await .unwrap(); @@ -434,7 +426,8 @@ pub async fn challenge_auth_rejects_invalid_signature() { actors .vault .ask(Bootstrap { - seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()), + seal_key: KeyCell::from([0u8; 32]), + custody: None, }) .await .unwrap(); @@ -444,10 +437,10 @@ pub async fn challenge_auth_rejects_invalid_signature() { { let mut conn = db.get().await.unwrap(); - let id: i32 = insert_into(schema::operator_identity::table) + let id: OperatorId = insert_into(schema::operator_identity::table) .values((schema::operator_identity::public_key.eq(pubkey_bytes.clone()),)) .returning(schema::operator_identity::id) - .get_result(&mut conn) + .get_result::(&mut conn) .await .unwrap(); integrity::sign_entity( @@ -506,3 +499,92 @@ pub async fn challenge_auth_rejects_invalid_signature() { Err(auth::Error::InvalidChallengeSolution) )); } + +/// The bootstrap token authorises registering committee members *before* the +/// vault exists. Once any bootstrap path succeeds it must stop working, or its +/// holder could keep minting operator identities until the next restart. +#[tokio::test] +#[test_log::test] +pub async fn bootstrap_token_rejected_after_bootstrap() { + let db = db::create_test_pool().await; + let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let token = actors.bootstrapper.ask(GetToken).await.unwrap().unwrap(); + + actors + .vault + .ask(Bootstrap { + seal_key: KeyCell::from([0u8; 32]), + custody: None, + }) + .await + .unwrap(); + + // `Bootstrapped` travels through the message bus, so the token disappears + // a couple of actor turns after the bootstrap call returns. + let mut retired = false; + for _ in 0..100 { + if actors.bootstrapper.ask(GetToken).await.unwrap().is_none() { + retired = true; + break; + } + tokio::task::yield_now().await; + } + assert!( + retired, + "the bootstrap token must be retired once the vault is bootstrapped" + ); + + 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.into_bytes()), + }) + .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, Err(auth::Error::InvalidBootstrapToken)), + "a spent bootstrap token must not authorise a new identity, got {response:?}" + ); + assert!(task.await.unwrap().is_err(), "authentication must fail"); + + let mut conn = db.get().await.unwrap(); + let registered: i64 = schema::operator_identity::table + .count() + .get_result(&mut conn) + .await + .unwrap(); + assert_eq!( + registered, 0, + "no identity may be registered after the bootstrap" + ); +} diff --git a/server/crates/arbiter-server/tests/operator/unseal.rs b/server/crates/arbiter-server/tests/operator/unseal.rs index 365e6ec..1e6523e 100644 --- a/server/crates/arbiter-server/tests/operator/unseal.rs +++ b/server/crates/arbiter-server/tests/operator/unseal.rs @@ -1,13 +1,11 @@ -use arbiter_crypto::{ - authn, - safecell::{SafeCell, SafeCellHandle as _}, -}; +use arbiter_crypto::authn; use arbiter_server::{ actors::{ GlobalActors, vault::{Bootstrap, Seal}, }, - db, + crypto::KeyCell, + db::{self, models::OperatorId}, peers::operator::{ Credentials, vault_gate::{ @@ -22,7 +20,7 @@ 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, @@ -34,7 +32,8 @@ async fn setup_sealed_gate( actors .vault .ask(Bootstrap { - seal_key_raw: SafeCell::new(seal_key.to_vec()), + seal_key: KeyCell::from(seal_key), + custody: None, }) .await .unwrap(); @@ -42,7 +41,10 @@ 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 = Credentials { + id: OperatorId::from_raw(1), + pubkey, + }; let gate = VaultGate::spawn(VaultGate::new(auth_creds, actors, db.clone(), promotion_tx)); (db, gate, promotion_rx) @@ -83,10 +85,10 @@ 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 = [7u8; 32]; let (_db, gate, _promotion_rx) = setup_sealed_gate(seal_key).await; - let encrypted_key = client_dh_encrypt(&gate, seal_key).await; + let encrypted_key = client_dh_encrypt(&gate, &seal_key).await; let response = gate.ask(encrypted_key).await; assert!(matches!(response, Ok(()))); @@ -95,10 +97,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 = [7u8; 32]; 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, &[8u8; 32]).await; let response = gate.ask(encrypted_key).await; assert!(matches!( @@ -112,7 +114,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 = [7u8; 32]; let (_db, gate, _promotion_rx) = setup_sealed_gate(seal_key).await; let client_secret = EphemeralSecret::random(); @@ -143,11 +145,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 = [9u8; 32]; 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, &[8u8; 32]).await; let response = gate.ask(encrypted_key).await; assert!(matches!( @@ -159,7 +161,7 @@ pub async fn unseal_retry_after_invalid_key() { } { - let encrypted_key = client_dh_encrypt(&gate, seal_key).await; + let encrypted_key = client_dh_encrypt(&gate, &seal_key).await; let response = gate.ask(encrypted_key).await; assert!(matches!(response, Ok(()))); diff --git a/server/crates/arbiter-server/tests/vault.rs b/server/crates/arbiter-server/tests/vault.rs index c7640a8..cd3b61e 100644 --- a/server/crates/arbiter-server/tests/vault.rs +++ b/server/crates/arbiter-server/tests/vault.rs @@ -2,6 +2,8 @@ mod common; #[path = "vault/concurrency.rs"] mod concurrency; +#[path = "vault/custody.rs"] +mod custody; #[path = "vault/lifecycle.rs"] mod lifecycle; #[path = "vault/storage.rs"] diff --git a/server/crates/arbiter-server/tests/vault/concurrency.rs b/server/crates/arbiter-server/tests/vault/concurrency.rs index ee77d19..0cd156c 100644 --- a/server/crates/arbiter-server/tests/vault/concurrency.rs +++ b/server/crates/arbiter-server/tests/vault/concurrency.rs @@ -5,13 +5,17 @@ use arbiter_server::{ GlobalActors, vault::{CreateNew, Error, Vault}, }, - db::{self, models, schema}, + crypto::KeyCell, + db::{self, custody::DieselCustodyStore, models, schema}, }; use diesel::{ExpressionMethods as _, QueryDsl, SelectableHelper, dsl::sql_query}; use diesel_async::RunQueryDsl; use kameo::actor::{ActorRef, Spawn as _}; -use std::collections::{HashMap, HashSet}; +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; use tokio::task::JoinSet; const TEST_AAD: &[u8] = b"test-aad"; @@ -165,11 +169,15 @@ async fn decrypt_roundtrip_after_high_concurrency() { let writes = write_concurrently(actor, "roundtrip", 40).await; let expected: HashMap> = writes.into_iter().collect(); - let mut decryptor = Vault::new(db.clone(), GlobalActors::spawn_message_bus()) - .await - .unwrap(); + let mut decryptor = Vault::new( + db.clone(), + GlobalActors::spawn_message_bus(), + Arc::new(DieselCustodyStore), + ) + .await + .unwrap(); decryptor - .try_unseal(SafeCell::new(b"test-seal-key".to_vec())) + .try_unseal(KeyCell::from([0u8; 32])) .await .unwrap(); diff --git a/server/crates/arbiter-server/tests/vault/custody.rs b/server/crates/arbiter-server/tests/vault/custody.rs new file mode 100644 index 0000000..895351e --- /dev/null +++ b/server/crates/arbiter-server/tests/vault/custody.rs @@ -0,0 +1,317 @@ +//! End-to-end coverage for the Shamir custody ceremonies. + +use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; +use arbiter_server::{ + actors::{ + GlobalActors, + vault::{Bootstrap, GetState, Seal, VaultState}, + vault_coordinator::{ContributeBootstrap, ContributeUnseal, StartBootstrap}, + }, + crypto::{KeyCell, shamir}, + db::{self, models::OperatorId, schema}, +}; + +use diesel::{ExpressionMethods as _, QueryDsl}; +use diesel_async::RunQueryDsl; + +/// Register `count` operator identities so committee members satisfy the +/// foreign key from `operator` to `operator_identity`. +async fn register_operators(db: &db::DatabasePool, count: usize) -> Vec { + let mut conn = db.get().await.unwrap(); + let mut ids = Vec::with_capacity(count); + for index in 0..count { + let pubkey = vec![u8::try_from(index).unwrap(); 32]; + let id: OperatorId = diesel::insert_into(schema::operator_identity::table) + .values((schema::operator_identity::public_key.eq(pubkey),)) + .returning(schema::operator_identity::id) + .get_result(&mut conn) + .await + .unwrap(); + ids.push(id); + } + ids +} + +async fn stored_share_count(db: &db::DatabasePool) -> i64 { + let mut conn = db.get().await.unwrap(); + schema::operator::table + .count() + .get_result(&mut conn) + .await + .unwrap() +} + +async fn stored_threshold(db: &db::DatabasePool) -> Option { + let mut conn = db.get().await.unwrap(); + schema::arbiter_settings::table + .select(schema::arbiter_settings::shamir_threshold) + .first(&mut conn) + .await + .unwrap() +} + +fn passphrase(seed: u8) -> SafeCell> { + SafeCell::new(vec![seed; 16]) +} + +/// The happy path: three operators bootstrap, and any two of them reopen the +/// vault after it is sealed. +#[tokio::test] +#[test_log::test] +async fn committee_of_three_unseals_with_two_passphrases() { + let db = db::create_test_pool().await; + let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let operators = register_operators(&db, 3).await; + + actors + .vault_coordinator + .ask(StartBootstrap { + operator_id: operators[0], + declared_count: 3, + }) + .await + .unwrap(); + + for (index, operator_id) in operators.iter().enumerate() { + let finished = actors + .vault_coordinator + .ask(ContributeBootstrap { + operator_id: *operator_id, + passphrase: passphrase(u8::try_from(index).unwrap()), + }) + .await + .unwrap(); + assert_eq!( + finished, + index == 2, + "the ceremony finishes only on the last contribution" + ); + } + + assert_eq!( + actors.vault.ask(GetState {}).await.unwrap(), + VaultState::Unsealed + ); + assert_eq!(stored_share_count(&db).await, 3); + assert_eq!(stored_threshold(&db).await, Some(2)); + + actors.vault.ask(Seal {}).await.unwrap(); + + let first = actors + .vault_coordinator + .ask(ContributeUnseal { + operator_id: operators[0], + passphrase: passphrase(0), + }) + .await + .unwrap(); + assert!(!first, "one of two shares must not unseal"); + + let second = actors + .vault_coordinator + .ask(ContributeUnseal { + operator_id: operators[2], + passphrase: passphrase(2), + }) + .await + .unwrap(); + assert!(second, "the threshold contribution should unseal the vault"); + assert_eq!( + actors.vault.ask(GetState {}).await.unwrap(), + VaultState::Unsealed + ); +} + +/// Shares that describe a seal key the vault never adopted would make the vault +/// permanently un-unsealable, so a refused bootstrap must leave the table empty. +#[tokio::test] +#[test_log::test] +async fn refused_bootstrap_stores_no_shares() { + let db = db::create_test_pool().await; + let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let operators = register_operators(&db, 1).await; + + // Another path bootstraps the vault first; the ceremony now has nowhere to go. + actors + .vault + .ask(Bootstrap { + seal_key: KeyCell::from([4u8; 32]), + custody: None, + }) + .await + .unwrap(); + + actors + .vault_coordinator + .ask(StartBootstrap { + operator_id: operators[0], + declared_count: 1, + }) + .await + .unwrap(); + + let error = actors + .vault_coordinator + .ask(ContributeBootstrap { + operator_id: operators[0], + passphrase: passphrase(1), + }) + .await + .expect_err("bootstrapping an already bootstrapped vault must fail"); + assert!( + format!("{error:?}").contains("AlreadyBootstrapped"), + "expected AlreadyBootstrapped, got {error:?}" + ); + + assert_eq!( + stored_share_count(&db).await, + 0, + "a refused bootstrap must not leave shares behind" + ); + assert_eq!( + stored_threshold(&db).await, + None, + "a refused bootstrap must not leave a threshold behind" + ); +} + +#[tokio::test] +#[test_log::test] +async fn oversized_and_degenerate_committees_are_rejected() { + let db = db::create_test_pool().await; + let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let operators = register_operators(&db, 1).await; + + for (count, expected) in [ + (0_usize, "EmptyCommittee"), + (2, "UnsupportedCommittee"), + (shamir::MAX_COMMITTEE_SIZE + 1, "CommitteeTooLarge"), + (usize::MAX, "CommitteeTooLarge"), + ] { + let error = actors + .vault_coordinator + .ask(StartBootstrap { + operator_id: operators[0], + declared_count: count, + }) + .await + .expect_err("committee size must be rejected"); + assert!( + format!("{error:?}").contains(expected), + "expected {expected} for count {count}, got {error:?}" + ); + } +} + +/// A committee whose members never all show up would otherwise wedge the +/// coordinator until restart. Only the operator that declared it may reset it. +#[tokio::test] +#[test_log::test] +async fn only_the_declarer_may_restart_a_stalled_committee() { + let db = db::create_test_pool().await; + let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let operators = register_operators(&db, 3).await; + + actors + .vault_coordinator + .ask(StartBootstrap { + operator_id: operators[0], + declared_count: 3, + }) + .await + .unwrap(); + actors + .vault_coordinator + .ask(ContributeBootstrap { + operator_id: operators[0], + passphrase: passphrase(0), + }) + .await + .unwrap(); + + let error = actors + .vault_coordinator + .ask(StartBootstrap { + operator_id: operators[1], + declared_count: 3, + }) + .await + .expect_err("a bystander must not reset someone else's ceremony"); + assert!( + format!("{error:?}").contains("AlreadyBootstrapping"), + "expected AlreadyBootstrapping, got {error:?}" + ); + + actors + .vault_coordinator + .ask(StartBootstrap { + operator_id: operators[0], + declared_count: 1, + }) + .await + .expect("the declarer may restart the ceremony"); + + let finished = actors + .vault_coordinator + .ask(ContributeBootstrap { + operator_id: operators[0], + passphrase: passphrase(0), + }) + .await + .unwrap(); + assert!( + finished, + "the restarted one-operator ceremony should complete" + ); + assert_eq!(stored_share_count(&db).await, 1); +} + +/// A wrong passphrase must not unseal, and the operator must be able to retry. +#[tokio::test] +#[test_log::test] +async fn wrong_passphrase_is_rejected_and_retryable() { + let db = db::create_test_pool().await; + let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let operators = register_operators(&db, 1).await; + + actors + .vault_coordinator + .ask(StartBootstrap { + operator_id: operators[0], + declared_count: 1, + }) + .await + .unwrap(); + actors + .vault_coordinator + .ask(ContributeBootstrap { + operator_id: operators[0], + passphrase: passphrase(7), + }) + .await + .unwrap(); + actors.vault.ask(Seal {}).await.unwrap(); + + let error = actors + .vault_coordinator + .ask(ContributeUnseal { + operator_id: operators[0], + passphrase: passphrase(8), + }) + .await + .expect_err("a wrong passphrase must not unseal"); + assert!( + format!("{error:?}").contains("InvalidPassphrase"), + "expected InvalidPassphrase, got {error:?}" + ); + + let unsealed = actors + .vault_coordinator + .ask(ContributeUnseal { + operator_id: operators[0], + passphrase: passphrase(7), + }) + .await + .expect("the operator may retry with the correct passphrase"); + assert!(unsealed, "the retry should unseal the vault"); +} diff --git a/server/crates/arbiter-server/tests/vault/lifecycle.rs b/server/crates/arbiter-server/tests/vault/lifecycle.rs index c7096ee..085bb36 100644 --- a/server/crates/arbiter-server/tests/vault/lifecycle.rs +++ b/server/crates/arbiter-server/tests/vault/lifecycle.rs @@ -5,12 +5,16 @@ use arbiter_server::{ GlobalActors, vault::{Error, Vault}, }, - crypto::encryption::v1::{Nonce, ROOT_KEY_TAG}, - db::{self, models, schema}, + crypto::{ + KeyCell, + encryption::v1::{Nonce, ROOT_KEY_TAG}, + }, + db::{self, custody::DieselCustodyStore, models, schema}, }; use diesel::{QueryDsl, SelectableHelper}; use diesel_async::RunQueryDsl; +use std::sync::Arc; const TEST_AAD: &[u8] = b"test-aad"; @@ -18,12 +22,16 @@ const TEST_AAD: &[u8] = b"test-aad"; #[test_log::test] async fn bootstrap() { let db = db::create_test_pool().await; - let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus()) - .await - .unwrap(); + let mut actor = Vault::new( + db.clone(), + GlobalActors::spawn_message_bus(), + Arc::new(DieselCustodyStore), + ) + .await + .unwrap(); - let seal_key = SafeCell::new(b"test-seal-key".to_vec()); - actor.bootstrap(seal_key).await.unwrap(); + let seal_key = KeyCell::from([0u8; 32]); + actor.bootstrap(seal_key, None).await.unwrap(); let mut conn = db.get().await.unwrap(); let row: models::RootKeyHistory = schema::root_key_history::table @@ -45,8 +53,8 @@ async fn 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 err = actor.bootstrap(seal_key2).await.unwrap_err(); + let seal_key2 = KeyCell::from([0u8; 32]); + let err = actor.bootstrap(seal_key2, None).await.unwrap_err(); assert!(matches!(err, Error::AlreadyBootstrapped)); } @@ -54,9 +62,13 @@ async fn bootstrap_rejects_double() { #[test_log::test] async fn create_new_before_bootstrap_fails() { let db = db::create_test_pool().await; - let mut actor = Vault::new(db, GlobalActors::spawn_message_bus()) - .await - .unwrap(); + let mut actor = Vault::new( + db, + GlobalActors::spawn_message_bus(), + Arc::new(DieselCustodyStore), + ) + .await + .unwrap(); let err = actor .create_new(SafeCell::new(b"data".to_vec()), TEST_AAD.to_vec()) @@ -69,9 +81,13 @@ async fn create_new_before_bootstrap_fails() { #[test_log::test] async fn decrypt_before_bootstrap_fails() { let db = db::create_test_pool().await; - let mut actor = Vault::new(db, GlobalActors::spawn_message_bus()) - .await - .unwrap(); + let mut actor = Vault::new( + db, + GlobalActors::spawn_message_bus(), + Arc::new(DieselCustodyStore), + ) + .await + .unwrap(); let err = actor.decrypt(1, TEST_AAD.to_vec()).await.unwrap_err(); assert!(matches!(err, Error::NotBootstrapped)); @@ -84,9 +100,13 @@ async fn new_restores_sealed_state() { let actor = common::bootstrapped_vault(&db).await; drop(actor); - let mut actor2 = Vault::new(db, GlobalActors::spawn_message_bus()) - .await - .unwrap(); + let mut actor2 = Vault::new( + db, + GlobalActors::spawn_message_bus(), + Arc::new(DieselCustodyStore), + ) + .await + .unwrap(); let err = actor2.decrypt(1, TEST_AAD.to_vec()).await.unwrap_err(); assert!(matches!(err, Error::Sealed)); } @@ -104,10 +124,14 @@ async fn unseal_correct_password() { .unwrap(); drop(actor); - 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 mut actor = Vault::new( + db.clone(), + GlobalActors::spawn_message_bus(), + Arc::new(DieselCustodyStore), + ) + .await + .unwrap(); + let seal_key = KeyCell::from([0u8; 32]); actor.try_unseal(seal_key).await.unwrap(); let mut decrypted = actor.decrypt(aead_id, TEST_AAD.to_vec()).await.unwrap(); @@ -127,15 +151,19 @@ async fn unseal_wrong_then_correct_password() { .unwrap(); drop(actor); - let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus()) - .await - .unwrap(); + let mut actor = Vault::new( + db.clone(), + GlobalActors::spawn_message_bus(), + Arc::new(DieselCustodyStore), + ) + .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, TEST_AAD.to_vec()).await.unwrap();